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).
+
+
+
+
+
-## 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');