diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bb5731af..c20a012f 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -4,7 +4,7 @@ When making changes in this repository: - Keep changes focused and avoid unrelated refactors. - Follow the existing PHP style: add `declare(strict_types=1);` to PHP files and keep code PSR-12 compatible. -- Preserve the existing architecture around `Workflow`, `Activity`, workflow stubs, signals, and generator-based workflow execution. +- Preserve the existing architecture around `Workflow`, `Activity`, workflow stubs, signals, and straight-line Fiber-based workflow execution. - Add or update PHPUnit tests when behavior changes. Keep unit tests in `tests/Unit` and integration-style coverage in `tests/Feature`. - Validate changes with the project quality cycle from the repository root: 1. `composer ecs` @@ -12,5 +12,5 @@ When making changes in this repository: 3. `composer unit` 4. `composer coverage` 5. `composer feature` -- Treat `composer coverage` as a hard requirement: unit-test coverage must remain at 100%. +- Treat `composer coverage` as a hard requirement: coverage must not fall below the versioned repository contract. - Prefer solutions that stay compatible with the supported Laravel versions declared in `composer.json`. diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 87057aa5..91f0a15b 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -70,7 +70,6 @@ jobs: - name: Validate CI script syntax run: | bash -n \ - scripts/ci/check-docs-release-audit.sh \ scripts/ci/laravel-embedded-upgrade-smoke.sh \ scripts/ci/check-packagist-release.sh \ scripts/ci/check-platform-conformance-mirror.sh \ diff --git a/.github/workflows/release-docs-audit.yml b/.github/workflows/release-verification.yml similarity index 50% rename from .github/workflows/release-docs-audit.yml rename to .github/workflows/release-verification.yml index 7f325119..617c5ec2 100644 --- a/.github/workflows/release-docs-audit.yml +++ b/.github/workflows/release-verification.yml @@ -1,24 +1,22 @@ -name: Release Docs Audit +name: Release Verification on: push: tags: - - '2.0.0-alpha.*' - - '2.0.0-beta.*' - - '2.0.0-rc.*' - - '2.0.0' + - '2.*' + - '!2.*-*' workflow_dispatch: inputs: tag: - description: 'Release tag to audit, for example 2.0.0-alpha.201' + description: 'Stable 2.x release tag, for example 2.0.1' required: true permissions: contents: read jobs: - release-artifact: - name: Verify published Workflow artifact + published-package: + name: Verify published Workflow package runs-on: ubuntu-latest outputs: laravel_published_matrix: ${{ steps.laravel-matrix.outputs.matrix }} @@ -33,11 +31,11 @@ jobs: php-version: '8.3' tools: composer - - name: Derive exhaustive Laravel release qualification + - name: Derive supported Laravel upgrade matrix id: laravel-matrix run: echo "matrix=$(php scripts/ci/laravel-embedded-upgrade-matrix.php --scope=published)" >> "$GITHUB_OUTPUT" - - name: Resolve release tag + - name: Resolve stable release tag id: release shell: bash env: @@ -50,8 +48,8 @@ jobs: tag="$GITHUB_REF_NAME" fi tag="${tag#v}" - if ! printf '%s\n' "$tag" | grep -Eq '^2\.0\.0((-(alpha|beta|rc)\.[0-9]+)?)$'; then - echo "::error::Invalid Workflow release tag for docs audit: $tag" + if ! printf '%s\n' "$tag" | grep -Eq '^2\.[0-9]+\.[0-9]+$'; then + echo "::error::Expected a stable Workflow 2.x tag, got: $tag" exit 1 fi echo "tag=$tag" >> "$GITHUB_OUTPUT" @@ -62,47 +60,20 @@ jobs: PACKAGIST_PACKAGE_VERSION: ${{ steps.release.outputs.tag }} run: scripts/ci/check-packagist-release.sh - - name: Verify published platform conformance mirror + - name: Verify published platform contract env: WORKFLOW_PLATFORM_CONFORMANCE_VERSION: ${{ steps.release.outputs.tag }} run: scripts/ci/check-platform-conformance-mirror.sh - docs-release-audit: - name: Verify public docs release-audit tuple - needs: release-artifact - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - persist-credentials: false - - - name: Require live docs release audit refresh - env: - DOCS_RELEASE_AUDIT_ARTIFACT: workflow - DOCS_RELEASE_AUDIT_VERSION: ${{ needs.release-artifact.outputs.release_tag }} - DOCS_RELEASE_AUDIT_EVIDENCE: docs-release-audit-evidence.json - DOCS_RELEASE_AUDIT_HANDOFF: docs-release-audit-handoff.json - run: scripts/ci/check-docs-release-audit.sh - - - name: Upload docs release audit evidence - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: docs-release-audit-evidence - path: | - docs-release-audit-evidence.json - docs-release-audit-handoff.json - if-no-files-found: warn - - laravel-embedded-upgrade-published: - name: Published Laravel ${{ matrix.laravel }} / PHP ${{ matrix.php }} upgrade - needs: release-artifact + laravel-upgrade: + name: Laravel ${{ matrix.laravel }} / PHP ${{ matrix.php }} published upgrade + needs: published-package runs-on: ubuntu-latest timeout-minutes: 25 strategy: fail-fast: false - matrix: ${{ fromJSON(needs.release-artifact.outputs.laravel_published_matrix) }} + matrix: ${{ fromJSON(needs.published-package.outputs.laravel_published_matrix) }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 @@ -114,8 +85,9 @@ jobs: php-version: ${{ matrix.php }} tools: composer - - name: Run the clean published-artifact upgrade journey + - name: Run the published-package upgrade journey env: LARAVEL_VERSION: ${{ matrix.laravel }} - WORKFLOW_V2_VERSION: ${{ needs.release-artifact.outputs.release_tag }} + WORKFLOW_V2_VERSION: ${{ needs.published-package.outputs.release_tag }} run: scripts/ci/laravel-embedded-upgrade-smoke.sh + diff --git a/CHANGELOG.md b/CHANGELOG.md index f833b7e5..1e32b181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,19 @@ ## Unreleased +## 2.0.1 - 2026-09-01 + - Workflow `2.0.1` keeps PHP exception traces Avro-portable when argument capture is enabled, preserving each frame's durable fields while omitting object and resource values that cannot cross the public payload boundary. + +## 2.0.0 - 2026-09-01 + - Workflow `2.0.0` promotes the fully qualified release-candidate runtime to the stable 2.0 line without changing its durable execution behavior. + +### 2.0 prerelease history + - Workflow `2.0.0-rc.55` treats the official stable-v1 Y and Base64 serializer settings as nonblocking migration diagnostics while v1 runs drain, without changing the Avro-only codec used for all new v2 payloads. diff --git a/README.md b/README.md index a8268dac..19939203 100644 --- a/README.md +++ b/README.md @@ -1,156 +1,103 @@ -

GitHub Workflow Status Codecov Packagist Downloads (custom server) - Docs Packagist License

+# Durable Workflow for Laravel -Durable Workflow (formerly Laravel Workflow) is a package for the Laravel web framework that provides tools for defining and managing workflows and activities. A workflow is a series of interconnected activities that are executed in a specific order to achieve a desired result. Activities are individual tasks or pieces of logic that are executed as part of a workflow. +

+ Build status + Code coverage + Latest Packagist version + Packagist downloads + MIT license +

-Durable Workflow can be used to automate and manage complex processes, such as agentic workflows (AI-driven), financial transactions, data analysis, data pipelines, microservices, job tracking, user signup flows, sagas and other business processes. By using Durable Workflow, developers can break down large, complex processes into smaller, modular units that can be easily maintained and updated. +Durable Workflow is the embedded Laravel runtime for durable execution. Write +long-running workflows as ordinary PHP, keep completed work recorded through +worker restarts and application deploys, and use Laravel's queues, cache, and +database as the runtime. -Some key features and benefits of Durable Workflow include: +This package also provides the orchestration engine hosted by +[Durable Workflow Server](https://github.com/durable-workflow/server). Use the +[PHP SDK](https://github.com/durable-workflow/sdk-php) with Server or +[Durable Workflow Cloud](https://cloud.durable-workflow.com/) when workers need +to run outside the Laravel application or across PHP, Python, and Rust. -- Support for defining workflows and activities using simple, declarative PHP classes. -- Tools for starting, monitoring, and managing workflows, including support for queuing and parallel execution. -- Built-in support for handling errors and retries, ensuring that workflows are executed reliably and consistently. -- Integration with Laravel's queue and event systems, allowing workflows to be executed asynchronously on worker servers. -- Extensive documentation and a growing community of developers who use and contribute to Durable Workflow. +## Install -## Documentation - -Documentation for Durable Workflow can be found on the [website](https://durable-workflow.com/docs/installation). - -## Community - -You can find us in the [GitHub discussions](https://github.com/durable-workflow/workflow/discussions) and also on our [Discord channel](https://discord.gg/xu5aDDpqVy). - -## Sample App - -There's also a [sample application](https://github.com/durable-workflow/sample-app) that you can run directly from GitHub in your browser. - -## Usage +```bash +composer require durable-workflow/workflow:^2.0 +php artisan migrate +``` -Install the embedded Laravel runtime: +Run a Laravel queue worker or Horizon to execute workflows and activities: ```bash -curl -fsSL https://durable-workflow.com/install-sdk.sh | sh -s -- workflow +php artisan queue:work ``` -This package owns Laravel service-provider integration, migrations, Eloquent -models, queue jobs, replay persistence, and in-process workflow and activity -authoring. It does not include a client or remote-worker runtime for the -standalone server. +## Your First Workflow -**1. Create a workflow** ```php -use function Workflow\V2\activity; +start('world'); -``` - -```php -$workflow->output(); -=> 'Hello, world!' -``` - -## Using a dedicated storage connection -By default all workflow persistence (every Eloquent model and every migration shipped -by this package) lives on your application's **default** database connection. To isolate -workflow state on its own database — for separate backup/retention/scaling, a different -driver, or tenant isolation — point the package at a dedicated connection: - -```php -// config/workflows.php -'storage' => [ - // null => the application's default connection (the default, unchanged behavior). - 'connection' => Env::dw('DW_STORAGE_CONNECTION', 'WORKFLOW_STORAGE_CONNECTION', null), -], -``` - -```dotenv -# .env — must match a key under config('database.connections') -DW_STORAGE_CONNECTION=durable_workflow +echo $workflow->output(); // Hello, world! ``` -When set, both the models and the migrations are routed to that connection, so -`php artisan migrate` creates the workflow tables there and all reads/writes target it. -Leaving it `null` preserves today's behavior exactly. - -The schema/database is governed by the connection's own configuration — use -`search_path` for PostgreSQL or `database` for MySQL on that connection. There is no -separate schema option. - -## Embedded and Polyglot Usage +Workflow code can coordinate activities, timers, signals, queries, updates, +child workflows, sagas, cancellation, retries, parallel work, side effects, +continue-as-new, search attributes, memo, and message streams. The runtime +persists execution history so replay can resume after process or host failure +without repeating completed activities. -This package provides the application-embedded version of Durable Workflow for Laravel. +## Choose a Deployment -Use it when your workflows and activities run within a Laravel application and you do not need workers written in other languages. - -For standalone or polyglot orchestration, run the [standalone Durable Workflow server](https://github.com/durable-workflow/server) and install the [PHP SDK](https://github.com/durable-workflow/sdk-php) in framework-neutral PHP applications and remote workers: - -```bash -curl -fsSL https://durable-workflow.com/install-sdk.sh | sh -s -- php -``` - -The standalone server allows PHP, Python, Rust, and other supported SDKs to participate in the same workflow system. - -| Deployment mode | PHP package | Runtime owner | +| Deployment | Use it when | Runtime owner | | --- | --- | --- | -| Embedded Laravel | `durable-workflow/workflow` | The Laravel application owns durable state and queue execution. | -| Standalone server host | `durable-workflow/workflow` inside `durable-workflow/server` | The server hosts Workflow's engine contracts and persistence. | -| Standalone PHP client or remote worker | `durable-workflow/sdk` | The SDK owns authentication, transport, protocol types, client operations, and worker polling. | +| Embedded Laravel | Workflows and activities live inside one Laravel application. | Your application owns persistence and queue execution. | +| Self-hosted Server | Workers run independently or in multiple languages. | Your team operates Server, MySQL, Redis, and optional Waterline. | +| Durable Workflow Cloud | You want a managed runtime for PHP, Python, and Rust workers. | Durable Workflow operates the runtime and persistence. | -## Sponsors - -The Durable Workflow package is sustained by the community via sponsors and volunteers. - -- Andriy Karpishyn -- Freispace Resource Scheduling -- Translate a Book +Embedded runs remain owned by the Laravel application. Moving new work to +Server or Cloud does not reinterpret existing embedded history. -## Monitoring +## Learn More -[Waterline](https://github.com/durable-workflow/waterline) is a separate UI that works nicely alongside Horizon. Think of Waterline as being to workflows what Horizon is to queues. -Waterline is a technical runtime UI for operators: use it for fleet -health, queues, waits, retries, failures, repair, and history -diagnostics. Business dashboards should read app-owned milestone -projections keyed by `workflow_id` and `run_id`, not Waterline data or -workflow runtime tables. +- [Embedded installation](https://durable-workflow.com/docs/2.0/installation/) +- [Embedded feature guides](https://durable-workflow.com/docs/2.0/category/embedded/) +- [Configuration reference](https://durable-workflow.com/docs/2.0/configuration/options/) +- [Deployment modes](https://durable-workflow.com/docs/2.0/polyglot/deployment-modes/) +- [Monitoring with Waterline](https://durable-workflow.com/docs/2.0/monitoring/) +- [Runnable Sample App](https://github.com/durable-workflow/sample-app) -### Dashboard View +Questions and design discussions are welcome in +[GitHub Discussions](https://github.com/durable-workflow/workflow/discussions) +and [Discord](https://discord.gg/xu5aDDpqVy). -![Waterline dashboard](https://raw.githubusercontent.com/durable-workflow/waterline/refs/heads/v2/docs/screenshots/dashboard.png) - -### Workflow View +## Sponsors -![Waterline workflow detail](https://raw.githubusercontent.com/durable-workflow/waterline/refs/heads/v2/docs/screenshots/workflow-detail.png) +Durable Workflow is sustained by contributors and sponsors: -Refer to https://github.com/durable-workflow/waterline for installation and configuration instructions. +- [Andriy Karpishyn](https://github.com/discovery-ukraine) +- [Freispace Resource Scheduling](https://freispace.com) +- [Translate a Book](https://translateabook.com) diff --git a/scripts/ci/check-docs-release-audit.sh b/scripts/ci/check-docs-release-audit.sh deleted file mode 100755 index 1f90dcc8..00000000 --- a/scripts/ci/check-docs-release-audit.sh +++ /dev/null @@ -1,297 +0,0 @@ -#!/usr/bin/env sh - -set -eu - -fail() { - title="$1" - message="$2" - - if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then - { - printf '## %s\n\n' "$title" - printf '%s\n' "$message" - } >> "$GITHUB_STEP_SUMMARY" - fi - - printf '::error title=%s::%s\n' "$title" "$message" >&2 - printf '%s\n' "$message" >&2 - exit 1 -} - -artifact="${DOCS_RELEASE_AUDIT_ARTIFACT:-}" -expected="${DOCS_RELEASE_AUDIT_VERSION:-${GITHUB_REF_NAME:-}}" -audit_url="${DOCS_RELEASE_AUDIT_URL:-https://durable-workflow.com/docs-page-release-audit.json}" -attempts="${DOCS_RELEASE_AUDIT_ATTEMPTS:-6}" -sleep_seconds="${DOCS_RELEASE_AUDIT_RETRY_SLEEP:-20}" -evidence_path="${DOCS_RELEASE_AUDIT_EVIDENCE:-}" -handoff_path="${DOCS_RELEASE_AUDIT_HANDOFF:-}" - -write_unavailable_evidence() { - message="$1" - - [ -n "$evidence_path" ] || return 0 - - node - "$evidence_path" "$artifact" "$expected" "$audit_url" "$message" <<'NODE' -const fs = require('fs'); - -const [evidencePath, artifact, expected, auditUrl, message] = process.argv.slice(2); - -fs.writeFileSync(evidencePath, `${JSON.stringify({ - schema: 'durable-workflow.release.docs-release-audit-evidence', - checked_at: new Date().toISOString(), - surface: 'public_docs_release_audit', - audit_url: auditUrl, - artifact, - expected_version: expected, - outcome: 'unavailable', - message, -}, null, 2)}\n`); -NODE -} - -case "$artifact" in - cli|sdk-python|server|workflow|waterline) ;; - *) fail "Docs release-audit artifact required" "DOCS_RELEASE_AUDIT_ARTIFACT must be one of cli, sdk-python, server, workflow, or waterline." ;; -esac - -expected="${expected#v}" -if [ -z "$expected" ]; then - fail "Docs release-audit version required" "DOCS_RELEASE_AUDIT_VERSION or GITHUB_REF_NAME must name the published artifact version." -fi - -case "$attempts" in - ''|*[!0-9]*) fail "Invalid docs release-audit retry count" "DOCS_RELEASE_AUDIT_ATTEMPTS must be a positive integer." ;; -esac -case "$sleep_seconds" in - ''|*[!0-9]*) fail "Invalid docs release-audit retry delay" "DOCS_RELEASE_AUDIT_RETRY_SLEEP must be a non-negative integer." ;; -esac -if [ "$attempts" -lt 1 ]; then - fail "Invalid docs release-audit retry count" "DOCS_RELEASE_AUDIT_ATTEMPTS must be at least 1." -fi - -tmp_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" -audit_path="${tmp_dir}/docs-page-release-audit-${artifact}-${expected}-$$.json" -trap 'rm -f "$audit_path"' EXIT HUP INT TERM -attempt=1 - -while [ "$attempt" -le "$attempts" ]; do - if curl -fsSL --retry 3 --retry-all-errors --connect-timeout 10 --max-time 30 -o "$audit_path" "$audit_url"; then - if node - "$audit_path" "$artifact" "$expected" "$audit_url" "$evidence_path" "$handoff_path" <<'NODE' -const fs = require('fs'); - -const [auditPath, artifact, expected, auditUrl, evidencePath, handoffPath] = process.argv.slice(2); -const title = 'Docs release-audit tuple stale'; -const refreshCommand = 'npm run refresh:public-artifact-versions'; -const refreshFiles = [ - 'scripts/public-artifact-versions.json', - 'docs/compatibility.md', -]; -const releaseAuditAssertions = [ - 'LEAK=0', - 'MIXED=0', - 'stable default 1.x', - 'explicit prerelease 2.0', -]; - -function releaseCheckSource() { - const serverUrl = process.env.GITHUB_SERVER_URL || 'https://github.com'; - const repository = process.env.GITHUB_REPOSITORY || null; - const runId = process.env.GITHUB_RUN_ID || null; - const runAttempt = process.env.GITHUB_RUN_ATTEMPT || null; - - return { - repository, - ref: process.env.GITHUB_REF_NAME || null, - sha: process.env.GITHUB_SHA || null, - run_id: runId, - run_attempt: runAttempt, - run_url: repository && runId - ? `${serverUrl}/${repository}/actions/runs/${runId}` - : null, - }; -} - -function docsRefreshHandoff(message, actualVersion, observedVersions) { - const staleArtifact = { - name: artifact, - expected_version: expected, - live_version: actualVersion, - }; - - return { - schema: 'durable-workflow.release.docs-artifact-tuple-handoff', - schema_version: 1, - action: 'pipeline_ready_item', - reason: 'public_docs_release_audit_stale', - repository: 'durable-workflow.github.io', - target_branch: 'main', - integration: 'pipeline', - refresh_command: refreshCommand, - refresh_files: refreshFiles, - stale_artifact: staleArtifact, - observed_artifact_versions: observedVersions, - source_release_check: releaseCheckSource(), - public_boundary: { - allowed_paths: refreshFiles, - forbidden_paths: [ - 'docusaurus.config.js', - 'sidebars.js', - 'versioned_docs/version-1.x', - 'versioned_sidebars/version-1.x-sidebars.json', - ], - }, - release_status_guard: { - stable_default_docs_line: '1.x', - prerelease_docs_line: '2.0', - no_default_docs_cutover: true, - live_release_audit_assertions: releaseAuditAssertions, - }, - ready_item: { - title: `Refresh public docs artifact tuple for ${artifact} ${expected}`, - body: [ - message, - '', - `Expected ${artifact} ${expected}; live docs release audit reports ${actualVersion || ''}.`, - `Run ${refreshCommand} and commit only scripts/public-artifact-versions.json plus docs/compatibility.md through the normal docs merge path.`, - ].join('\n'), - labels: [ - 'pipeline:ready-item', - 'branch:main', - 'state:pending', - ], - acceptance: [ - 'The public docs release-audit JSON reports the current published artifact tuple.', - 'Stable 1.x remains the default public docs line.', - 'The live release-audit JSON reports LEAK=0 and MIXED=0.', - 'The refresh lands through the docs merge gate, not from a public release workflow.', - ], - }, - }; -} - -function docsRefreshRequest(handoff) { - return { - schema: 'durable-workflow.docs.refresh-request', - reason: handoff.reason, - repository: handoff.repository, - target_branch: handoff.target_branch, - integration: handoff.integration, - refresh_command: handoff.refresh_command, - refresh_files: handoff.refresh_files, - stale_artifact: handoff.stale_artifact, - observed_artifact_versions: handoff.observed_artifact_versions, - source_release_check: handoff.source_release_check, - ready_item: handoff.ready_item, - handoff_schema: handoff.schema, - }; -} - -function writeHandoff(handoff) { - if (!handoffPath) { - return; - } - - fs.writeFileSync(handoffPath, `${JSON.stringify(handoff, null, 2)}\n`); -} - -function writeEvidence(outcome, extra = {}) { - if (!evidencePath) { - return; - } - - fs.writeFileSync(evidencePath, `${JSON.stringify({ - schema: 'durable-workflow.release.docs-release-audit-evidence', - checked_at: new Date().toISOString(), - surface: 'public_docs_release_audit', - audit_url: auditUrl, - artifact, - expected_version: expected, - source_release_check: releaseCheckSource(), - outcome, - ...extra, - }, null, 2)}\n`); -} - -function retry(message) { - writeEvidence('retry', {message}); - console.error(message); - process.exit(3); -} - -function fail(message, extra = {}) { - writeEvidence('stale', { - message, - ...extra, - }); - - if (process.env.GITHUB_STEP_SUMMARY) { - fs.appendFileSync( - process.env.GITHUB_STEP_SUMMARY, - `## ${title}\n\n${message}\n\n` - ); - } - console.error(`::error title=${title}::${message}`); - console.error(message); - process.exit(2); -} - -let audit; -try { - audit = JSON.parse(fs.readFileSync(auditPath, 'utf8')); -} catch (err) { - retry(`${auditUrl} did not return parseable JSON: ${err.message}`); -} - -if (audit.schema !== 'durable-workflow.docs.page-release-audit') { - retry(`${auditUrl} returned schema ${audit.schema || ''}, not durable-workflow.docs.page-release-audit.`); -} - -const versions = audit.artifact_versions; -if (!versions || typeof versions !== 'object' || Array.isArray(versions)) { - retry(`${auditUrl} must contain an artifact_versions object.`); -} - -const actual = versions[artifact]; -if (actual !== expected) { - const actualVersion = Object.prototype.hasOwnProperty.call(versions, artifact) ? actual : null; - const message = `${auditUrl} reports artifact_versions.${artifact}=${actual || ''}, expected ${expected}. ` + - 'Run npm run refresh:public-artifact-versions in durable-workflow.github.io and land scripts/public-artifact-versions.json plus docs/compatibility.md through the normal docs merge path before treating this release as fully surfaced.'; - const handoff = docsRefreshHandoff(message, actualVersion, versions); - - writeHandoff(handoff); - - fail( - `${message} When DOCS_RELEASE_AUDIT_HANDOFF is set, the uploaded handoff artifact contains the pipeline-ready docs refresh request.`, - { - actual_version: actualVersion, - observed_artifact_versions: versions, - docs_refresh_request: docsRefreshRequest(handoff), - docs_artifact_tuple_handoff: handoff, - docs_artifact_tuple_handoff_path: handoffPath || null, - } - ); -} - -writeEvidence('pass', {actual_version: actual}); -console.log(`${auditUrl} confirms artifact_versions.${artifact}=${expected}.`); -NODE - then - exit 0 - else - node_status=$? - if [ "$node_status" -eq 2 ]; then - exit 1 - fi - fi - fi - - if [ "$attempt" -lt "$attempts" ]; then - printf 'Waiting for docs release-audit JSON (%s/%s): %s\n' "$attempt" "$attempts" "$audit_url" >&2 - sleep "$sleep_seconds" - fi - attempt=$((attempt + 1)) -done - -message="Could not fetch ${audit_url} after ${attempts} attempt(s)." -write_unavailable_evidence "$message" -fail "Docs release-audit unavailable" "$message" diff --git a/scripts/ci/test-build-routing.py b/scripts/ci/test-build-routing.py index 3c323381..d49c816f 100644 --- a/scripts/ci/test-build-routing.py +++ b/scripts/ci/test-build-routing.py @@ -14,7 +14,7 @@ ROOT = Path(__file__).resolve().parents[2] BUILD_WORKFLOW = ROOT / ".github/workflows/php.yml" -RELEASE_AUDIT_WORKFLOW = ROOT / ".github/workflows/release-docs-audit.yml" +RELEASE_VERIFICATION_WORKFLOW = ROOT / ".github/workflows/release-verification.yml" FEATURE_SHARD_VERIFIER = ROOT / "scripts/ci/verify-feature-shards.php" FEATURE_SHARD_SPLITTER = ROOT / "scripts/ci/split-feature-tests.php" PR_TEST_SELECTOR = ROOT / "scripts/ci/select-pr-tests.php" @@ -327,7 +327,7 @@ class BuildWorkflowTrustTest(unittest.TestCase): @classmethod def setUpClass(cls) -> None: cls.build = BUILD_WORKFLOW.read_text() - cls.release_audit = RELEASE_AUDIT_WORKFLOW.read_text() + cls.release_verification = RELEASE_VERIFICATION_WORKFLOW.read_text() cls.workflow_sources = [ path.read_text() for path in (ROOT / ".github/workflows").glob("*.yml") ] @@ -357,26 +357,17 @@ def test_pull_request_caches_are_separate_from_protected_run_caches(self) -> Non self.build.count("${{ github.event_name }}-${{ runner.os }}-php-") // 2, ) - def test_release_audit_cannot_be_triggered_by_pull_requests(self) -> None: - trigger = self.release_audit.split("\npermissions:", 1)[0] + def test_release_verification_cannot_be_triggered_by_pull_requests(self) -> None: + trigger = self.release_verification.split("\npermissions:", 1)[0] self.assertNotIn("pull_request", trigger) - def test_published_laravel_and_docs_evidence_are_independent( - self, - ) -> None: + def test_published_laravel_upgrade_depends_on_package_verification(self) -> None: published_ancestors = workflow_job_ancestors( - self.release_audit, - "laravel-embedded-upgrade-published", - ) - docs_ancestors = workflow_job_ancestors( - self.release_audit, - "docs-release-audit", + self.release_verification, + "laravel-upgrade", ) - self.assertIn("release-artifact", published_ancestors) - self.assertNotIn("docs-release-audit", published_ancestors) - self.assertIn("release-artifact", docs_ancestors) - self.assertNotIn("laravel-embedded-upgrade-published", docs_ancestors) + self.assertEqual({"published-package"}, published_ancestors) def test_target_branch_jobs_skip_pull_requests(self) -> None: for job in ( diff --git a/tests/Unit/V2/LaravelEmbeddedUpgradeContractTest.php b/tests/Unit/V2/LaravelEmbeddedUpgradeContractTest.php index 0c83650b..d62c4910 100644 --- a/tests/Unit/V2/LaravelEmbeddedUpgradeContractTest.php +++ b/tests/Unit/V2/LaravelEmbeddedUpgradeContractTest.php @@ -62,7 +62,7 @@ public function testQualificationMatricesAreDerivedFromTheShippedContract(): voi $sourceMatrix = $this->qualificationMatrix($root, 'source'); $publishedMatrix = $this->qualificationMatrix($root, 'published'); $sourceWorkflow = Yaml::parseFile($root . '/.github/workflows/php.yml'); - $releaseWorkflow = Yaml::parseFile($root . '/.github/workflows/release-docs-audit.yml'); + $releaseWorkflow = Yaml::parseFile($root . '/.github/workflows/release-verification.yml'); $expectedSourceMatrix = [ 'include' => $this->deriveMinimumCells($intersection['authority']), ]; @@ -82,17 +82,13 @@ public function testQualificationMatricesAreDerivedFromTheShippedContract(): voi ); $this->assertSame( '${{ steps.laravel-matrix.outputs.matrix }}', - $releaseWorkflow['jobs']['release-artifact']['outputs']['laravel_published_matrix'], + $releaseWorkflow['jobs']['published-package']['outputs']['laravel_published_matrix'], ); $this->assertSame( - '${{ fromJSON(needs.release-artifact.outputs.laravel_published_matrix) }}', - $releaseWorkflow['jobs']['laravel-embedded-upgrade-published']['strategy']['matrix'], - ); - $this->assertSame('release-artifact', $releaseWorkflow['jobs']['docs-release-audit']['needs']); - $this->assertSame( - 'release-artifact', - $releaseWorkflow['jobs']['laravel-embedded-upgrade-published']['needs'], + '${{ fromJSON(needs.published-package.outputs.laravel_published_matrix) }}', + $releaseWorkflow['jobs']['laravel-upgrade']['strategy']['matrix'], ); + $this->assertSame('published-package', $releaseWorkflow['jobs']['laravel-upgrade']['needs']); } /** diff --git a/tests/Unit/V2/ReadmeBranchDestinationsTest.php b/tests/Unit/V2/ReadmeBranchDestinationsTest.php index d343cd37..9b59c837 100644 --- a/tests/Unit/V2/ReadmeBranchDestinationsTest.php +++ b/tests/Unit/V2/ReadmeBranchDestinationsTest.php @@ -15,7 +15,10 @@ final class ReadmeBranchDestinationsTest extends TestCase public function testActionsBadgeAndDestinationAreScopedToV2(): void { - [$destination, $image] = $this->badgeUrls('GitHub Workflow Status'); + [$destination, $image] = $this->badgeUrls( + 'github.com', + self::REPOSITORY_PATH . '/actions/workflows/php.yml/badge.svg', + ); $this->assertUrl($destination, 'github.com', self::REPOSITORY_PATH . '/actions/workflows/php.yml'); $this->assertSame([ @@ -30,7 +33,10 @@ public function testActionsBadgeAndDestinationAreScopedToV2(): void public function testCodecovBadgeAndDestinationAreScopedToV2(): void { - [$destination, $image] = $this->badgeUrls('Codecov'); + [$destination, $image] = $this->badgeUrls( + 'codecov.io', + '/gh' . self::REPOSITORY_PATH . '/branch/v2/graph/badge.svg', + ); $this->assertUrl($destination, 'codecov.io', '/gh' . self::REPOSITORY_PATH . '/branch/v2'); $this->assertUrl($image, 'codecov.io', '/gh' . self::REPOSITORY_PATH . '/branch/v2/graph/badge.svg'); @@ -65,7 +71,7 @@ public function testRepositoryLinksDoNotTargetMaster(): void /** * @return array{string, string} */ - private function badgeUrls(string $alt): array + private function badgeUrls(string $host, string $path): array { $document = new DOMDocument(); $previous = libxml_use_internal_errors(true); @@ -79,11 +85,25 @@ private function badgeUrls(string $alt): array $this->assertTrue($loaded); - $images = (new DOMXPath($document))->query(sprintf('//img[@alt="%s"]', $alt)); + $images = (new DOMXPath($document))->query('//img[@src]'); $this->assertNotFalse($images); - $this->assertCount(1, $images, sprintf('Expected exactly one %s badge.', $alt)); - $image = $images->item(0); + $matches = []; + + foreach ($images as $candidate) { + if (! $candidate instanceof DOMElement) { + continue; + } + + $source = $candidate->getAttribute('src'); + if (parse_url($source, PHP_URL_HOST) === $host && parse_url($source, PHP_URL_PATH) === $path) { + $matches[] = $candidate; + } + } + + $this->assertCount(1, $matches, sprintf('Expected exactly one badge at https://%s%s.', $host, $path)); + + $image = $matches[0]; $this->assertInstanceOf(DOMElement::class, $image); $link = $image->parentNode; $this->assertInstanceOf(DOMElement::class, $link);