From 10e0a6cecd8a29a8f502ede8df9dd3de865564f3 Mon Sep 17 00:00:00 2001 From: Durable Workflow Date: Tue, 1 Sep 2026 09:22:57 +0000 Subject: [PATCH] Streamline the CLI repository entry point --- .github/workflows/build.yml | 1 - .github/workflows/release.yml | 34 +- .github/workflows/sdk-python-parity.yml | 49 -- README.md | 729 ++----------------- docs/cli-reference.md | 707 ++++++++++++++++++ CONFORMANCE.md => docs/conformance.md | 16 +- docs/distribution.md | 9 +- scripts/check-sdk-python-parity.sh | 109 --- scripts/ci/check-docs-release-audit.sh | 335 --------- scripts/ci/test-release-workflow-metadata.js | 2 +- tests/DocsReleaseAuditTest.php | 450 ------------ tests/OnboardingVersionPinsTest.php | 40 - tests/ReleaseInstallerContractTest.php | 46 +- 13 files changed, 801 insertions(+), 1726 deletions(-) delete mode 100644 .github/workflows/sdk-python-parity.yml create mode 100644 docs/cli-reference.md rename CONFORMANCE.md => docs/conformance.md (95%) delete mode 100755 scripts/check-sdk-python-parity.sh delete mode 100755 scripts/ci/check-docs-release-audit.sh delete mode 100644 tests/DocsReleaseAuditTest.php delete mode 100644 tests/OnboardingVersionPinsTest.php diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cde80e1..5f06dbd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -65,7 +65,6 @@ jobs: sh -n scripts/install.sh sh -n scripts/generate-homebrew-formula.sh sh -n scripts/verify-release.sh - sh -n scripts/ci/check-docs-release-audit.sh node --check scripts/ci/release-version.js node --check scripts/ci/verify-cli-release-channel.js node --check scripts/ci/verify-cli-upgrade-result.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b4d558..6cbd5df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -271,33 +271,19 @@ jobs: printf '## Public CLI asset preflight\n\n' printf '%s\n\n' "$message" if [ "$present" = "true" ]; then - printf '%s' 'Because the complete public asset set already exists, ' - printf '%s\n' 'the live docs release-audit gate runs before any rebuild or upload.' + printf '%s\n' 'The complete public asset set already exists; metadata can be reconciled without rebuilding or replacing assets.' else - printf 'The live docs release-audit gate will run after this release publishes and verifies the public download surface.\n' + printf 'The release will build and verify the complete public download surface before publication.\n' fi } >> "$GITHUB_STEP_SUMMARY" fi - - name: Require live docs release audit for existing public assets - if: steps.public_assets.outputs.present == 'true' - env: - DOCS_RELEASE_AUDIT_ARTIFACT: cli - DOCS_RELEASE_AUDIT_VERSION: ${{ needs.resolve-release.outputs.tag }} - DOCS_RELEASE_AUDIT_EVIDENCE: docs-release-audit-evidence.json - DOCS_RELEASE_AUDIT_HANDOFF: docs-release-audit-handoff.json - DOCS_RELEASE_AUDIT_STALE_MODE: advisory - run: release-control/scripts/ci/check-docs-release-audit.sh - - name: Upload release preflight evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: release-preflight-evidence - path: | - release-preflight-public-assets-evidence.json - docs-release-audit-evidence.json - docs-release-audit-handoff.json + path: release-preflight-public-assets-evidence.json if-no-files-found: warn reconcile-existing-release-metadata: @@ -1733,22 +1719,10 @@ jobs: printf '%s\n' "$upgrade_apply_evidence" | node scripts/ci/verify-cli-upgrade-result.js \ --mode apply --release-version "$tag" --channel-version "$channel_version" - - name: Verify live docs release audit after public downloads - env: - DOCS_RELEASE_AUDIT_ARTIFACT: cli - DOCS_RELEASE_AUDIT_VERSION: ${{ needs.resolve-release.outputs.tag }} - DOCS_RELEASE_AUDIT_EVIDENCE: docs-release-audit-evidence.json - DOCS_RELEASE_AUDIT_HANDOFF: docs-release-audit-handoff.json - DOCS_RELEASE_AUDIT_STALE_MODE: advisory - run: release-control/scripts/ci/check-docs-release-audit.sh - - name: Upload release evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: release-evidence - path: | - release-public-download-evidence.json - docs-release-audit-evidence.json - docs-release-audit-handoff.json + path: release-public-download-evidence.json if-no-files-found: warn diff --git a/.github/workflows/sdk-python-parity.yml b/.github/workflows/sdk-python-parity.yml deleted file mode 100644 index 456eeca..0000000 --- a/.github/workflows/sdk-python-parity.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: SDK-Python parity fixtures - -on: - workflow_dispatch: - inputs: - cli_ref: - description: Full CLI SHA from the synchronized fixture tuple - required: true - type: string - sdk_python_ref: - description: Full SDK-Python SHA from the synchronized fixture tuple - required: true - type: string - -permissions: - contents: read - -jobs: - parity: - name: Compare immutable fixture revisions - if: ${{ github.server_url == 'https://github.com' }} - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - name: Require full commit SHAs - env: - CLI_REF: ${{ inputs.cli_ref }} - SDK_PYTHON_REF: ${{ inputs.sdk_python_ref }} - run: | - if [[ ! "$CLI_REF" =~ ^[0-9a-f]{40}$ ]]; then - echo "cli_ref must be a full lowercase commit SHA" >&2 - exit 1 - fi - if [[ ! "$SDK_PYTHON_REF" =~ ^[0-9a-f]{40}$ ]]; then - echo "sdk_python_ref must be a full lowercase commit SHA" >&2 - exit 1 - fi - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - ref: ${{ inputs.cli_ref }} - path: cli - - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 - with: - repository: durable-workflow/sdk-python - ref: ${{ inputs.sdk_python_ref }} - path: sdk-python - - name: Compare shared control-plane parity fixtures - working-directory: cli - run: scripts/check-sdk-python-parity.sh ../sdk-python diff --git a/README.md b/README.md index 974dd76..b718212 100644 --- a/README.md +++ b/README.md @@ -1,708 +1,129 @@ # Durable Workflow CLI -Command-line interface for running and interacting with the [Durable Workflow Server](https://github.com/durable-workflow/server). +

+ Build status + Latest release + MIT license +

-## Installation +`dw` is the command-line interface for Durable Workflow Cloud and self-hosted +Durable Workflow Server. Use it to start and inspect workflows, send signals +and updates, manage schedules and namespaces, diagnose workers and task queues, +and automate operations with stable JSON output. -Two supported public install paths depending on what you have installed: +## Install -**1. Standalone binary (no PHP required).** The easiest path — a one-liner -installer that detects your OS and arch: +The standalone binary does not require PHP. ```bash # Linux and macOS curl -fsSL https://durable-workflow.com/install.sh | sh ``` -The versionless installer follows the supported 2.0 prerelease channel. It -resolves the CLI from the public passing compatibility authority instead of -assuming the newest published GitHub prerelease is already qualified. - -Pin an exact release for CI, conformance, or reproducible automation: - -```bash -# Linux and macOS -curl -fsSL https://durable-workflow.com/install.sh | VERSION= sh -``` - ```powershell # Windows irm https://durable-workflow.com/install.ps1 | iex ``` -Unpinned installs resolve the supported CLI release from the passing public -artifact compatibility authority. During the 2.0 prerelease this keeps release -candidates discoverable without classifying one as GitHub's stable Latest -release. - -```powershell -# Windows, exact release -$env:VERSION = '' -irm https://durable-workflow.com/install.ps1 | iex -``` - -The installers download the release `SHA256SUMS` manifest and verify the -binary checksum before writing `dw` into the install directory. Installer -scripts live in this repository under `scripts/` and are published with each -tagged release so the one-line install path is versioned with the binaries it -downloads. - -Set `DURABLE_WORKFLOW_INSTALL_VERIFY_ATTESTATIONS=1` when the GitHub CLI is -installed to make the installer also verify artifact attestations for the -downloaded binary and `SHA256SUMS` before installation. - -After installing, the Unix installer reports both the installed binary and -the `dw` path selected by the current `PATH`, together with both versions. A -shadowed install exits unsuccessfully and prints the exact current-shell and -profile changes needed to put the user-owned binary first. When the invoking -shell could still have the pre-install command path cached, the installer -instead requires a targeted current-shell cache refresh before reporting the -install as ready. - -Set `DURABLE_WORKFLOW_INSTALL_OUTPUT=json` for the same result as a stable -machine-readable qualification record. - -Standalone installs update only when explicitly requested with `dw upgrade`; -the CLI does not update in the background. `dw update` and `dw self-update` -are not update aliases and receive the normal unknown-command diagnostic. - -Or download a native binary directly from the [releases -page](https://github.com/durable-workflow/cli/releases). Use the prerelease -channel for onboarding, or select an exact tag when recording a reproducible -build. - -Available assets: -`dw-linux-x86_64`, `dw-linux-aarch64`, -`dw-macos-aarch64`, `dw-windows-x86_64.exe`. - -Tagged releases also include `dw.rb`, a generated Homebrew formula for the -macOS arm64 binary, with the release URL and SHA256 baked in. Until a public -tap is live, two install paths are supported: install from the bundled formula -directly with `brew install --formula ./dw.rb` after downloading it from the -release, or vendor the same formula into a self-hosted tap so users can run -`brew install //dw`. See [`docs/distribution.md`](docs/distribution.md) -for the full Homebrew install runbook. - -macOS x86_64 standalone binaries are not currently produced because the -`macos-13` runner label is not available to this org; Intel Mac users can -run the PHAR with a system PHP. - -To verify a direct download, fetch the matching `SHA256SUMS` file from the -same release and check it before moving the binary into your PATH: - -```bash -sha256sum -c SHA256SUMS --ignore-missing -chmod +x dw-linux-x86_64 -./dw-linux-x86_64 --version -``` - -Release assets, including the installer scripts, also carry GitHub artifact -attestations generated by the tagged release workflow. To verify that an asset -was built by this repository's release workflow, install the GitHub CLI and run: - -```bash -gh attestation verify dw-linux-x86_64 --repo durable-workflow/cli -gh attestation verify SHA256SUMS --repo durable-workflow/cli -``` - -Tagged releases include `verify-release.sh` for downloaded release -directories. It verifies every local asset named in `SHA256SUMS`; pass -`--attest` to also verify GitHub artifact attestations for the checked files: - -```bash -sh verify-release.sh . -sh verify-release.sh --attest . -``` - -Windows operators can verify the same manifest from PowerShell: - -```powershell -$expected = Select-String -Path .\SHA256SUMS -Pattern 'dw-windows-x86_64.exe' -$actual = (Get-FileHash .\dw-windows-x86_64.exe -Algorithm SHA256).Hash.ToLower() -if (-not $expected.Line.StartsWith($actual)) { throw 'Checksum mismatch' } -.\dw-windows-x86_64.exe --version -``` - -**2. PHAR (requires PHP >= 8.2).** Download `dw.phar` from the -[releases page](https://github.com/durable-workflow/cli/releases) and run it -with `php dw.phar` (or `chmod +x` and call directly — the PHAR -has a `#!/usr/bin/env php` shebang). - -### Building from source - -```bash -make phar # Build the PHAR (requires PHP >= 8.2 and Composer) -make binary # Build the PHAR plus a standalone native binary for the - # current platform (downloads Box and static-php-cli on demand) -make clean # Remove build artifacts -``` - -Build artifacts land in `./build/`. See [scripts/build.sh](scripts/build.sh) -for the underlying steps; tools are cached under `build/.tools/`. - -### Release Policy - -Release assets are published from the tagged source by GitHub Actions. Each -release includes `SHA256SUMS` for `dw.phar` and every supported native binary -for that tag: Linux x86_64, Linux aarch64, macOS aarch64, and Windows x86_64. -The release workflow waits for all supported platform builders before -publishing the manifest; a failed platform build blocks the release instead of -publishing a partial standalone surface. - -The release workflow also publishes artifact attestations for every release -asset, including `SHA256SUMS`, the installer scripts, and the generated -Homebrew formula, so operators can verify both checksum integrity and GitHub -Actions build provenance with `gh attestation verify` or the release-bundled -`verify-release.sh --attest` helper. These attestations are the current -machine-verifiable provenance mechanism for the 2.0 line. The one-line -installers keep checksum verification as the baseline and add attestation -verification when `DURABLE_WORKFLOW_INSTALL_VERIFY_ATTESTATIONS=1` is set. - -Native binaries and PHARs are not currently code-signed or notarized. Treat the -GitHub release tag, artifact attestations, and `SHA256SUMS` as the current -provenance boundary. Signing and notarization are explicitly out of scope for -the 2.0 line; see [`docs/distribution.md`](docs/distribution.md) for the -rationale and the conditions under which that decision would be revisited. - -`dw` does not auto-update itself; the explicit `dw upgrade` command is the -only update path for standalone binary installs, and it never runs -unsolicited. By default, the command upgrades an older binary to the supported -channel, leaves an equal or newer binary unchanged, and will not downgrade a -newer binary even with `--force`. Use `--force` to reinstall an equal version; -an intentional downgrade requires an explicit `--tag=`. See -[`docs/distribution.md`](docs/distribution.md#auto-update) for the complete -status and JSON contract. The standalone installer and direct GitHub release -assets are the public release channels for CI and conformance jobs that only -need the `dw` binary. Composer package metadata is not a supported public CLI -distribution channel for the 2.0 line. The CLI also does not collect telemetry -— there is no background network traffic beyond commands that explicitly -contact the configured Durable Workflow server. Telemetry is permanently out -of scope for the 2.0 line. - -The PHAR is a reproducible build: given the same tag and the -`SOURCE_DATE_EPOCH` recorded by the release workflow, locally rebuilding from -source produces a byte-identical `dw.phar`. Run `scripts/verify-reproducible-build.sh` -to confirm the rebuild is deterministic on your machine, and see -[`docs/distribution.md`](docs/distribution.md) for the cross-check against a -published release artifact. - -### Live Server Smoke Test - -Unit tests use mocked HTTP clients. To verify the packaged `dw` entrypoint -against a real server, start a local Durable Workflow server first, then run: - -```bash -make smoke-server -``` - -By default the smoke test targets `http://localhost:8080` with no token. Override -the target and credentials when needed: - -```bash -DURABLE_WORKFLOW_CLI_SMOKE_SERVER_URL=http://localhost:18082 \ -DURABLE_WORKFLOW_CLI_SMOKE_ADMIN_TOKEN=admin-token \ -DURABLE_WORKFLOW_CLI_SMOKE_OPERATOR_TOKEN=operator-token \ -DURABLE_WORKFLOW_CLI_SMOKE_WORKER_TOKEN=worker-token \ -make smoke-server -``` - -The smoke path creates a disposable namespace, starts and inspects a workflow, -reads its history, registers a diagnostic worker, polls and completes the -workflow task through the worker protocol, creates and deletes a paused -schedule, and terminates a second cleanup workflow. - -## Configuration - -For day-to-day work, create named environment profiles. Profiles keep the -server URL, namespace, token source, TLS verification mode, and default output -format together so commands do not drift between shell aliases: - -```bash -dw env:set dev --server=http://localhost:8080 --namespace=default --make-default -dw env:set prod --server=https://api.example.com --namespace=orders --token-env=PROD_DW_TOKEN --profile-output=json -dw env:list -dw env:show prod -``` - -Profiles are stored in `~/.config/dw/config.json` by default, or -`$XDG_CONFIG_HOME/dw/config.json` when `XDG_CONFIG_HOME` is set. Set -`DW_CONFIG_HOME` to point `dw` at a separate config directory. - -Profile selection is explicit and typo-safe: - -```bash -dw env:use dev -DW_ENV=prod dw workflow:list -dw --env=prod workflow:list -``` - -Unknown names passed through `--env`, `DW_ENV`, or `dw env:use` fail instead -of falling back to another target. Literal token values are redacted by -`env:list` and `env:show` unless `--show-token` is passed; prefer -`--token-env=NAME` so secrets stay out of the config file. - -For one-off automation, set the server URL and auth token via environment -variables: - -```bash -export DURABLE_WORKFLOW_SERVER_URL=http://localhost:8080 -export DURABLE_WORKFLOW_AUTH_TOKEN=your-token -export DURABLE_WORKFLOW_NAMESPACE=default -export DURABLE_WORKFLOW_TLS_VERIFY=true -``` - -Or pass them as options to any command: - -```bash -dw --server=http://localhost:8080 --token=your-token --namespace=production --tls-verify=true workflow:list -``` - -Connection settings resolve with one stable precedence contract: command-line -flags win over environment variables, environment variables win over the -selected profile, and profiles win over built-in defaults. Profile selection -resolves as `--env`, then `DW_ENV`, then the `current_env` set by -`dw env:use`. `DURABLE_WORKFLOW_SERVER_URL`, -`DURABLE_WORKFLOW_NAMESPACE`, `DURABLE_WORKFLOW_AUTH_TOKEN`, and -`DURABLE_WORKFLOW_TLS_VERIFY` are the portable environment variable names for -carriers. `DURABLE_WORKFLOW_TLS_VERIFY` and `--tls-verify` accept `true`, -`false`, `yes`, `no`, `on`, `off`, `1`, or `0`. Tokens are bearer-token -credentials today; mTLS and signed-header credentials are reserved extension -points and must be added as redacted references instead of echoed secret -material. External executor configs follow the same auth-composition contract: -`auth_refs` may persist a profile name, environment variable name, token-file -path, mTLS certificate path plus key reference, or signed-header key reference -plus header allowlist. They must not persist bearer tokens, private keys, or -signing secrets inline. - -Namespace-scoped commands always send exactly one namespace to the server. When -`--namespace` is omitted, `dw` resolves the namespace from -`DURABLE_WORKFLOW_NAMESPACE`, the selected profile, or the built-in `default` -namespace; workflow, schedule, search-attribute, task-queue, and worker list -commands do not fan out across all tenant namespaces. Namespace-scoped -workflow, schedule, search-attribute, task-queue, and worker visibility -commands include the effective namespace in human and JSON outputs so -operators can tell which scope was queried or mutated. Namespace CRUD JSON -outputs also expose `namespace` alongside the resource `name` for the same -operator-facing context. - -Invocable activity handlers use the `invocable_http` carrier type in the same -external executor config file. The CLI validates that these targets stay -activity-only, use `POST`, declare an absolute HTTPS URL, avoid embedded URL -credentials, and keep `timeout_seconds` within the server-published -invocable-carrier envelope. Loopback HTTP is accepted only for local -development. `dw server:info` and `dw doctor --output=json` expose the -server-advertised `worker_protocol.invocable_carrier_contract` so operators can -verify the request/response content types, task-kind scope, idempotency key -source, and retry-authority boundary before enabling a mapping. - -The CLI targets control-plane contract version `2` automatically via -`X-Durable-Workflow-Control-Plane-Version: 2` and expects canonical v2 -response fields such as `*_name` and `wait_for`. Non-canonical legacy aliases -such as `signal` and `wait_policy` are rejected. - -The server also emits a nested `control_plane.contract` document with schema -`durable-workflow.v2.control-plane-response.contract`, version `1`, and -`legacy_field_policy: reject_non_canonical`. The CLI validates that nested -boundary before trusting the server-emitted `legacy_fields`, -`required_fields`, and `success_fields` metadata. - -For request fields such as `workflow:start --duplicate-policy` and -`workflow:update --wait`, the CLI now reads the server-published -`control_plane.request_contract` manifest from `GET /api/cluster/info` before -sending the command. Supported servers publish schema -`durable-workflow.v2.control-plane-request.contract`, version `1`, with an -`operations` map. The CLI treats missing or unknown request-contract -schema/version metadata as a compatibility error instead of silently guessing. -Use `dw server:info` to inspect the current canonical values, -rejected aliases, removed fields, and the server-advertised role-topology -contract for the current node, including shape, process class, matching-role -deployment knobs, current write boundaries, scaling/failure metadata, and the -fleet-wide `coordination_health` manifest that summarizes rollout-safety -warning/error checks from `GET /api/cluster/info`. -Use `dw doctor` when you need the full resolved local/remote diagnostic state: -CLI build identity, selected server/namespace/profile, a redacted -`connection.effective_config` block that names which source won for each -setting, normalized auth-composition source names, TLS verification mode, -server-advertised `auth_composition_contract` metadata, `/api/cluster/info`, -and compatibility warnings derived from the protocol manifests and -`client_compatibility` metadata. -Use `dw debug workflow ` when support needs a single stuck-run capture: -execution state, pending workflow/activity tasks, task queue backlog and -pollers, recent failures, and compatibility metadata. - -## Shell Completion - -Generate shell completion scripts with the built-in `completion` command: - -```bash -dw completion bash -dw completion zsh -dw completion fish -``` - -For ad-hoc use, evaluate the generated script in your current shell: +The installers verify the downloaded binary against the release +`SHA256SUMS` manifest. Releases also include GitHub artifact attestations, +native binaries, `dw.phar`, installer scripts, and a Homebrew formula. ```bash -eval "$(dw completion bash)" +dw --version +dw list ``` -For persistent installation, write the script to a shell-specific completion -location, or source it from your shell startup file. The completion endpoint -suggests command names, option names, and stable values for enum-like fields -such as workflow status, duplicate policy, update wait policy, schedule overlap -policy, worker status, search attribute type, and local dev database driver. - -## Compatibility +See the [distribution guide](docs/distribution.md) for exact-version installs, +direct downloads, checksum and attestation verification, Homebrew, PHAR, and +reproducible builds. -The installed CLI is compatible with servers that advertise -`control_plane.version: "2"`, -`control_plane.request_contract.schema: durable-workflow.v2.control-plane-request.contract` -version `1`, and a `client_compatibility.clients.cli.supported_versions` -range that includes the local CLI version from `GET /api/cluster/info`. -Worker diagnostic commands speak worker protocol `1.0` and accept server -responses from compatible `1.x` worker-protocol minors; breaking major -versions are refused with an explicit compatibility error. +## Connect -The top-level server `version` is build identity only. The CLI validates the -protocol manifests before the first server operation in each command. If the -server cannot safely interoperate, the CLI refuses before mutation, -registration, polling, or dropped work, exits with `COMPATIBILITY` (`8`), and -names the CLI version, server version, compatibility window, and next step: +Named profiles keep a runtime URL, namespace, token source, TLS policy, and +output preference together. ```bash -$ dw workflow:list -Server compatibility error: refusing before the requested operation because the installed dw release cannot safely interoperate with the connected server. Compatibility window: ; control-plane version 2; worker protocol same-major <= 1.0. Next step: Upgrade dw, pin dw to a supported release, or connect to a compatible server. Detail: Server compatibility error: missing control_plane.request_contract; expected durable-workflow.v2.control-plane-request.contract v1. -Next steps: - - Upgrade dw, pin dw to a supported release, or connect to a compatible server. - Try: dw doctor --output=json -``` +# Self-hosted Server +dw env:set local \ + --server=http://localhost:8080 \ + --namespace=default \ + --make-default -With `--output=json`, the same failure includes a structured compatibility -object for automation: +# Durable Workflow Cloud namespace +dw env:set cloud \ + --server="$DURABLE_WORKFLOW_RUNTIME_URL" \ + --namespace="$DURABLE_WORKFLOW_NAMESPACE" \ + --token-env=DURABLE_WORKFLOW_CLIENT_TOKEN -```json -{ - "exit_code": 8, - "compatibility": { - "cli_version": "", - "server_version": "", - "compatibility_window": "; control-plane version 2; worker protocol same-major <= 1.0", - "next_step": "Upgrade dw, pin dw to a supported release, or connect to a compatible server.", - "detail": "Server compatibility error: missing control_plane.request_contract; expected durable-workflow.v2.control-plane-request.contract v1." - } -} +dw doctor --env=local +dw server:info --env=cloud ``` -`dw --version` prints local build identity. When `DURABLE_WORKFLOW_SERVER_URL` -or `DW_ENV` explicitly selects a target, it also performs a short best-effort -compatibility probe and emits at most one warning from protocol/client metadata. -The first server-talking command in a CLI process uses the same warning source -and points to `dw doctor` for the full resolved diagnostic payload. - -See the [Version Compatibility](https://durable-workflow.github.io/docs/2.0/compatibility) documentation for the full compatibility matrix across all components. +An unknown profile fails instead of silently falling back to another runtime. +Literal tokens are redacted from normal profile output; prefer `--token-env` +so credentials remain outside the config file. -## Commands +## Run a Workflow -### Server +Start a worker for the same namespace and task queue, then run: ```bash -# Check server health -dw server:health - -# Show server version, capabilities, role topology, and coordination health -dw server:info - -# Diagnose the resolved connection and compatibility state -dw doctor -dw doctor --env=prod --output=json - -# Start a local development server -dw server:start-dev -dw server:start-dev --port=9090 --db=sqlite +dw workflow:start \ + --type=orders.checkout \ + --task-queue=orders \ + --workflow-id="order-$(date +%s)" \ + --input='{"order_id":"123"}' \ + --wait \ + --json ``` -### Workflows +Common operations: ```bash -# Start a workflow -dw workflow:start --type=order.process --input='{"order_id":123}' -dw workflow:start --type=order.process --input-file=payload.json -dw workflow:start --type=order.process --input='b3BhcXVlLWlk' --input-encoding=base64 -dw workflow:start --type=order.process --workflow-id=order-123 -dw workflow:start --type=order.process --execution-timeout=3600 --run-timeout=600 - -# List workflows -dw workflow:list -dw workflow:list --namespace=orders dw workflow:list --status=running -dw workflow:list --type=order.process - -# Describe a workflow -dw workflow:describe order-123 -dw workflow:describe order-123 --run-id=01HXYZ --json - -# Diagnose a stuck workflow in one command -dw debug workflow order-123 -dw debug workflow order-123 --run-id=01HXYZ --output=json - -# Watch a long-running workflow and print state changes -dw watch workflow order-123 -dw watch workflow order-123 --run-id=01HXYZ --interval=5 --max-polls=60 - -# Send a signal -dw workflow:signal order-123 payment-received --input='{"amount":99.99}' -dw workflow:signal counter-1 increment --input='["not-an-int"]' --output=json -# {"error":"Server error: Signal argument validation failed.","exit_code":2,"status_code":422,"reason":"invalid_signal_arguments",...} - -# Query workflow state -dw workflow:query order-123 current-status -dw workflow:query counter-1 current-at --input='["not-an-int"]' --output=json -# {"error":"Server error: Query argument validation failed.","exit_code":2,"status_code":422,"reason":"invalid_query_arguments",...} - -# Send an update -dw workflow:update order-123 approve --input='{"approver":"admin"}' - -# Send an integration event through a bounded bridge adapter -dw bridge:webhook stripe --action=start_workflow --idempotency-key=stripe-event-1001 --target='{"workflow_type":"orders.fulfillment","task_queue":"external-workflows","business_key":"order-1001"}' --input='{"order_id":"order-1001"}' -dw bridge:webhook pagerduty --action=signal_workflow --idempotency-key=pd-event-3003 --target='{"workflow_id":"wf-remediation-42","signal_name":"incident_escalated"}' --input='{"severity":"critical"}' --json - -# Cancel a workflow (workflow code can observe and clean up) -dw workflow:cancel order-123 --reason="Customer request" -dw workflow:cancel --all-matching='customer-42' --yes --reason="Customer request" - -# Terminate a workflow (immediate, no cleanup) -dw workflow:terminate order-123 --reason="Stuck workflow" - -# View event history -dw workflow:history order-123 01HXYZ -dw workflow:history order-123 01HXYZ --follow -``` - -### Namespaces - -```bash -# List namespaces -dw namespace:list - -# Create a namespace -dw namespace:create staging --description="Staging environment" --retention=7 - -# Describe a namespace -dw namespace:describe staging - -# Update a namespace -dw namespace:update staging --retention=14 - -# Delete a namespace and its runtime state -dw namespace:delete staging --json -``` - -Configure external payload storage for a namespace: - -```bash -dw namespace:set-storage-driver billing s3 --bucket=dw-payloads --prefix=billing/ --threshold-bytes=2097152 -dw namespace:set-storage-driver dev local --uri=file:///var/lib/durable-workflow/payloads --json -dw storage:test --namespace=billing --large-bytes=2097152 -dw storage:test --driver=s3 --json -``` - -### Schedules - -```bash -# Create a schedule -dw schedules create --workflow-type=reports.daily --cron="0 9 * * *" -dw schedules create --workflow-type=reports.daily --cron="0 9 * * *" --input-file=payload.json -dw schedules create --schedule-id=daily-report --workflow-type=reports.daily --cron="0 9 * * *" --timezone=America/New_York - -# List schedules -dw schedules list -dw schedules list --namespace=orders -dw schedules list --status=active --type=reports.daily --limit=25 --json -dw schedules list --query='Region = "eu" AND Priority = 2' --json -dw schedules list --next-page-token='opaque-token-from-previous-page' --json - -# Describe a schedule -dw schedules describe daily-report - -# Pause/resume -dw schedules pause daily-report --note="Holiday freeze" -dw schedules resume daily-report - -# Trigger immediately -dw schedules trigger daily-report - -# Backfill missed runs -dw schedules backfill daily-report --start-time=2024-01-01T00:00:00Z --end-time=2024-01-07T00:00:00Z - -# Delete a schedule -dw schedules delete daily-report -``` - -Schedule-list filters execute on the server and combine with AND semantics. -The JSON envelope retains `next_page_token`; pass a non-null token back -unchanged with the same namespace, status, workflow type, and visibility query. -Human table output also prints the next token when another page exists. Invalid -filters and malformed, mismatched, cross-namespace, or stale tokens retain the -server's status, reason, field errors, and `last_safe_cursor` in JSON error -output. - -### Task Queues - -```bash -# List task queues with admission status -dw task-queue:list - -# Describe a task queue (pollers, backlog, queue, namespace, and downstream admission budgets) -dw task-queue:describe default - -# Inspect and operate worker build-id rollout cohorts -dw task-queue:build-ids orders -dw task-queue:promote orders --build-id build-2026.04.21-z9 -dw task-queue:drain orders --build-id build-2026.04.20-a3f -dw task-queue:resume orders --build-id build-2026.04.20-a3f -``` - -### Worker Protocol Diagnostics - -```bash -# Register a diagnostic worker identity -dw worker:register cli-worker --task-queue=orders --workflow-type=orders.Checkout - -# Poll and lease one workflow task -dw workflow-task:poll cli-worker --task-queue=orders --json - -# Complete the leased workflow task with a JSON workflow result -dw workflow-task:complete TASK_ID ATTEMPT --lease-owner=cli-worker --complete-result='{"ok":true}' - -# Report a worker-side failure on a leased workflow-task attempt (workflow tasks are replayed, not retried against application logic) -dw workflow-task:fail TASK_ID ATTEMPT --lease-owner=cli-worker --message="replay mismatch" - -# Fetch the next history page for a leased workflow task -dw workflow-task:history TASK_ID PAGE_TOKEN --lease-owner=cli-worker --attempt=ATTEMPT --json - -# Poll and answer a routed query task -dw query-task:poll cli-worker --task-queue=orders --json -dw query-task:complete QUERY_TASK_ID ATTEMPT --lease-owner=cli-worker --result='{"ready":true}' -dw query-task:fail QUERY_TASK_ID ATTEMPT --lease-owner=cli-worker --message="unknown query" +dw workflow:describe --json +dw workflow:signal payment-received --input='{"amount":99.99}' +dw workflow:query current-status --json +dw workflow:update approve --input='{"approver":"operator"}' +dw workflow:cancel --reason="Customer request" +dw workflow:history ``` -### Activities +## Automation Contract -```bash -# Complete an activity externally -dw activity:complete TASK_ID ATTEMPT_ID --input='{"status":"done"}' -dw activity:complete TASK_ID ATTEMPT_ID --input-file=result.json - -# Fail an activity externally -dw activity:fail TASK_ID ATTEMPT_ID --message="External service unavailable" --non-retryable -``` - -Input-accepting commands use the same payload flags everywhere: -`--input` for inline values, `--input-file` for a file path or `-` for stdin, -and `--input-encoding=json|raw|base64` with `json` as the default. - -### System Operations +All read and mutating commands support machine-readable output. Use +`--output=json` for one response or `--output=jsonl` for line-oriented records. ```bash -# Show the rollout-safety coordination-health snapshot -dw system:operator-metrics - -# Pipe the raw snapshot to jq for scripted checks -dw system:operator-metrics --json | jq '.operator_metrics.workers.active_workers_supporting_required' - -# Show task repair diagnostics -dw system:repair-status - -# Run a task repair sweep -dw system:repair-pass - -# Show expired activity timeout diagnostics -dw system:activity-timeout-status - -# Run activity timeout enforcement sweep -dw system:activity-timeout-pass - -# Target specific execution IDs -dw system:activity-timeout-pass --execution-id=EXEC_ID_1 --execution-id=EXEC_ID_2 -``` - -## Global Options - -| Option | Description | -|--------|-------------| -| `--server`, `-s` | Server URL (default: `$DURABLE_WORKFLOW_SERVER_URL` or `http://localhost:8080`) | -| `--env` | Named profile to use (overrides `$DW_ENV` and `dw env:use`; hard-fails if missing) | -| `--namespace` | Target namespace (default: `$DURABLE_WORKFLOW_NAMESPACE` or `default`) | -| `--token` | Auth token (default: `$DURABLE_WORKFLOW_AUTH_TOKEN`) | -| `--tls-verify` | Verify TLS certificates (`true`/`false`; default: `$DURABLE_WORKFLOW_TLS_VERIFY`, profile setting, or `true`) | - -## Exit Codes - -The CLI uses a stable exit-code policy so scripts and CI pipelines can react -to specific failure modes without parsing stderr. Values follow Symfony -Console's canonical `0`/`1`/`2` for success / failure / usage, and extend -from there: - -| Code | Name | Meaning | -|------|------|---------| -| 0 | `SUCCESS` | Operation completed successfully. | -| 1 | `FAILURE` | Generic failure — command ran but did not succeed. | -| 2 | `INVALID` | Invalid usage — bad arguments, unknown options, or local validation. Also returned for HTTP 4xx responses that are not covered below (e.g. 400, 422). | -| 3 | `NETWORK` | Could not reach the server (connection refused, DNS failure, TLS handshake failure, transport error). | -| 4 | `AUTH` | Authentication or authorization failure. Returned for HTTP `401` and `403`. | -| 5 | `NOT_FOUND` | Resource not found. Returned for HTTP `404`. | -| 6 | `SERVER` | Server error. Returned for HTTP `5xx`. | -| 7 | `TIMEOUT` | Request timed out before the server responded. Also returned for HTTP `408`. | -| 8 | `COMPATIBILITY` | Compatibility failure. The CLI/server protocol window cannot be used safely, so the command refused before the requested operation. | - -Example: - -```bash -dw workflow:describe chk-does-not-exist -echo $? # 5 (NOT_FOUND) - -dw server:health --server=http://unreachable:9999 -echo $? # 3 (NETWORK) +dw workflow:list --output=json | jq '.workflows[].workflow_id' +dw schedule:list --output=jsonl +dw schema:list +dw schema:show workflow:list > workflow-list.schema.json ``` -Exit codes are defined in [`DurableWorkflow\Cli\Support\ExitCode`](src/Support/ExitCode.php) -and are covered by [`tests/Commands/ExitCodePolicyTest.php`](tests/Commands/ExitCodePolicyTest.php). - -## JSON Output +Exit codes distinguish usage, network, authentication, not-found, server, +timeout, and compatibility failures. Published JSON Schema manifests define +the patch-stable response envelopes. -Every list, describe, read, and mutating command supports `--json` for -machine-readable output. JSON responses preserve server response fields and -may add CLI-resolved context, such as the effective `namespace` for -namespace-scoped commands, making them safe to pipe into `jq` or feed into -downstream tooling. +## Reference -```bash -# Read surface — stable even when no --json flag is passed for list views. -dw workflow:list --json | jq '.workflows[].workflow_id' - -# Mutating surface — capture command response for idempotent automation. -wf_id=$(dw workflow:start --type=orders.Checkout --json | jq -r '.workflow_id') -dw workflow:signal "$wf_id" approve --json | jq '.command_status' -``` +- [CLI guide](https://durable-workflow.com/docs/2.0/polyglot/cli/) +- [Command reference](https://durable-workflow.com/docs/2.0/polyglot/cli-reference/) +- [Complete repository reference](docs/cli-reference.md) +- [Distribution and verification](docs/distribution.md) +- [Component conformance](docs/conformance.md) +- [Durable Workflow Server](https://github.com/durable-workflow/server) -The CLI publishes patch-stable JSON Schema files for JSON and JSONL command -responses and for `workflow:history-export` replay bundles. The -[versioned public manifest](https://durable-workflow.github.io/cli-json-envelopes/v3/manifest.json) -gives every JSON envelope and JSONL record schema an HTTPS identity and -SHA-256 digest. The prior v2 resolver remains available with its original -bytes for consumers pinned to that revision. PHAR and -standalone binary builds also bundle the same catalog under `schemas/output/`; -operators can inspect the embedded catalog without unpacking the artifact: +## Development ```bash -dw schema:list -dw schema:manifest | jq '.commands["workflow:list"].resolver_url' -dw schema:show workflow:list > workflow-list.schema.json -dw schema:show workflow:list --output=jsonl > workflow-list-record.schema.json +composer install +composer test +make phar ``` -Schemas are additive across patch releases: new optional fields may appear, but -required top-level fields and their basic types stay stable. +Run `make help` for the complete local development surface. ## License diff --git a/docs/cli-reference.md b/docs/cli-reference.md new file mode 100644 index 0000000..146f620 --- /dev/null +++ b/docs/cli-reference.md @@ -0,0 +1,707 @@ +# Complete Durable Workflow CLI Reference + +This is the detailed command, configuration, output, and operator reference for +the [Durable Workflow CLI](../README.md). Start with the root README for the +short installation and first-workflow path. + +## Installation + +Two supported public install paths depending on what you have installed: + +**1. Standalone binary (no PHP required).** The easiest path — a one-liner +installer that detects your OS and arch: + +```bash +# Linux and macOS +curl -fsSL https://durable-workflow.com/install.sh | sh +``` + +The versionless installer resolves the current supported stable release from +the public compatibility authority. + +Pin an exact release for CI, conformance, or reproducible automation: + +```bash +# Linux and macOS +curl -fsSL https://durable-workflow.com/install.sh | VERSION= sh +``` + +```powershell +# Windows +irm https://durable-workflow.com/install.ps1 | iex +``` + +Unpinned installs resolve the supported CLI release from the public artifact +compatibility authority. + +```powershell +# Windows, exact release +$env:VERSION = '' +irm https://durable-workflow.com/install.ps1 | iex +``` + +The installers download the release `SHA256SUMS` manifest and verify the +binary checksum before writing `dw` into the install directory. Installer +scripts live in this repository under `scripts/` and are published with each +tagged release so the one-line install path is versioned with the binaries it +downloads. + +Set `DURABLE_WORKFLOW_INSTALL_VERIFY_ATTESTATIONS=1` when the GitHub CLI is +installed to make the installer also verify artifact attestations for the +downloaded binary and `SHA256SUMS` before installation. + +After installing, the Unix installer reports both the installed binary and +the `dw` path selected by the current `PATH`, together with both versions. A +shadowed install exits unsuccessfully and prints the exact current-shell and +profile changes needed to put the user-owned binary first. When the invoking +shell could still have the pre-install command path cached, the installer +instead requires a targeted current-shell cache refresh before reporting the +install as ready. + +Set `DURABLE_WORKFLOW_INSTALL_OUTPUT=json` for the same result as a stable +machine-readable qualification record. + +Standalone installs update only when explicitly requested with `dw upgrade`; +the CLI does not update in the background. `dw update` and `dw self-update` +are not update aliases and receive the normal unknown-command diagnostic. + +Or download a native binary directly from the [releases +page](https://github.com/durable-workflow/cli/releases). Use the latest stable +release for normal installation, or select an exact tag for reproducible builds. + +Available assets: +`dw-linux-x86_64`, `dw-linux-aarch64`, +`dw-macos-aarch64`, `dw-windows-x86_64.exe`. + +Tagged releases also include `dw.rb`, a generated Homebrew formula for the +macOS arm64 binary, with the release URL and SHA256 baked in. Until a public +tap is live, two install paths are supported: install from the bundled formula +directly with `brew install --formula ./dw.rb` after downloading it from the +release, or vendor the same formula into a self-hosted tap so users can run +`brew install //dw`. See [`docs/distribution.md`](distribution.md) +for the full Homebrew install runbook. + +macOS x86_64 standalone binaries are not currently produced because the +`macos-13` runner label is not available to this org; Intel Mac users can +run the PHAR with a system PHP. + +To verify a direct download, fetch the matching `SHA256SUMS` file from the +same release and check it before moving the binary into your PATH: + +```bash +sha256sum -c SHA256SUMS --ignore-missing +chmod +x dw-linux-x86_64 +./dw-linux-x86_64 --version +``` + +Release assets, including the installer scripts, also carry GitHub artifact +attestations generated by the tagged release workflow. To verify that an asset +was built by this repository's release workflow, install the GitHub CLI and run: + +```bash +gh attestation verify dw-linux-x86_64 --repo durable-workflow/cli +gh attestation verify SHA256SUMS --repo durable-workflow/cli +``` + +Tagged releases include `verify-release.sh` for downloaded release +directories. It verifies every local asset named in `SHA256SUMS`; pass +`--attest` to also verify GitHub artifact attestations for the checked files: + +```bash +sh verify-release.sh . +sh verify-release.sh --attest . +``` + +Windows operators can verify the same manifest from PowerShell: + +```powershell +$expected = Select-String -Path .\SHA256SUMS -Pattern 'dw-windows-x86_64.exe' +$actual = (Get-FileHash .\dw-windows-x86_64.exe -Algorithm SHA256).Hash.ToLower() +if (-not $expected.Line.StartsWith($actual)) { throw 'Checksum mismatch' } +.\dw-windows-x86_64.exe --version +``` + +**2. PHAR (requires PHP >= 8.2).** Download `dw.phar` from the +[releases page](https://github.com/durable-workflow/cli/releases) and run it +with `php dw.phar` (or `chmod +x` and call directly — the PHAR +has a `#!/usr/bin/env php` shebang). + +### Building from source + +```bash +make phar # Build the PHAR (requires PHP >= 8.2 and Composer) +make binary # Build the PHAR plus a standalone native binary for the + # current platform (downloads Box and static-php-cli on demand) +make clean # Remove build artifacts +``` + +Build artifacts land in `./build/`. See [scripts/build.sh](../scripts/build.sh) +for the underlying steps; tools are cached under `build/.tools/`. + +### Release Policy + +Release assets are published from the tagged source by GitHub Actions. Each +release includes `SHA256SUMS` for `dw.phar` and every supported native binary +for that tag: Linux x86_64, Linux aarch64, macOS aarch64, and Windows x86_64. +The release workflow waits for all supported platform builders before +publishing the manifest; a failed platform build blocks the release instead of +publishing a partial standalone surface. + +The release workflow also publishes artifact attestations for every release +asset, including `SHA256SUMS`, the installer scripts, and the generated +Homebrew formula, so operators can verify both checksum integrity and GitHub +Actions build provenance with `gh attestation verify` or the release-bundled +`verify-release.sh --attest` helper. These attestations are the current +machine-verifiable provenance mechanism for the 2.0 line. The one-line +installers keep checksum verification as the baseline and add attestation +verification when `DURABLE_WORKFLOW_INSTALL_VERIFY_ATTESTATIONS=1` is set. + +Native binaries and PHARs are not currently code-signed or notarized. Treat the +GitHub release tag, artifact attestations, and `SHA256SUMS` as the current +provenance boundary. Signing and notarization are explicitly out of scope for +the 2.0 line; see [`docs/distribution.md`](distribution.md) for the +rationale and the conditions under which that decision would be revisited. + +`dw` does not auto-update itself; the explicit `dw upgrade` command is the +only update path for standalone binary installs, and it never runs +unsolicited. By default, the command upgrades an older binary to the supported +channel, leaves an equal or newer binary unchanged, and will not downgrade a +newer binary even with `--force`. Use `--force` to reinstall an equal version; +an intentional downgrade requires an explicit `--tag=`. See +[`docs/distribution.md`](distribution.md#auto-update) for the complete +status and JSON contract. The standalone installer and direct GitHub release +assets are the public release channels for CI and conformance jobs that only +need the `dw` binary. Composer package metadata is not a supported public CLI +distribution channel for the 2.0 line. The CLI also does not collect telemetry +— there is no background network traffic beyond commands that explicitly +contact the configured Durable Workflow server. Telemetry is permanently out +of scope for the 2.0 line. + +The PHAR is a reproducible build: given the same tag and the +`SOURCE_DATE_EPOCH` recorded by the release workflow, locally rebuilding from +source produces a byte-identical `dw.phar`. Run `scripts/verify-reproducible-build.sh` +to confirm the rebuild is deterministic on your machine, and see +[`docs/distribution.md`](distribution.md) for the cross-check against a +published release artifact. + +### Live Server Smoke Test + +Unit tests use mocked HTTP clients. To verify the packaged `dw` entrypoint +against a real server, start a local Durable Workflow server first, then run: + +```bash +make smoke-server +``` + +By default the smoke test targets `http://localhost:8080` with no token. Override +the target and credentials when needed: + +```bash +DURABLE_WORKFLOW_CLI_SMOKE_SERVER_URL=http://localhost:18082 \ +DURABLE_WORKFLOW_CLI_SMOKE_ADMIN_TOKEN=admin-token \ +DURABLE_WORKFLOW_CLI_SMOKE_OPERATOR_TOKEN=operator-token \ +DURABLE_WORKFLOW_CLI_SMOKE_WORKER_TOKEN=worker-token \ +make smoke-server +``` + +The smoke path creates a disposable namespace, starts and inspects a workflow, +reads its history, registers a diagnostic worker, polls and completes the +workflow task through the worker protocol, creates and deletes a paused +schedule, and terminates a second cleanup workflow. + +## Configuration + +For day-to-day work, create named environment profiles. Profiles keep the +server URL, namespace, token source, TLS verification mode, and default output +format together so commands do not drift between shell aliases: + +```bash +dw env:set dev --server=http://localhost:8080 --namespace=default --make-default +dw env:set prod --server=https://api.example.com --namespace=orders --token-env=PROD_DW_TOKEN --profile-output=json +dw env:list +dw env:show prod +``` + +Profiles are stored in `~/.config/dw/config.json` by default, or +`$XDG_CONFIG_HOME/dw/config.json` when `XDG_CONFIG_HOME` is set. Set +`DW_CONFIG_HOME` to point `dw` at a separate config directory. + +Profile selection is explicit and typo-safe: + +```bash +dw env:use dev +DW_ENV=prod dw workflow:list +dw --env=prod workflow:list +``` + +Unknown names passed through `--env`, `DW_ENV`, or `dw env:use` fail instead +of falling back to another target. Literal token values are redacted by +`env:list` and `env:show` unless `--show-token` is passed; prefer +`--token-env=NAME` so secrets stay out of the config file. + +For one-off automation, set the server URL and auth token via environment +variables: + +```bash +export DURABLE_WORKFLOW_SERVER_URL=http://localhost:8080 +export DURABLE_WORKFLOW_AUTH_TOKEN=your-token +export DURABLE_WORKFLOW_NAMESPACE=default +export DURABLE_WORKFLOW_TLS_VERIFY=true +``` + +Or pass them as options to any command: + +```bash +dw --server=http://localhost:8080 --token=your-token --namespace=production --tls-verify=true workflow:list +``` + +Connection settings resolve with one stable precedence contract: command-line +flags win over environment variables, environment variables win over the +selected profile, and profiles win over built-in defaults. Profile selection +resolves as `--env`, then `DW_ENV`, then the `current_env` set by +`dw env:use`. `DURABLE_WORKFLOW_SERVER_URL`, +`DURABLE_WORKFLOW_NAMESPACE`, `DURABLE_WORKFLOW_AUTH_TOKEN`, and +`DURABLE_WORKFLOW_TLS_VERIFY` are the portable environment variable names for +carriers. `DURABLE_WORKFLOW_TLS_VERIFY` and `--tls-verify` accept `true`, +`false`, `yes`, `no`, `on`, `off`, `1`, or `0`. Tokens are bearer-token +credentials today; mTLS and signed-header credentials are reserved extension +points and must be added as redacted references instead of echoed secret +material. External executor configs follow the same auth-composition contract: +`auth_refs` may persist a profile name, environment variable name, token-file +path, mTLS certificate path plus key reference, or signed-header key reference +plus header allowlist. They must not persist bearer tokens, private keys, or +signing secrets inline. + +Namespace-scoped commands always send exactly one namespace to the server. When +`--namespace` is omitted, `dw` resolves the namespace from +`DURABLE_WORKFLOW_NAMESPACE`, the selected profile, or the built-in `default` +namespace; workflow, schedule, search-attribute, task-queue, and worker list +commands do not fan out across all tenant namespaces. Namespace-scoped +workflow, schedule, search-attribute, task-queue, and worker visibility +commands include the effective namespace in human and JSON outputs so +operators can tell which scope was queried or mutated. Namespace CRUD JSON +outputs also expose `namespace` alongside the resource `name` for the same +operator-facing context. + +Invocable activity handlers use the `invocable_http` carrier type in the same +external executor config file. The CLI validates that these targets stay +activity-only, use `POST`, declare an absolute HTTPS URL, avoid embedded URL +credentials, and keep `timeout_seconds` within the server-published +invocable-carrier envelope. Loopback HTTP is accepted only for local +development. `dw server:info` and `dw doctor --output=json` expose the +server-advertised `worker_protocol.invocable_carrier_contract` so operators can +verify the request/response content types, task-kind scope, idempotency key +source, and retry-authority boundary before enabling a mapping. + +The CLI targets control-plane contract version `2` automatically via +`X-Durable-Workflow-Control-Plane-Version: 2` and expects canonical v2 +response fields such as `*_name` and `wait_for`. Non-canonical legacy aliases +such as `signal` and `wait_policy` are rejected. + +The server also emits a nested `control_plane.contract` document with schema +`durable-workflow.v2.control-plane-response.contract`, version `1`, and +`legacy_field_policy: reject_non_canonical`. The CLI validates that nested +boundary before trusting the server-emitted `legacy_fields`, +`required_fields`, and `success_fields` metadata. + +For request fields such as `workflow:start --duplicate-policy` and +`workflow:update --wait`, the CLI now reads the server-published +`control_plane.request_contract` manifest from `GET /api/cluster/info` before +sending the command. Supported servers publish schema +`durable-workflow.v2.control-plane-request.contract`, version `1`, with an +`operations` map. The CLI treats missing or unknown request-contract +schema/version metadata as a compatibility error instead of silently guessing. +Use `dw server:info` to inspect the current canonical values, +rejected aliases, removed fields, and the server-advertised role-topology +contract for the current node, including shape, process class, matching-role +deployment knobs, current write boundaries, scaling/failure metadata, and the +fleet-wide `coordination_health` manifest that summarizes rollout-safety +warning/error checks from `GET /api/cluster/info`. +Use `dw doctor` when you need the full resolved local/remote diagnostic state: +CLI build identity, selected server/namespace/profile, a redacted +`connection.effective_config` block that names which source won for each +setting, normalized auth-composition source names, TLS verification mode, +server-advertised `auth_composition_contract` metadata, `/api/cluster/info`, +and compatibility warnings derived from the protocol manifests and +`client_compatibility` metadata. +Use `dw debug workflow ` when support needs a single stuck-run capture: +execution state, pending workflow/activity tasks, task queue backlog and +pollers, recent failures, and compatibility metadata. + +## Shell Completion + +Generate shell completion scripts with the built-in `completion` command: + +```bash +dw completion bash +dw completion zsh +dw completion fish +``` + +For ad-hoc use, evaluate the generated script in your current shell: + +```bash +eval "$(dw completion bash)" +``` + +For persistent installation, write the script to a shell-specific completion +location, or source it from your shell startup file. The completion endpoint +suggests command names, option names, and stable values for enum-like fields +such as workflow status, duplicate policy, update wait policy, schedule overlap +policy, worker status, search attribute type, and local dev database driver. + +## Compatibility + +The installed CLI is compatible with servers that advertise +`control_plane.version: "2"`, +`control_plane.request_contract.schema: durable-workflow.v2.control-plane-request.contract` +version `1`, and a `client_compatibility.clients.cli.supported_versions` +range that includes the local CLI version from `GET /api/cluster/info`. +Worker diagnostic commands speak worker protocol `1.0` and accept server +responses from compatible `1.x` worker-protocol minors; breaking major +versions are refused with an explicit compatibility error. + +The top-level server `version` is build identity only. The CLI validates the +protocol manifests before the first server operation in each command. If the +server cannot safely interoperate, the CLI refuses before mutation, +registration, polling, or dropped work, exits with `COMPATIBILITY` (`8`), and +names the CLI version, server version, compatibility window, and next step: + +```bash +$ dw workflow:list +Server compatibility error: refusing before the requested operation because the installed dw release cannot safely interoperate with the connected server. Compatibility window: ; control-plane version 2; worker protocol same-major <= 1.0. Next step: Upgrade dw, pin dw to a supported release, or connect to a compatible server. Detail: Server compatibility error: missing control_plane.request_contract; expected durable-workflow.v2.control-plane-request.contract v1. +Next steps: + - Upgrade dw, pin dw to a supported release, or connect to a compatible server. + Try: dw doctor --output=json +``` + +With `--output=json`, the same failure includes a structured compatibility +object for automation: + +```json +{ + "exit_code": 8, + "compatibility": { + "cli_version": "", + "server_version": "", + "compatibility_window": "; control-plane version 2; worker protocol same-major <= 1.0", + "next_step": "Upgrade dw, pin dw to a supported release, or connect to a compatible server.", + "detail": "Server compatibility error: missing control_plane.request_contract; expected durable-workflow.v2.control-plane-request.contract v1." + } +} +``` + +`dw --version` prints local build identity. When `DURABLE_WORKFLOW_SERVER_URL` +or `DW_ENV` explicitly selects a target, it also performs a short best-effort +compatibility probe and emits at most one warning from protocol/client metadata. +The first server-talking command in a CLI process uses the same warning source +and points to `dw doctor` for the full resolved diagnostic payload. + +See the [Version Compatibility](https://durable-workflow.github.io/docs/2.0/compatibility) documentation for the full compatibility matrix across all components. + +## Commands + +### Server + +```bash +# Check server health +dw server:health + +# Show server version, capabilities, role topology, and coordination health +dw server:info + +# Diagnose the resolved connection and compatibility state +dw doctor +dw doctor --env=prod --output=json + +# Start a local development server +dw server:start-dev +dw server:start-dev --port=9090 --db=sqlite +``` + +### Workflows + +```bash +# Start a workflow +dw workflow:start --type=order.process --input='{"order_id":123}' +dw workflow:start --type=order.process --input-file=payload.json +dw workflow:start --type=order.process --input='b3BhcXVlLWlk' --input-encoding=base64 +dw workflow:start --type=order.process --workflow-id=order-123 +dw workflow:start --type=order.process --execution-timeout=3600 --run-timeout=600 + +# List workflows +dw workflow:list +dw workflow:list --namespace=orders +dw workflow:list --status=running +dw workflow:list --type=order.process + +# Describe a workflow +dw workflow:describe order-123 +dw workflow:describe order-123 --run-id=01HXYZ --json + +# Diagnose a stuck workflow in one command +dw debug workflow order-123 +dw debug workflow order-123 --run-id=01HXYZ --output=json + +# Watch a long-running workflow and print state changes +dw watch workflow order-123 +dw watch workflow order-123 --run-id=01HXYZ --interval=5 --max-polls=60 + +# Send a signal +dw workflow:signal order-123 payment-received --input='{"amount":99.99}' +dw workflow:signal counter-1 increment --input='["not-an-int"]' --output=json +# {"error":"Server error: Signal argument validation failed.","exit_code":2,"status_code":422,"reason":"invalid_signal_arguments",...} + +# Query workflow state +dw workflow:query order-123 current-status +dw workflow:query counter-1 current-at --input='["not-an-int"]' --output=json +# {"error":"Server error: Query argument validation failed.","exit_code":2,"status_code":422,"reason":"invalid_query_arguments",...} + +# Send an update +dw workflow:update order-123 approve --input='{"approver":"admin"}' + +# Send an integration event through a bounded bridge adapter +dw bridge:webhook stripe --action=start_workflow --idempotency-key=stripe-event-1001 --target='{"workflow_type":"orders.fulfillment","task_queue":"external-workflows","business_key":"order-1001"}' --input='{"order_id":"order-1001"}' +dw bridge:webhook pagerduty --action=signal_workflow --idempotency-key=pd-event-3003 --target='{"workflow_id":"wf-remediation-42","signal_name":"incident_escalated"}' --input='{"severity":"critical"}' --json + +# Cancel a workflow (workflow code can observe and clean up) +dw workflow:cancel order-123 --reason="Customer request" +dw workflow:cancel --all-matching='customer-42' --yes --reason="Customer request" + +# Terminate a workflow (immediate, no cleanup) +dw workflow:terminate order-123 --reason="Stuck workflow" + +# View event history +dw workflow:history order-123 01HXYZ +dw workflow:history order-123 01HXYZ --follow +``` + +### Namespaces + +```bash +# List namespaces +dw namespace:list + +# Create a namespace +dw namespace:create staging --description="Staging environment" --retention=7 + +# Describe a namespace +dw namespace:describe staging + +# Update a namespace +dw namespace:update staging --retention=14 + +# Delete a namespace and its runtime state +dw namespace:delete staging --json +``` + +Configure external payload storage for a namespace: + +```bash +dw namespace:set-storage-driver billing s3 --bucket=dw-payloads --prefix=billing/ --threshold-bytes=2097152 +dw namespace:set-storage-driver dev local --uri=file:///var/lib/durable-workflow/payloads --json +dw storage:test --namespace=billing --large-bytes=2097152 +dw storage:test --driver=s3 --json +``` + +### Schedules + +```bash +# Create a schedule +dw schedules create --workflow-type=reports.daily --cron="0 9 * * *" +dw schedules create --workflow-type=reports.daily --cron="0 9 * * *" --input-file=payload.json +dw schedules create --schedule-id=daily-report --workflow-type=reports.daily --cron="0 9 * * *" --timezone=America/New_York + +# List schedules +dw schedules list +dw schedules list --namespace=orders +dw schedules list --status=active --type=reports.daily --limit=25 --json +dw schedules list --query='Region = "eu" AND Priority = 2' --json +dw schedules list --next-page-token='opaque-token-from-previous-page' --json + +# Describe a schedule +dw schedules describe daily-report + +# Pause/resume +dw schedules pause daily-report --note="Holiday freeze" +dw schedules resume daily-report + +# Trigger immediately +dw schedules trigger daily-report + +# Backfill missed runs +dw schedules backfill daily-report --start-time=2024-01-01T00:00:00Z --end-time=2024-01-07T00:00:00Z + +# Delete a schedule +dw schedules delete daily-report +``` + +Schedule-list filters execute on the server and combine with AND semantics. +The JSON envelope retains `next_page_token`; pass a non-null token back +unchanged with the same namespace, status, workflow type, and visibility query. +Human table output also prints the next token when another page exists. Invalid +filters and malformed, mismatched, cross-namespace, or stale tokens retain the +server's status, reason, field errors, and `last_safe_cursor` in JSON error +output. + +### Task Queues + +```bash +# List task queues with admission status +dw task-queue:list + +# Describe a task queue (pollers, backlog, queue, namespace, and downstream admission budgets) +dw task-queue:describe default + +# Inspect and operate worker build-id rollout cohorts +dw task-queue:build-ids orders +dw task-queue:promote orders --build-id build-2026.04.21-z9 +dw task-queue:drain orders --build-id build-2026.04.20-a3f +dw task-queue:resume orders --build-id build-2026.04.20-a3f +``` + +### Worker Protocol Diagnostics + +```bash +# Register a diagnostic worker identity +dw worker:register cli-worker --task-queue=orders --workflow-type=orders.Checkout + +# Poll and lease one workflow task +dw workflow-task:poll cli-worker --task-queue=orders --json + +# Complete the leased workflow task with a JSON workflow result +dw workflow-task:complete TASK_ID ATTEMPT --lease-owner=cli-worker --complete-result='{"ok":true}' + +# Report a worker-side failure on a leased workflow-task attempt (workflow tasks are replayed, not retried against application logic) +dw workflow-task:fail TASK_ID ATTEMPT --lease-owner=cli-worker --message="replay mismatch" + +# Fetch the next history page for a leased workflow task +dw workflow-task:history TASK_ID PAGE_TOKEN --lease-owner=cli-worker --attempt=ATTEMPT --json + +# Poll and answer a routed query task +dw query-task:poll cli-worker --task-queue=orders --json +dw query-task:complete QUERY_TASK_ID ATTEMPT --lease-owner=cli-worker --result='{"ready":true}' +dw query-task:fail QUERY_TASK_ID ATTEMPT --lease-owner=cli-worker --message="unknown query" +``` + +### Activities + +```bash +# Complete an activity externally +dw activity:complete TASK_ID ATTEMPT_ID --input='{"status":"done"}' +dw activity:complete TASK_ID ATTEMPT_ID --input-file=result.json + +# Fail an activity externally +dw activity:fail TASK_ID ATTEMPT_ID --message="External service unavailable" --non-retryable +``` + +Input-accepting commands use the same payload flags everywhere: +`--input` for inline values, `--input-file` for a file path or `-` for stdin, +and `--input-encoding=json|raw|base64` with `json` as the default. + +### System Operations + +```bash +# Show the rollout-safety coordination-health snapshot +dw system:operator-metrics + +# Pipe the raw snapshot to jq for scripted checks +dw system:operator-metrics --json | jq '.operator_metrics.workers.active_workers_supporting_required' + +# Show task repair diagnostics +dw system:repair-status + +# Run a task repair sweep +dw system:repair-pass + +# Show expired activity timeout diagnostics +dw system:activity-timeout-status + +# Run activity timeout enforcement sweep +dw system:activity-timeout-pass + +# Target specific execution IDs +dw system:activity-timeout-pass --execution-id=EXEC_ID_1 --execution-id=EXEC_ID_2 +``` + +## Global Options + +| Option | Description | +|--------|-------------| +| `--server`, `-s` | Server URL (default: `$DURABLE_WORKFLOW_SERVER_URL` or `http://localhost:8080`) | +| `--env` | Named profile to use (overrides `$DW_ENV` and `dw env:use`; hard-fails if missing) | +| `--namespace` | Target namespace (default: `$DURABLE_WORKFLOW_NAMESPACE` or `default`) | +| `--token` | Auth token (default: `$DURABLE_WORKFLOW_AUTH_TOKEN`) | +| `--tls-verify` | Verify TLS certificates (`true`/`false`; default: `$DURABLE_WORKFLOW_TLS_VERIFY`, profile setting, or `true`) | + +## Exit Codes + +The CLI uses a stable exit-code policy so scripts and CI pipelines can react +to specific failure modes without parsing stderr. Values follow Symfony +Console's canonical `0`/`1`/`2` for success / failure / usage, and extend +from there: + +| Code | Name | Meaning | +|------|------|---------| +| 0 | `SUCCESS` | Operation completed successfully. | +| 1 | `FAILURE` | Generic failure — command ran but did not succeed. | +| 2 | `INVALID` | Invalid usage — bad arguments, unknown options, or local validation. Also returned for HTTP 4xx responses that are not covered below (e.g. 400, 422). | +| 3 | `NETWORK` | Could not reach the server (connection refused, DNS failure, TLS handshake failure, transport error). | +| 4 | `AUTH` | Authentication or authorization failure. Returned for HTTP `401` and `403`. | +| 5 | `NOT_FOUND` | Resource not found. Returned for HTTP `404`. | +| 6 | `SERVER` | Server error. Returned for HTTP `5xx`. | +| 7 | `TIMEOUT` | Request timed out before the server responded. Also returned for HTTP `408`. | +| 8 | `COMPATIBILITY` | Compatibility failure. The CLI/server protocol window cannot be used safely, so the command refused before the requested operation. | + +Example: + +```bash +dw workflow:describe chk-does-not-exist +echo $? # 5 (NOT_FOUND) + +dw server:health --server=http://unreachable:9999 +echo $? # 3 (NETWORK) +``` + +Exit codes are defined in [`DurableWorkflow\Cli\Support\ExitCode`](../src/Support/ExitCode.php) +and are covered by [`tests/Commands/ExitCodePolicyTest.php`](../tests/Commands/ExitCodePolicyTest.php). + +## JSON Output + +Every list, describe, read, and mutating command supports `--json` for +machine-readable output. JSON responses preserve server response fields and +may add CLI-resolved context, such as the effective `namespace` for +namespace-scoped commands, making them safe to pipe into `jq` or feed into +downstream tooling. + +```bash +# Read surface — stable even when no --json flag is passed for list views. +dw workflow:list --json | jq '.workflows[].workflow_id' + +# Mutating surface — capture command response for idempotent automation. +wf_id=$(dw workflow:start --type=orders.Checkout --json | jq -r '.workflow_id') +dw workflow:signal "$wf_id" approve --json | jq '.command_status' +``` + +The CLI publishes patch-stable JSON Schema files for JSON and JSONL command +responses and for `workflow:history-export` replay bundles. The +[versioned public manifest](https://durable-workflow.github.io/cli-json-envelopes/v3/manifest.json) +gives every JSON envelope and JSONL record schema an HTTPS identity and +SHA-256 digest. The prior v2 resolver remains available with its original +bytes for consumers pinned to that revision. PHAR and +standalone binary builds also bundle the same catalog under `schemas/output/`; +operators can inspect the embedded catalog without unpacking the artifact: + +```bash +dw schema:list +dw schema:manifest | jq '.commands["workflow:list"].resolver_url' +dw schema:show workflow:list > workflow-list.schema.json +dw schema:show workflow:list --output=jsonl > workflow-list-record.schema.json +``` + +Schemas are additive across patch releases: new optional fields may appear, but +required top-level fields and their basic types stay stable. + +## License + +MIT diff --git a/CONFORMANCE.md b/docs/conformance.md similarity index 95% rename from CONFORMANCE.md rename to docs/conformance.md index bdf8b55..07ab185 100644 --- a/CONFORMANCE.md +++ b/docs/conformance.md @@ -1,4 +1,4 @@ -# Platform Conformance — `dw` CLI Claim +# CLI Conformance The `dw` CLI participates in the public platform conformance suite specified by [`durable-workflow.github.io/static/platform-conformance-contract.json`](https://durable-workflow.github.io/platform-conformance-contract.json), @@ -35,7 +35,7 @@ suite. | Category | Source path | Status | | --- | --- | --- | -| `control_plane_request_response` | `tests/fixtures/control-plane/` | stable, parity-shared with `sdk-python` | +| `control_plane_request_response` | `tests/fixtures/control-plane/` | stable, CLI-owned protocol fixtures | | `cli_json_envelopes` | `tests/fixtures/control-plane/`, `schemas/` | stable | | `worker_task_lifecycle` (CLI input side) | `tests/fixtures/external-task/`, `tests/fixtures/external-task-input/` | stable | @@ -55,10 +55,9 @@ suite. | `skew_refusal_matrix_contract` | `durable-workflow.github.io/static/platform-conformance/skew-refusal-matrix-scenarios.json` (served at `/platform-conformance/skew-refusal-matrix-scenarios.json`) | stable, suite version `27`, manifest version `1` | | `principal_attribution_contract` | `durable-workflow.github.io/static/platform-conformance/principal-attribution-scenarios.json` (served at `/platform-conformance/principal-attribution-scenarios.json`) | stable, suite version `27`, manifest version `1` | -The fixtures in this repo are exercised today by: - -- `scripts/check-sdk-python-parity.sh` -- the `sdk-python-parity` job in `.github/workflows/build.yml` +The fixtures in this repository are exercised by the command contract tests in +`composer test`. Other clients qualify independently against the same public +Server protocol instead of maintaining pairwise copies with the CLI. Local command tests also exercise CLI signal/query JSON behavior, but implementation tests are not stable fixture sources for @@ -140,7 +139,7 @@ document before tag, with the conformance level at `full` or | --- | --- | | Required claimed targets | `cli_json_client` | | Required suite version | public docs-site manifest `durable-workflow.v2.platform-conformance.suite` version `27` | -| CI job | `platform-conformance` (lands when the harness reference implementation publishes; until then `sdk-python-parity` covers CLI-owned fixture parity) | +| CI job | CLI command contract tests in `composer test`; live runtime scenarios are recorded by the public platform conformance process | | Block on `nonconforming` | yes | | Artifact attached to release | harness result document, schema `durable-workflow.v2.platform-conformance.result` | @@ -163,6 +162,3 @@ category emits a warning and does not block. - Principal attribution scenarios: - Workflow update runtime scenarios: - Public docs page: -- Polyglot parity doc: - -- Existing per-repo gate: `scripts/check-sdk-python-parity.sh`. diff --git a/docs/distribution.md b/docs/distribution.md index 1ed92c0..c017f96 100644 --- a/docs/distribution.md +++ b/docs/distribution.md @@ -24,9 +24,8 @@ The one-line installer is the recommended path on every supported platform. Both `install.sh` and `install.ps1` download the matching `SHA256SUMS` manifest, verify the binary's checksum before writing it into the install directory, and refuse to proceed when the checksum does not match. An unpinned install -resolves the passing public artifact compatibility authority. Before stable -promotion it names the supported prerelease without relying on GitHub's stable -Latest channel. +resolves the current stable release from the public artifact compatibility +authority. On Unix, installation is ready only when an ordinary `dw` invocation resolves to the installed path. The installer reports the installed and active paths @@ -37,8 +36,8 @@ the result requires a targeted `dw` cache refresh in that shell. Set `DURABLE_WORKFLOW_INSTALL_OUTPUT=json` to emit the final result as `durable-workflow.cli.install.v1` for release qualification. -The default installer follows the qualified supported release. To require that -the authority still names a prerelease, set `VERSION` to `prerelease`: +The default installer follows the qualified supported release. Maintainers can +explicitly require a prerelease channel during future preview programs: ```bash curl -fsSL https://durable-workflow.com/install.sh | VERSION=prerelease sh diff --git a/scripts/check-sdk-python-parity.sh b/scripts/check-sdk-python-parity.sh deleted file mode 100755 index a358c5b..0000000 --- a/scripts/check-sdk-python-parity.sh +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env bash -# Fail if any control-plane parity fixture drifts between this repo and sdk-python. -# -# Both the CLI and the Python SDK carry the same set of shared parity fixtures -# under tests/fixtures/control-plane/. The copies must stay byte-identical so -# that neither side can silently add an operation, change the shared wire -# contract, or change the opposite side's language projection. -# -# Usage: -# scripts/check-sdk-python-parity.sh -# -# Exits 0 when both repos carry the same byte-identical fixtures. -# Exits 1 with a missing-file report or unified diff when any fixture drifts. - -set -euo pipefail - -if [[ $# -lt 1 ]]; then - echo "usage: $0 " >&2 - exit 2 -fi - -sdk_root="$1" -cli_root="$(cd "$(dirname "$0")/.." && pwd)" - -cli_dir="$cli_root/tests/fixtures/control-plane" -sdk_dir="$sdk_root/tests/fixtures/control-plane" - -if [[ ! -d "$cli_dir" ]]; then - echo "CLI fixtures directory not found: $cli_dir" >&2 - exit 1 -fi -if [[ ! -d "$sdk_dir" ]]; then - echo "SDK-Python fixtures directory not found: $sdk_dir" >&2 - exit 1 -fi - -compared=0 -drifted=0 -missing=0 -divergent_files=() -cli_only=() -sdk_only=() - -while IFS= read -r -d '' cli_file; do - name="$(basename "$cli_file")" - sdk_file="$sdk_dir/$name" - if [[ ! -e "$sdk_file" ]]; then - missing=$((missing + 1)) - cli_only+=("$name") - continue - fi - compared=$((compared + 1)) - if ! cmp -s "$cli_file" "$sdk_file"; then - drifted=$((drifted + 1)) - divergent_files+=("$name") - fi -done < <(find "$cli_dir" -maxdepth 1 -name '*-parity.json' -type f -print0 | sort -z) - -while IFS= read -r -d '' sdk_file; do - name="$(basename "$sdk_file")" - cli_file="$cli_dir/$name" - if [[ ! -e "$cli_file" ]]; then - missing=$((missing + 1)) - sdk_only+=("$name") - fi -done < <(find "$sdk_dir" -maxdepth 1 -name '*-parity.json' -type f -print0 | sort -z) - -echo "Compared $compared shared parity fixture(s) between CLI and SDK-Python." - -if [[ $missing -eq 0 && $drifted -eq 0 ]]; then - echo "All shared fixtures are byte-identical." - exit 0 -fi - -if [[ $missing -gt 0 ]]; then - echo >&2 - echo "Parity fixture filename drift detected:" >&2 - echo >&2 - if [[ ${#cli_only[@]} -gt 0 ]]; then - echo "Present in CLI only:" >&2 - for name in "${cli_only[@]}"; do - echo " - tests/fixtures/control-plane/$name" >&2 - done - echo >&2 - fi - if [[ ${#sdk_only[@]} -gt 0 ]]; then - echo "Present in SDK-Python only:" >&2 - for name in "${sdk_only[@]}"; do - echo " - tests/fixtures/control-plane/$name" >&2 - done - echo >&2 - fi -fi - -if [[ $drifted -eq 0 ]]; then - echo "Reconcile the fixture set so both repos advertise the same shared control-plane operations." >&2 - exit 1 -fi - -echo >&2 -echo "$drifted fixture(s) drifted:" >&2 -echo >&2 -for name in "${divergent_files[@]}"; do - echo "--- $name ---" >&2 - diff -u "$cli_dir/$name" "$sdk_dir/$name" || true - echo >&2 -done >&2 -echo "Reconcile the fixtures so both repos advertise the same control-plane contract." >&2 -exit 1 diff --git a/scripts/ci/check-docs-release-audit.sh b/scripts/ci/check-docs-release-audit.sh deleted file mode 100755 index f2cf29c..0000000 --- a/scripts/ci/check-docs-release-audit.sh +++ /dev/null @@ -1,335 +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:-}" -stale_mode="${DOCS_RELEASE_AUDIT_STALE_MODE:-blocking}" -script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" -release_version_helper="${script_dir}/release-version.js" - -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 - -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 -if ! expected="$(node "$release_version_helper" normalize "$expected" 2>/dev/null)"; then - fail "Invalid docs release-audit version" "DOCS_RELEASE_AUDIT_VERSION or GITHUB_REF_NAME must be exact SemVer with at most one leading v." -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 - -case "$stale_mode" in - blocking|advisory) ;; - *) fail "Invalid docs release-audit stale mode" "DOCS_RELEASE_AUDIT_STALE_MODE must be blocking or advisory." ;; -esac -if [ "$stale_mode" = "advisory" ] && [ -z "$handoff_path" ]; then - fail "Docs release-audit handoff required" "DOCS_RELEASE_AUDIT_HANDOFF is required when stale docs audits are advisory." -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" "$stale_mode" "$release_version_helper" <<'NODE' -const fs = require('fs'); - -const [auditPath, artifact, expected, auditUrl, evidencePath, handoffPath, staleMode, releaseVersionHelper] = process.argv.slice(2); -const {compareReleaseVersions, parseReleaseVersion} = require(releaseVersionHelper); -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 reportStale(message, extra = {}) { - const publicationBlocking = staleMode !== 'advisory'; - - writeEvidence('stale', { - message, - publication_blocking: publicationBlocking, - ...extra, - }); - - if (process.env.GITHUB_STEP_SUMMARY) { - fs.appendFileSync( - process.env.GITHUB_STEP_SUMMARY, - `## ${title}\n\n${message}\n\n` - ); - } - const annotation = publicationBlocking ? 'error' : 'warning'; - console.error(`::${annotation} title=${title}::${message}`); - console.error(message); - process.exit(publicationBlocking ? 2 : 0); -} - -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]; -const actualPresent = Object.prototype.hasOwnProperty.call(versions, artifact); -if (actualPresent && parseReleaseVersion(actual) === null) { - retry(`${auditUrl} reports invalid artifact_versions.${artifact}=${String(actual)}.`); -} - -if (actual !== expected) { - const actualVersion = actualPresent ? actual : null; - const versionOrder = compareReleaseVersions(actualVersion, expected); - - if (versionOrder !== null && versionOrder > 0) { - const message = `${auditUrl} already reports newer artifact_versions.${artifact}=${actualVersion}; ` + - `the replayed ${expected} release is superseded and must not request a docs tuple refresh.`; - - writeEvidence('superseded', { - actual_version: actualVersion, - publication_blocking: false, - message, - }); - console.log(message); - process.exit(0); - } - - 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); - - reportStale( - `${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-release-workflow-metadata.js b/scripts/ci/test-release-workflow-metadata.js index a95390b..28cfae3 100644 --- a/scripts/ci/test-release-workflow-metadata.js +++ b/scripts/ci/test-release-workflow-metadata.js @@ -90,7 +90,7 @@ test('upgrade channel verification uses the executable installed from the public const workflow = fs.readFileSync(workflowPath, 'utf8'); const verificationStart = workflow.indexOf('- name: Verify public release downloads'); const verificationEnd = workflow.indexOf( - '- name: Verify live docs release audit after public downloads', + '- name: Upload release evidence', verificationStart, ); diff --git a/tests/DocsReleaseAuditTest.php b/tests/DocsReleaseAuditTest.php deleted file mode 100644 index 2cf8b44..0000000 --- a/tests/DocsReleaseAuditTest.php +++ /dev/null @@ -1,450 +0,0 @@ - */ - private array $temporaryPaths = []; - - protected function tearDown(): void - { - foreach (array_reverse($this->temporaryPaths) as $path) { - if (is_file($path)) { - @unlink($path); - } elseif (is_dir($path)) { - @rmdir($path); - } - } - - $this->temporaryPaths = []; - } - - public function test_stale_docs_tuple_is_advisory_when_a_handoff_is_requested(): void - { - $sandbox = $this->createSandbox(); - $audit = $this->writeAudit($sandbox, '0.1.92'); - $evidence = $sandbox.'/evidence.json'; - $handoff = $sandbox.'/handoff.json'; - - $process = $this->runAudit($sandbox, $audit, $evidence, $handoff, 'advisory'); - - self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); - self::assertStringContainsString('::warning title=Docs release-audit tuple stale::', $process->getErrorOutput()); - self::assertStringNotContainsString('::error title=Docs release-audit tuple stale::', $process->getErrorOutput()); - - $evidencePayload = $this->readJson($evidence); - self::assertSame('stale', $evidencePayload['outcome']); - self::assertFalse($evidencePayload['publication_blocking']); - self::assertSame('0.1.93', $evidencePayload['expected_version']); - self::assertSame('0.1.92', $evidencePayload['actual_version']); - - $handoffPayload = $this->readJson($handoff); - self::assertSame('durable-workflow.release.docs-artifact-tuple-handoff', $handoffPayload['schema']); - self::assertSame('pipeline_ready_item', $handoffPayload['action']); - self::assertSame('durable-workflow.github.io', $handoffPayload['repository']); - self::assertSame('0.1.93', $handoffPayload['stale_artifact']['expected_version']); - self::assertSame('0.1.92', $handoffPayload['stale_artifact']['live_version']); - self::assertSame( - 'https://github.com/durable-workflow/cli/actions/runs/1234', - $handoffPayload['source_release_check']['run_url'], - ); - } - - public function test_stale_docs_tuple_remains_blocking_without_advisory_mode(): void - { - $sandbox = $this->createSandbox(); - $audit = $this->writeAudit($sandbox, '0.1.92'); - $evidence = $sandbox.'/evidence.json'; - $handoff = $sandbox.'/handoff.json'; - - $process = $this->runAudit($sandbox, $audit, $evidence, $handoff); - - self::assertSame(1, $process->getExitCode()); - self::assertStringContainsString('::error title=Docs release-audit tuple stale::', $process->getErrorOutput()); - self::assertTrue($this->readJson($evidence)['publication_blocking']); - } - - public function test_older_release_replay_does_not_request_a_docs_tuple_downgrade(): void - { - $sandbox = $this->createSandbox(); - $audit = $this->writeAudit($sandbox, '0.1.94'); - $evidence = $sandbox.'/evidence.json'; - $handoff = $sandbox.'/handoff.json'; - - $process = $this->runAudit($sandbox, $audit, $evidence, $handoff, 'advisory'); - - self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); - self::assertStringContainsString( - 'the replayed 0.1.93 release is superseded and must not request a docs tuple refresh', - $process->getOutput(), - ); - self::assertStringNotContainsString('Docs release-audit tuple stale', $process->getErrorOutput()); - self::assertFileDoesNotExist($handoff); - - $evidencePayload = $this->readJson($evidence); - self::assertSame('superseded', $evidencePayload['outcome']); - self::assertSame('0.1.93', $evidencePayload['expected_version']); - self::assertSame('0.1.94', $evidencePayload['actual_version']); - self::assertFalse($evidencePayload['publication_blocking']); - self::assertArrayNotHasKey('docs_refresh_request', $evidencePayload); - self::assertArrayNotHasKey('docs_artifact_tuple_handoff', $evidencePayload); - } - - public function test_exact_docs_tuple_passes_without_a_handoff(): void - { - $sandbox = $this->createSandbox(); - $audit = $this->writeAudit($sandbox, '0.1.93'); - $evidence = $sandbox.'/evidence.json'; - $handoff = $sandbox.'/handoff.json'; - - $process = $this->runAudit($sandbox, $audit, $evidence, $handoff, 'advisory'); - - self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); - self::assertStringContainsString( - 'confirms artifact_versions.cli=0.1.93', - $process->getOutput(), - ); - self::assertStringNotContainsString('Docs release-audit tuple stale', $process->getErrorOutput()); - self::assertFileDoesNotExist($handoff); - - $evidencePayload = $this->readJson($evidence); - self::assertSame('pass', $evidencePayload['outcome']); - self::assertSame('0.1.93', $evidencePayload['expected_version']); - self::assertSame('0.1.93', $evidencePayload['actual_version']); - self::assertArrayNotHasKey('docs_refresh_request', $evidencePayload); - self::assertArrayNotHasKey('docs_artifact_tuple_handoff', $evidencePayload); - } - - #[DataProvider('acceptedReleaseVersions')] - public function test_release_resolver_versions_exactly_match_the_docs_audit( - string $releaseTag, - string $advertisedVersion, - ): void { - $resolver = $this->runReleaseResolver($releaseTag); - self::assertSame(0, $resolver->getExitCode(), $resolver->getErrorOutput()); - self::assertSame($advertisedVersion."\n", $resolver->getOutput()); - - $sandbox = $this->createSandbox(); - $audit = $this->writeAudit($sandbox, $advertisedVersion); - $evidence = $sandbox.'/evidence.json'; - $handoff = $sandbox.'/handoff.json'; - $process = $this->runAudit( - $sandbox, - $audit, - $evidence, - $handoff, - 'advisory', - $advertisedVersion, - ); - - self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); - self::assertSame('pass', $this->readJson($evidence)['outcome']); - self::assertFileDoesNotExist($handoff); - } - - /** @return iterable */ - public static function acceptedReleaseVersions(): iterable - { - yield 'stable' => ['1.2.3', '1.2.3']; - yield 'optional v prefix' => ['v1.2.3-rc-linux.1', '1.2.3-rc-linux.1']; - yield 'hyphenated prerelease identifier' => ['1.2.3-rc-linux.1', '1.2.3-rc-linux.1']; - yield 'hyphenated prerelease and build identifiers' => [ - '1.2.3-rc-linux.1+linux-x86-64.7', - '1.2.3-rc-linux.1+linux-x86-64.7', - ]; - } - - public function test_hyphenated_prerelease_identifiers_follow_semver_precedence(): void - { - $sandbox = $this->createSandbox(); - $audit = $this->writeAudit($sandbox, '1.2.3-rc-linux.10'); - $evidence = $sandbox.'/evidence.json'; - $handoff = $sandbox.'/handoff.json'; - - $process = $this->runAudit( - $sandbox, - $audit, - $evidence, - $handoff, - 'advisory', - '1.2.3-rc-linux.2', - ); - - self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); - self::assertSame('superseded', $this->readJson($evidence)['outcome']); - self::assertFileDoesNotExist($handoff); - } - - public function test_newer_numeric_prerelease_does_not_request_a_docs_tuple_downgrade(): void - { - $sandbox = $this->createSandbox(); - $audit = $this->writeAudit($sandbox, '2.0.0-alpha.138'); - $evidence = $sandbox.'/evidence.json'; - $handoff = $sandbox.'/handoff.json'; - - $process = $this->runAudit( - $sandbox, - $audit, - $evidence, - $handoff, - 'advisory', - '2.0.0-alpha.137', - ); - - self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); - self::assertStringContainsString( - 'the replayed 2.0.0-alpha.137 release is superseded and must not request a docs tuple refresh', - $process->getOutput(), - ); - self::assertStringNotContainsString('Docs release-audit tuple stale', $process->getErrorOutput()); - self::assertFileDoesNotExist($handoff); - - $evidencePayload = $this->readJson($evidence); - self::assertSame('superseded', $evidencePayload['outcome']); - self::assertSame('2.0.0-alpha.137', $evidencePayload['expected_version']); - self::assertSame('2.0.0-alpha.138', $evidencePayload['actual_version']); - self::assertFalse($evidencePayload['publication_blocking']); - self::assertArrayNotHasKey('docs_refresh_request', $evidencePayload); - self::assertArrayNotHasKey('docs_artifact_tuple_handoff', $evidencePayload); - } - - public function test_older_numeric_prerelease_remains_stale_and_requests_a_handoff(): void - { - $sandbox = $this->createSandbox(); - $audit = $this->writeAudit($sandbox, '2.0.0-alpha.136'); - $evidence = $sandbox.'/evidence.json'; - $handoff = $sandbox.'/handoff.json'; - - $process = $this->runAudit( - $sandbox, - $audit, - $evidence, - $handoff, - 'advisory', - '2.0.0-alpha.137', - ); - - self::assertSame(0, $process->getExitCode(), $process->getErrorOutput()); - self::assertStringContainsString('::warning title=Docs release-audit tuple stale::', $process->getErrorOutput()); - - $evidencePayload = $this->readJson($evidence); - self::assertSame('stale', $evidencePayload['outcome']); - self::assertSame('2.0.0-alpha.137', $evidencePayload['expected_version']); - self::assertSame('2.0.0-alpha.136', $evidencePayload['actual_version']); - self::assertFalse($evidencePayload['publication_blocking']); - - $handoffPayload = $this->readJson($handoff); - self::assertSame('2.0.0-alpha.137', $handoffPayload['stale_artifact']['expected_version']); - self::assertSame('2.0.0-alpha.136', $handoffPayload['stale_artifact']['live_version']); - } - - #[DataProvider('staleModes')] - public function test_invalid_advertised_version_is_a_hard_failure_without_a_handoff(?string $staleMode): void - { - $sandbox = $this->createSandbox(); - $audit = $this->writeAudit($sandbox, 'not-a-version'); - $evidence = $sandbox.'/evidence.json'; - $handoff = $sandbox.'/handoff.json'; - - $process = $this->runAudit($sandbox, $audit, $evidence, $handoff, $staleMode); - - self::assertSame(1, $process->getExitCode()); - self::assertStringContainsString( - 'reports invalid artifact_versions.cli=not-a-version', - $process->getErrorOutput(), - ); - self::assertSame('unavailable', $this->readJson($evidence)['outcome']); - self::assertFileDoesNotExist($handoff); - } - - #[DataProvider('malformedReleaseVersions')] - public function test_malformed_versions_are_rejected_by_the_resolver_and_docs_audit(string $version): void - { - $resolver = $this->runReleaseResolver($version); - self::assertSame(1, $resolver->getExitCode()); - - $sandbox = $this->createSandbox(); - $audit = $this->writeAudit($sandbox, $version); - $evidence = $sandbox.'/evidence.json'; - $handoff = $sandbox.'/handoff.json'; - $process = $this->runAudit($sandbox, $audit, $evidence, $handoff, 'advisory'); - - self::assertSame(1, $process->getExitCode()); - self::assertStringContainsString( - "reports invalid artifact_versions.cli={$version}", - $process->getErrorOutput(), - ); - self::assertFileDoesNotExist($handoff); - } - - /** @return iterable */ - public static function malformedReleaseVersions(): iterable - { - yield 'leading zero in core' => ['01.2.3']; - yield 'leading zero in numeric prerelease' => ['1.2.3-01']; - yield 'empty prerelease identifier' => ['1.2.3-rc..1']; - yield 'empty build identifier' => ['1.2.3+linux..1']; - } - - /** @return iterable */ - public static function staleModes(): iterable - { - yield 'blocking' => [null]; - yield 'advisory' => ['advisory']; - } - - public function test_advisory_mode_requires_an_uploadable_handoff(): void - { - $sandbox = $this->createSandbox(); - $audit = $this->writeAudit($sandbox, '0.1.92'); - $evidence = $sandbox.'/evidence.json'; - - $process = $this->runAudit($sandbox, $audit, $evidence, '', 'advisory'); - - self::assertSame(1, $process->getExitCode()); - self::assertStringContainsString('DOCS_RELEASE_AUDIT_HANDOFF is required', $process->getErrorOutput()); - } - - public function test_advisory_mode_does_not_hide_an_invalid_audit_response(): void - { - $sandbox = $this->createSandbox(); - $audit = $sandbox.'/audit.json'; - self::assertNotFalse(file_put_contents($audit, '{"schema":"unexpected"}')); - $this->temporaryPaths[] = $audit; - $evidence = $sandbox.'/evidence.json'; - $handoff = $sandbox.'/handoff.json'; - - $process = $this->runAudit($sandbox, $audit, $evidence, $handoff, 'advisory'); - - self::assertSame(1, $process->getExitCode()); - self::assertStringContainsString('Docs release-audit unavailable', $process->getErrorOutput()); - self::assertSame('unavailable', $this->readJson($evidence)['outcome']); - self::assertFileDoesNotExist($handoff); - } - - private function createSandbox(): string - { - $sandbox = sys_get_temp_dir().'/cli-docs-release-audit-'.bin2hex(random_bytes(6)); - self::assertTrue(mkdir($sandbox)); - $this->temporaryPaths[] = $sandbox; - - $fakeCurl = $sandbox.'/curl'; - self::assertNotFalse(file_put_contents($fakeCurl, <<<'SH' -#!/usr/bin/env sh -set -eu - -output='' -while [ "$#" -gt 0 ]; do - case "$1" in - -o) - output="$2" - shift 2 - ;; - *) - shift - ;; - esac -done - -[ -n "$output" ] -cp "$FAKE_AUDIT_SOURCE" "$output" -SH)); - self::assertTrue(chmod($fakeCurl, 0755)); - $this->temporaryPaths[] = $fakeCurl; - - return $sandbox; - } - - private function writeAudit(string $sandbox, string $cliVersion): string - { - $path = $sandbox.'/audit.json'; - self::assertNotFalse(file_put_contents($path, json_encode([ - 'schema' => 'durable-workflow.docs.page-release-audit', - 'artifact_versions' => [ - 'cli' => $cliVersion, - 'server' => '2.0.0-beta.9', - ], - ], JSON_THROW_ON_ERROR))); - $this->temporaryPaths[] = $path; - - return $path; - } - - private function runAudit( - string $sandbox, - string $audit, - string $evidence, - string $handoff, - ?string $staleMode = null, - string $expectedVersion = '0.1.93', - ): Process { - $summary = $sandbox.'/summary.md'; - $this->temporaryPaths[] = $summary; - $this->temporaryPaths[] = $evidence; - if ($handoff !== '') { - $this->temporaryPaths[] = $handoff; - } - - $environment = [ - 'PATH' => $sandbox.':'.getenv('PATH'), - 'FAKE_AUDIT_SOURCE' => $audit, - 'DOCS_RELEASE_AUDIT_ARTIFACT' => 'cli', - 'DOCS_RELEASE_AUDIT_VERSION' => $expectedVersion, - 'DOCS_RELEASE_AUDIT_URL' => 'https://docs.example.invalid/release-audit.json', - 'DOCS_RELEASE_AUDIT_ATTEMPTS' => '1', - 'DOCS_RELEASE_AUDIT_RETRY_SLEEP' => '0', - 'DOCS_RELEASE_AUDIT_EVIDENCE' => $evidence, - 'DOCS_RELEASE_AUDIT_HANDOFF' => $handoff, - 'GITHUB_STEP_SUMMARY' => $summary, - 'GITHUB_SERVER_URL' => 'https://github.com', - 'GITHUB_REPOSITORY' => 'durable-workflow/cli', - 'GITHUB_REF_NAME' => $expectedVersion, - 'GITHUB_SHA' => str_repeat('a', 40), - 'GITHUB_RUN_ID' => '1234', - 'GITHUB_RUN_ATTEMPT' => '1', - ]; - if ($staleMode !== null) { - $environment['DOCS_RELEASE_AUDIT_STALE_MODE'] = $staleMode; - } - - $process = new Process( - [dirname(__DIR__).'/scripts/ci/check-docs-release-audit.sh'], - dirname(__DIR__), - $environment, - ); - $process->run(); - - return $process; - } - - private function runReleaseResolver(string $version): Process - { - $process = new Process([ - 'node', - dirname(__DIR__).'/scripts/ci/release-version.js', - 'normalize', - $version, - ], dirname(__DIR__)); - $process->run(); - - return $process; - } - - /** @return array */ - private function readJson(string $path): array - { - $contents = file_get_contents($path); - self::assertIsString($contents); - $payload = json_decode($contents, true, flags: JSON_THROW_ON_ERROR); - self::assertIsArray($payload); - - return $payload; - } -} diff --git a/tests/OnboardingVersionPinsTest.php b/tests/OnboardingVersionPinsTest.php deleted file mode 100644 index 46221d7..0000000 --- a/tests/OnboardingVersionPinsTest.php +++ /dev/null @@ -1,40 +0,0 @@ - - */ - public static function publicOnboardingDocuments(): iterable - { - yield 'README' => ['README.md']; - yield 'distribution guide' => ['docs/distribution.md']; - } -} diff --git a/tests/ReleaseInstallerContractTest.php b/tests/ReleaseInstallerContractTest.php index 828922a..3f1d761 100644 --- a/tests/ReleaseInstallerContractTest.php +++ b/tests/ReleaseInstallerContractTest.php @@ -224,13 +224,7 @@ public function test_installers_are_versioned_release_assets(): void self::assertStringContainsString('existing-public-assets-rerun-gate', $releaseWorkflow); self::assertStringContainsString('pre-upload-public-asset-presence-check', $releaseWorkflow); self::assertStringContainsString('complete_public_asset_set: present === \'true\'', $releaseWorkflow); - self::assertStringContainsString('Require live docs release audit for existing public assets', $releaseWorkflow); - self::assertStringContainsString("if: steps.public_assets.outputs.present == 'true'", $releaseWorkflow); - self::assertStringContainsString('DOCS_RELEASE_AUDIT_EVIDENCE: docs-release-audit-evidence.json', $releaseWorkflow); - self::assertStringContainsString('DOCS_RELEASE_AUDIT_HANDOFF: docs-release-audit-handoff.json', $releaseWorkflow); - self::assertSame(2, substr_count($releaseWorkflow, 'DOCS_RELEASE_AUDIT_STALE_MODE: advisory')); self::assertStringContainsString('release-preflight-public-assets-evidence.json', $releaseWorkflow); - self::assertStringContainsString('docs-release-audit-handoff.json', $releaseWorkflow); self::assertStringContainsString('needs: [resolve-release, release-preflight]', $releaseWorkflow); self::assertStringContainsString("needs.release-preflight.result == 'success'", $releaseWorkflow); self::assertStringContainsString("needs.release-preflight.outputs.public_assets_present != 'true'", $releaseWorkflow); @@ -253,7 +247,6 @@ public function test_installers_are_versioned_release_assets(): void self::assertStringContainsString('release-public-download-evidence.json', $releaseWorkflow); self::assertStringContainsString('"artifact_versions": {"cli": "%s"}', $releaseWorkflow); self::assertStringContainsString('"installable_artifacts": {"verified_public_downloads": true, "version": "%s"}', $releaseWorkflow); - self::assertStringContainsString('Verify live docs release audit after public downloads', $releaseWorkflow); self::assertStringContainsString('name: release-evidence', $releaseWorkflow); self::assertStringNotContainsString('"docs_release_audit": {"artifact": "cli", "version": "%s", "checked_before_public_upload": true', $releaseWorkflow); self::assertStringContainsString('install.sh', $releaseWorkflow); @@ -271,21 +264,17 @@ public function test_installers_are_versioned_release_assets(): void self::assertStringContainsString('--without-suggestions --retry="${SPC_DOWNLOAD_RETRY}"', $releaseWorkflow); self::assertStringContainsString('--without-suggestions --retry="$env:SPC_DOWNLOAD_RETRY"', $releaseWorkflow); self::assertStringContainsString('name: ${{ matrix.name }}-spc-logs', $releaseWorkflow); - self::assertStringNotContainsString('Require live docs release audit refresh', $releaseWorkflow); + self::assertStringNotContainsString('docs release audit', strtolower($releaseWorkflow)); + self::assertStringNotContainsString('docs-artifact-tuple-handoff', $releaseWorkflow); - $preflightDocsGatePosition = strpos($releaseWorkflow, 'Require live docs release audit for existing public assets'); $buildPosition = strpos($releaseWorkflow, 'build-phar:'); $uploadPosition = strpos($releaseWorkflow, 'Create GitHub Release'); $publicDownloadPosition = strpos($releaseWorkflow, 'Verify public release downloads'); - $postUploadDocsGatePosition = strpos($releaseWorkflow, 'Verify live docs release audit after public downloads'); - self::assertIsInt($preflightDocsGatePosition); self::assertIsInt($buildPosition); self::assertIsInt($uploadPosition); self::assertIsInt($publicDownloadPosition); - self::assertIsInt($postUploadDocsGatePosition); - self::assertLessThan($buildPosition, $preflightDocsGatePosition); - self::assertLessThan($uploadPosition, $preflightDocsGatePosition); - self::assertLessThan($postUploadDocsGatePosition, $publicDownloadPosition); + self::assertLessThan($uploadPosition, $buildPosition); + self::assertLessThan($publicDownloadPosition, $uploadPosition); } public function test_release_phpmicro_toolchain_is_pinned_verified_and_trust_scoped(): void @@ -509,7 +498,6 @@ public function test_build_validates_installer_scripts(): void self::assertStringContainsString('sh -n scripts/generate-homebrew-formula.sh', $buildWorkflow); self::assertStringContainsString('sh -n scripts/verify-release.sh', $buildWorkflow); self::assertStringContainsString('bash -n scripts/verify-public-release-assets.sh', $buildWorkflow); - self::assertStringContainsString('sh -n scripts/ci/check-docs-release-audit.sh', $buildWorkflow); self::assertStringContainsString('node --check scripts/ci/release-version.js', $buildWorkflow); self::assertStringContainsString('node --check scripts/ci/verify-cli-release-channel.js', $buildWorkflow); self::assertStringNotContainsString('verify-stable-release-authorization.js', $buildWorkflow); @@ -531,10 +519,8 @@ public function test_release_binds_the_exact_source_at_publication(): void self::assertStringContainsString('control_commit: ${{ steps.resolve.outputs.control_commit }}', $releaseWorkflow); self::assertStringContainsString('initiator: ${{ steps.resolve.outputs.initiator }}', $releaseWorkflow); self::assertSame(2, substr_count($releaseWorkflow, 'Checkout qualified release policy authority')); - self::assertSame(2, substr_count($releaseWorkflow, 'release-control/scripts/ci/check-docs-release-audit.sh')); self::assertSame(2, substr_count($releaseWorkflow, 'release-control/scripts/ci/verify-release-tag-source.sh')); self::assertStringContainsString('release-control/scripts/verify-public-release-assets.sh', $releaseWorkflow); - self::assertStringNotContainsString('run: scripts/ci/check-docs-release-audit.sh', $releaseWorkflow); self::assertStringContainsString('durable-workflow.cli.release-control-authority/v1', $releaseWorkflow); self::assertStringContainsString('"control": {"ref": "%s", "commit": "%s"}', $releaseWorkflow); self::assertSame(5, substr_count($releaseWorkflow, 'ref: ${{ needs.resolve-release.outputs.commit }}')); @@ -622,30 +608,6 @@ public function test_release_includes_checksum_and_attestation_verifier(): void self::assertStringContainsString('dw-windows-x86_64.exe', $publicAssetVerifier); } - public function test_docs_release_audit_writes_preflight_evidence(): void - { - $auditor = self::readRepoFile('scripts/ci/check-docs-release-audit.sh'); - - self::assertStringContainsString('DOCS_RELEASE_AUDIT_EVIDENCE', $auditor); - self::assertStringContainsString('DOCS_RELEASE_AUDIT_HANDOFF', $auditor); - self::assertStringContainsString('durable-workflow.release.docs-release-audit-evidence', $auditor); - self::assertStringContainsString('durable-workflow.release.docs-artifact-tuple-handoff', $auditor); - self::assertStringContainsString('docs-page-release-audit-${artifact}-${expected}-$$.json', $auditor); - self::assertStringContainsString('trap \'rm -f "$audit_path"\' EXIT HUP INT TERM', $auditor); - self::assertStringContainsString("surface: 'public_docs_release_audit'", $auditor); - self::assertStringContainsString("outcome: 'unavailable'", $auditor); - self::assertStringContainsString("writeEvidence('stale'", $auditor); - self::assertStringContainsString("writeEvidence('pass'", $auditor); - self::assertStringContainsString('actual_version: actualVersion', $auditor); - self::assertStringContainsString("schema: 'durable-workflow.docs.refresh-request'", $auditor); - self::assertStringContainsString("repository: 'durable-workflow.github.io'", $auditor); - self::assertStringContainsString('refresh_command: refreshCommand', $auditor); - self::assertStringContainsString('refresh_files: refreshFiles', $auditor); - self::assertStringContainsString('observed_artifact_versions: versions', $auditor); - self::assertStringContainsString('docs_refresh_request: docsRefreshRequest', $auditor); - self::assertStringContainsString('docs_artifact_tuple_handoff: handoff', $auditor); - } - public function test_release_publishes_generated_homebrew_formula(): void { $releaseWorkflow = self::readRepoFile('.github/workflows/release.yml');