diff --git a/.github/workflows/docs-pr.yml b/.github/workflows/docs-pr.yml
index 64d22a92..408f8d56 100644
--- a/.github/workflows/docs-pr.yml
+++ b/.github/workflows/docs-pr.yml
@@ -9,6 +9,8 @@ on:
- 'overrides/**'
- 'mkdocs.yml'
- 'pyproject.toml'
+ - 'requirements-docs.txt'
+ - 'scripts/prepare_portal.py'
- 'scripts/check-docs-analytics.py'
- 'scripts/check-docs-layout.py'
- 'scripts/docstring_cross_references.py'
@@ -29,6 +31,8 @@ jobs:
timeout-minutes: 15
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ persist-credentials: false
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.12"
@@ -36,18 +40,28 @@ jobs:
uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust
with:
clients: pip,python
- - name: Install package and documentation dependencies
- run: pip install -e '.[docs]'
+ - name: Install pinned documentation dependencies
+ run: |
+ pip install --require-hashes --only-binary=:all: -r requirements-docs.txt
- name: Install Chromium
env:
NODE_OPTIONS: --use-openssl-ca
NODE_EXTRA_CA_CERTS: ''
NODE_USE_SYSTEM_CA: '0'
NODE_TLS_REJECT_UNAUTHORIZED: '1'
- run: python -m playwright install --with-deps chromium
+ run: python -m playwright install chromium
- name: Build and check the portal
run: |
- mkdocs build --strict
+ python scripts/prepare_portal.py
+ mkdocs build --strict --config-file .docs-build.yml
python scripts/docstring_cross_references.py --site site
python scripts/check-docs-analytics.py site
- python scripts/check-docs-layout.py site
+ python scripts/check-docs-layout.py site --screenshots docs-review
+ cp .docs-provenance.json docs-review/deployment-audit.json
+ cp .docs-api-inventory.json docs-review/api-source-inventory.json
+ - name: Retain rendered review screenshots
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: python-portal-review-${{ github.sha }}
+ path: docs-review
+ retention-days: 7
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 4232aff6..d31535ab 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -9,8 +9,10 @@ on:
- 'overrides/**'
- 'mkdocs.yml'
- 'pyproject.toml'
+ - 'requirements-docs.txt'
- 'scripts/check-docs-analytics.py'
- 'scripts/check-docs-layout.py'
+ - 'scripts/prepare_portal.py'
- 'scripts/docstring_cross_references.py'
- '.github/workflows/docs.yml'
workflow_dispatch:
@@ -45,8 +47,9 @@ jobs:
fi
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
- ref: ${{ (inputs.stable_tag != '' && format('refs/tags/{0}', inputs.stable_tag)) || github.sha }}
+ ref: ${{ github.sha }}
fetch-depth: 0
+ persist-credentials: false
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: "3.12"
@@ -54,27 +57,49 @@ jobs:
uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust
with:
clients: pip,python
+ - name: Check out the selected published SDK source
+ if: inputs.stable_tag != ''
+ uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
+ with:
+ ref: ${{ format('refs/tags/{0}', inputs.stable_tag) }}
+ path: .docs-sdk
+ persist-credentials: false
- name: Resolve the public documentation release channel
id: release-channel
env:
STABLE_TAG: ${{ inputs.stable_tag }}
GH_TOKEN: ${{ github.token }}
run: |
- version=$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')
+ docs_revision=$(git rev-parse HEAD)
+ test "$docs_revision" = "$GITHUB_SHA"
+ sdk_revision=$docs_revision
+ project=pyproject.toml
+ if [ -n "$STABLE_TAG" ]; then
+ project=.docs-sdk/pyproject.toml
+ fi
+ version=$(python - "$project" <<'PY'
+ import sys
+ import tomllib
+ print(tomllib.load(open(sys.argv[1], "rb"))["project"]["version"])
+ PY
+ )
if [ -n "$STABLE_TAG" ]; then
test "$GITHUB_EVENT_NAME" = workflow_dispatch
test "$GITHUB_REF" = refs/heads/main
test "$version" = "$STABLE_TAG"
released=$(gh release view "$STABLE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease --jq '(.isDraft == false and .isPrerelease == false)')
test "$released" = true
- source_revision=$(git rev-parse HEAD)
+ sdk_revision=$(git -C .docs-sdk rev-parse HEAD)
tag_revision=$(git rev-parse "refs/tags/$STABLE_TAG^{commit}")
- test "$source_revision" = "$tag_revision"
+ test "$sdk_revision" = "$tag_revision"
python - "$version" <<'PY'
import json
import re
import sys
import urllib.request
+ from urllib.parse import urlsplit
+ from pathlib import Path
+ import os
version = sys.argv[1]
with urllib.request.urlopen(
@@ -87,6 +112,7 @@ jobs:
if package.get("info", {}).get("name") != "durable-workflow" or package["info"].get("version") != version:
raise SystemExit("PyPI package identity differs from selected tag")
kinds = set()
+ wheel = None
for artifact in package.get("urls", []):
if artifact.get("yanked") is not False:
continue
@@ -96,26 +122,59 @@ jobs:
if not filename.startswith(f"durable_workflow-{version}-") and filename != f"durable_workflow-{version}.tar.gz":
raise SystemExit("PyPI artifact filename differs from selected package")
kinds.add(artifact.get("packagetype"))
+ if filename == f"durable_workflow-{version}-py3-none-any.whl":
+ if wheel is not None:
+ raise SystemExit("Selected package has duplicate universal wheels")
+ wheel = artifact
if not {"bdist_wheel", "sdist"} <= kinds:
raise SystemExit("Selected stable package has no unyanked wheel and sdist")
+ if wheel is None:
+ raise SystemExit("Selected package has no universal Python wheel")
+ url = urlsplit(wheel.get("url", ""))
+ if (url.geturl() != wheel["url"] or url.scheme != "https" or url.netloc != "files.pythonhosted.org"
+ or url.query or url.fragment or not url.path.startswith("/packages/")
+ or not url.path.endswith("/" + wheel["filename"])
+ or not re.fullmatch(r"[A-Za-z0-9/_.-]+", url.path)):
+ raise SystemExit("Published wheel URL is outside the package authority")
+ with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output:
+ output.write(f"wheel_url={wheel['url']}\nwheel_sha256={wheel['digests']['sha256']}\n")
PY
echo stable=true >> "$GITHUB_OUTPUT"
else
echo stable=false >> "$GITHUB_OUTPUT"
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
+ echo "docs_revision=$docs_revision" >> "$GITHUB_OUTPUT"
+ echo "sdk_revision=$sdk_revision" >> "$GITHUB_OUTPUT"
- name: Install package and documentation dependencies
- run: pip install -e '.[docs]'
+ env:
+ STABLE_TAG: ${{ inputs.stable_tag }}
+ PUBLISHED_WHEEL_URL: ${{ steps.release-channel.outputs.wheel_url }}
+ PUBLISHED_WHEEL_SHA256: ${{ steps.release-channel.outputs.wheel_sha256 }}
+ run: |
+ pip install --require-hashes --only-binary=:all: -r requirements-docs.txt
+ if [ -n "$STABLE_TAG" ]; then
+ python - <<'PY'
+ import os
+ from pathlib import Path
+ requirement = f"durable-workflow @ {os.environ['PUBLISHED_WHEEL_URL']} --hash=sha256:{os.environ['PUBLISHED_WHEEL_SHA256']}\n"
+ Path(".docs-published-wheel.txt").write_text(requirement)
+ PY
+ pip install --require-hashes --no-deps --only-binary=:all: -r .docs-published-wheel.txt
+ fi
- name: Install Chromium
env:
NODE_OPTIONS: --use-openssl-ca
NODE_EXTRA_CA_CERTS: ''
NODE_USE_SYSTEM_CA: '0'
NODE_TLS_REJECT_UNAUTHORIZED: '1'
- run: python -m playwright install --with-deps chromium
+ run: python -m playwright install chromium
- name: Build and check the portal
env:
CLOUDFLARE_WEB_ANALYTICS_TOKEN: ${{ vars.CLOUDFLARE_WEB_ANALYTICS_TOKEN }}
+ STABLE_TAG: ${{ inputs.stable_tag }}
+ DOCS_WHEEL_SHA256: ${{ steps.release-channel.outputs.wheel_sha256 }}
+ PYTHONDONTWRITEBYTECODE: '1'
run: |
if ! printf '%s' "$CLOUDFLARE_WEB_ANALYTICS_TOKEN" | grep -Eq '^[a-f0-9]{32}$'; then
echo 'CLOUDFLARE_WEB_ANALYTICS_TOKEN must be the canonical 32-character site token' >&2
@@ -123,29 +182,23 @@ jobs:
fi
sed -i "s/__CLOUDFLARE_WEB_ANALYTICS_TOKEN__/$CLOUDFLARE_WEB_ANALYTICS_TOKEN/" \
docs/javascripts/analytics.js
- mkdocs build --strict
+ python scripts/prepare_portal.py
+ mkdocs build --strict --config-file .docs-build.yml
python scripts/docstring_cross_references.py --site site
python scripts/check-docs-analytics.py site --require-token
- python scripts/check-docs-layout.py site
+ python scripts/check-docs-layout.py site --screenshots docs-review
test -s site/index.html
test "$(cat site/CNAME)" = "python.durable-workflow.com"
- - name: Bind Pages artifact to the exact source revision
- env:
- SDK_VERSION: ${{ steps.release-channel.outputs.version }}
+ - name: Bind the artifact to both documentation and SDK sources
run: |
- SOURCE_REVISION=$(git rev-parse HEAD)
- export SOURCE_REVISION
- python - <<'PY'
- import json
- import os
- from pathlib import Path
-
- Path("site/deployment-audit.json").write_text(json.dumps({
- "schema": "durable-workflow.sdk-python.docs-deployment/v1",
- "source_revision": os.environ["SOURCE_REVISION"],
- "sdk_version": os.environ["SDK_VERSION"],
- }) + "\n")
- PY
+ cp .docs-provenance.json site/deployment-audit.json
+ cp .docs-api-inventory.json site/api-source-inventory.json
+ - name: Retain rendered review screenshots
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: python-portal-review-${{ github.sha }}
+ path: docs-review
+ retention-days: 7
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
if: github.server_url == 'https://github.com'
with:
diff --git a/.gitignore b/.gitignore
index d8b04350..f0620d69 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,13 @@ dist/
build/
site/
*.egg-info/
+
+# Generated documentation source split and provenance
+.docs-sdk/
+.docs-stage-*/
+.docs-build.yml
+.docs-api-inventory.json
+.docs-provenance.json
+.docs-requirements.txt
+
+.docs-published-wheel.txt
diff --git a/docs/assets/favicon.svg b/docs/assets/favicon.svg
index 3141555e..02879965 100644
--- a/docs/assets/favicon.svg
+++ b/docs/assets/favicon.svg
@@ -1,5 +1,9 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/assets/logo.svg b/docs/assets/logo.svg
new file mode 100644
index 00000000..02879965
--- /dev/null
+++ b/docs/assets/logo.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/index.md b/docs/index.md
index 704a2561..8689adc3 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -11,7 +11,7 @@ hide:
-
First-party Python SDK · 2.0 stable
+
Python SDK · 2.14.1 published
# Durable workflows in Python.
@@ -33,10 +33,14 @@ namespace.
## Install
+
+
```bash
pip install durable-workflow
```
+
+
Use a virtual environment and lock the resolved package version with the rest
of your application dependencies.
@@ -53,39 +57,12 @@ of your application dependencies.
A client asks the runtime to do durable work. A worker receives tasks and
dispatches them to your workflow and activity code.
-
-
-
-
-
-
-## Choose who runs the runtime
-
-The workflow types, activity types, and task queue stay the same. Only the
-endpoint, credentials, and operating boundary change.
-
-
-
- Available without an account
- Self-hosted Server
- Run the published Server image locally, then execute the complete Python journey below.
- Start locally →
-
-
- Managed runtime · limited access
- Durable Workflow Cloud
- Connect the same program to a provisioned namespace with separate client and worker credentials.
-
- Connect a namespace
- Request early access →
-
-
-
+
@@ -96,16 +73,22 @@ endpoint, credentials, and operating boundary change.
This source-free path runs one Python file containing an activity, workflow,
worker, and client against the stable Server channel.
-### 1. Start Server
+### 1. Start Server { #start-server }
Docker keeps this first run local. Select the stable 2.x Server channel:
+
+
```bash
export DW_SERVER_IMAGE='durableworkflow/server:2'
```
+
+
Then bootstrap and start that qualified image:
+
+
```bash
export DURABLE_WORKFLOW_RUNTIME_URL='http://127.0.0.1:8080'
export DURABLE_WORKFLOW_RUNTIME_NAMESPACE='default'
@@ -122,13 +105,28 @@ docker run -d --name durable-workflow-python-server -p 8080:8080 \
until curl -sf http://127.0.0.1:8080/api/ready >/dev/null; do sleep 1; done
```
-### 2. Save `greeter.py`
+
+
+
+
+
+
Step 2 of 3
+
+### Save `greeter.py` { #save-greeter }
The named constants make the authoring contract visible: the decorator and
start call share a workflow type, the workflow and decorator share an activity
type, and the client and worker share one task queue. Values cross those
boundaries with the supported Avro authoring codec.
+First [start Server](#start-server), then save this complete program and
+[run it in step 3](#run-greeter). Copy preserves the whole file and its indentation.
+
+
+
+
+
+
```python
import asyncio
import logging
@@ -181,17 +179,29 @@ async def main() -> None:
asyncio.run(main())
```
-### 3. Run it
+
+
+
+
+
+
+### 3. Run it { #run-greeter }
+
+
```bash
python greeter.py
```
+
+
The SDK reports registration before it handles the workflow, followed by the
completed result:
+
+
```text
worker py-worker-… registered on python-workers
Hello, world!
@@ -199,6 +209,35 @@ Hello, world!
+
+
+
+
+
+
+## Choose who runs the runtime
+
+The workflow types, activity types, and task queue stay the same. Only the
+endpoint, credentials, and operating boundary change.
+
+
+
+ Available without an account
+ Self-hosted Server
+ Run the published Server image locally, then execute the complete Python journey below.
+ Start locally →
+
+
+ Managed runtime · limited access
+ Durable Workflow Cloud
+ Connect the same program to a provisioned namespace with separate client and worker credentials.
+
+ Connect a namespace
+ Request early access →
+
+
+
+
@@ -221,6 +260,8 @@ Replace the placeholders with values returned for your namespace, then run the
same `greeter.py`. Keep client and worker tokens in their respective processes
when you split the example for production.
+
+
```bash
export DURABLE_WORKFLOW_RUNTIME_URL='
'
export DURABLE_WORKFLOW_RUNTIME_NAMESPACE=''
@@ -230,6 +271,8 @@ unset DURABLE_WORKFLOW_TOKEN
python greeter.py
```
+
+
Cloud runtime guide Run the Python playground →
diff --git a/docs/javascripts/identity.js b/docs/javascripts/identity.js
new file mode 100644
index 00000000..7af03aaf
--- /dev/null
+++ b/docs/javascripts/identity.js
@@ -0,0 +1,72 @@
+import { watchPublicStatus } from "./status.mjs";
+
+/* Native disclosure semantics retain links and keyboard navigation without JS. */
+(() => {
+ const close = (switcher, restoreFocus) => {
+ if (!switcher.open) return;
+ switcher.open = false;
+ if (restoreFocus) switcher.querySelector("summary").focus();
+ };
+ document.querySelectorAll("[data-sdk-switcher]").forEach((switcher) => {
+ switcher.addEventListener("keydown", (event) => {
+ if (event.key === "Escape" && switcher.open) {
+ event.preventDefault();
+ close(switcher, true);
+ }
+ });
+ document.addEventListener("click", (event) => {
+ if (!switcher.contains(event.target)) close(switcher, false);
+ });
+ });
+
+ document.querySelectorAll('.md-header__option label[for^="__palette_"]').forEach((control) => {
+ control.setAttribute("role", "button");
+ control.setAttribute("aria-label", control.title);
+ control.tabIndex = 0;
+ control.addEventListener("keydown", (event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ // Material also handles Enter globally; one activation changes the theme once.
+ event.stopPropagation();
+ control.click();
+ requestAnimationFrame(() => {
+ document.querySelector('.md-header__option input:checked')?.nextElementSibling?.focus();
+ });
+ }
+ });
+ });
+
+ const pill = document.querySelector("[data-public-status]");
+ if (!pill) return;
+ const labels = {
+ operational: "All systems operational",
+ degraded: "Degraded performance",
+ incident: "Service incident",
+ maintenance: "Maintenance",
+ stale: "Status stale",
+ unverified: "Status unverified",
+ unavailable: "Status unavailable",
+ unknown: "Status unknown",
+ };
+ const renderStatus = (state) => {
+ const safeState = Object.hasOwn(labels, state) ? state : "unknown";
+ if (pill.dataset.publicStatus === safeState) return;
+ pill.dataset.publicStatus = safeState;
+ pill.querySelector("[data-status-label]").textContent = labels[safeState];
+ };
+ let stopStatus;
+ const startStatus = () => {
+ stopStatus?.();
+ renderStatus("unknown");
+ stopStatus = watchPublicStatus(renderStatus);
+ };
+ startStatus();
+ window.addEventListener("pagehide", () => {
+ stopStatus?.();
+ stopStatus = undefined;
+ renderStatus("unknown");
+ });
+ window.addEventListener("pageshow", (event) => {
+ if (event.persisted) startStatus();
+ });
+})();
diff --git a/docs/javascripts/status.mjs b/docs/javascripts/status.mjs
new file mode 100644
index 00000000..2659eae6
--- /dev/null
+++ b/docs/javascripts/status.mjs
@@ -0,0 +1,109 @@
+// Shared public footer summary. No component rows, credentials or telemetry.
+export const STATUS_API = 'https://status.durable-workflow.com/api/current.json';
+const statuses = new Set(['ok', 'warning', 'failed', 'unknown']);
+const levels = new Set(['ok', 'warning', 'critical', 'unknown']);
+const unknown = () => ({state: 'unknown', validUntil: 0});
+function timestamp(value) {
+ if (typeof value !== 'string') return Number.NaN;
+ const match = /^(\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d)(?:\.\d{1,9})?(Z|([+-])(\d\d):(\d\d))$/.exec(value);
+ if (!match) return Number.NaN;
+ const parsed = Date.parse(value);
+ if (!Number.isFinite(parsed)) return Number.NaN;
+ const offsetMinutes = match[2] === 'Z' ? 0 : Number(match[4]) * 60 + Number(match[5]);
+ const offsetSign = match[3] === '-' ? -1 : 1;
+ // Date.parse normalizes impossible days and 24:00. Round-trip the supplied
+ // calendar fields in their original offset before accepting the evidence.
+ const suppliedCalendar = new Date(parsed + offsetMinutes * offsetSign * 60000).toISOString().slice(0, 19);
+ return suppliedCalendar === match[1] ? parsed : Number.NaN;
+}
+
+function evidenceWindow(snapshot, now) {
+ if (snapshot?.schema !== 'durable_workflow.status_page_snapshot.v1'
+ || snapshot.product !== 'cloud' || snapshot.environment !== 'prod' || !Number.isFinite(now)) return unknown();
+ const checked = timestamp(snapshot.checked_at);
+ const generated = timestamp(snapshot.generated_at);
+ const freshness = snapshot.freshness;
+ if (!Number.isFinite(checked) || !Number.isFinite(generated) || checked > now
+ || generated > now || generated < checked || !freshness
+ || !Number.isFinite(freshness.stale_after_seconds) || freshness.stale_after_seconds <= 0
+ || freshness.stale_after_seconds > 900 || !Number.isFinite(freshness.age_seconds)
+ || freshness.age_seconds < 0) return unknown();
+ const validUntil = checked + freshness.stale_after_seconds * 1000;
+ if (freshness.status === 'stale' || now >= validUntil || freshness.age_seconds >= freshness.stale_after_seconds) return {state: 'stale', validUntil: 0};
+ if (freshness.status !== 'fresh') return unknown();
+ return {validUntil};
+}
+
+function aggregateState(snapshot, validUntil) {
+ const impact = snapshot.customer_impact;
+ if (!statuses.has(snapshot.status) || !levels.has(snapshot.alert_level)
+ || !impact || !statuses.has(impact.status) || !levels.has(impact.alert_level)) return unknown();
+ const reason = snapshot.meta?.public_status_reason;
+ if (reason === 'cloud_unreachable') return {state: 'unavailable', validUntil};
+ if (reason === 'monitor_stale' || reason === 'probe_unverified') return {state: 'unverified', validUntil};
+ if (reason === 'publisher_stale') return {state: 'stale', validUntil: 0};
+ if (reason !== undefined && reason !== null && reason !== '') return unknown();
+ const stateTokens = new Set([snapshot.status, impact.status]);
+ const levelTokens = new Set([snapshot.alert_level, impact.alert_level]);
+ if (stateTokens.has('failed') || levelTokens.has('critical')) return {state: 'incident', validUntil};
+ if (stateTokens.has('warning') || levelTokens.has('warning')) {
+ const families = impact.affected_families;
+ const maintenanceOnly = Array.isArray(families) && families.length === 1 && families[0] === 'maintenance';
+ return {state: maintenanceOnly ? 'maintenance' : 'degraded', validUntil};
+ }
+ if (stateTokens.has('unknown') || levelTokens.has('unknown')) return {state: 'unknown', validUntil};
+ return {state: 'operational', validUntil};
+}
+
+export function publicStatus(snapshot, now = Date.now()) {
+ const evidence = evidenceWindow(snapshot, now);
+ return evidence.state ? evidence : aggregateState(snapshot, evidence.validUntil);
+}
+
+export async function fetchPublicStatus({fetchImpl = globalThis.fetch, now = Date.now, signal} = {}) {
+ const response = await fetchImpl(STATUS_API, {
+ method: 'GET', mode: 'cors', credentials: 'omit', cache: 'no-store',
+ redirect: 'error', referrerPolicy: 'no-referrer', signal,
+ });
+ if (!response.ok) throw new Error('Public status unavailable');
+ const body = await response.text();
+ if (body.length > 65536) throw new Error('Public status response too large');
+ return publicStatus(JSON.parse(body), now());
+}
+
+// At most one request at a time, five-second deadline, sixty-second cadence.
+// Invalidate a previously successful result at its evidence cutoff, even if the
+// next request hangs or the tab has been backgrounded. The caller owns cleanup.
+export function watchPublicStatus(onChange, {fetchImpl = globalThis.fetch, now = Date.now} = {}) {
+ let stopped = false;
+ let controller;
+ let timer;
+ let expiry;
+ const publish = summary => {
+ clearTimeout(expiry);
+ onChange(summary.state);
+ if (summary.validUntil > now()) {
+ expiry = setTimeout(() => onChange('stale'), summary.validUntil - now());
+ }
+ };
+ const refresh = async () => {
+ controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), 5000);
+ try {
+ const summary = await fetchPublicStatus({fetchImpl, now, signal: controller.signal});
+ if (!stopped) publish(summary);
+ } catch {
+ if (!stopped) publish(unknown());
+ } finally {
+ clearTimeout(timeout);
+ if (!stopped) timer = setTimeout(refresh, 60000);
+ }
+ };
+ void refresh();
+ return () => {
+ stopped = true;
+ controller?.abort();
+ clearTimeout(timer);
+ clearTimeout(expiry);
+ };
+}
diff --git a/docs/stylesheets/fonts/inter-OFL.txt b/docs/stylesheets/fonts/inter-OFL.txt
new file mode 100644
index 00000000..21f6aff9
--- /dev/null
+++ b/docs/stylesheets/fonts/inter-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/docs/stylesheets/fonts/inter-latin.woff2 b/docs/stylesheets/fonts/inter-latin.woff2
new file mode 100644
index 00000000..d15208de
Binary files /dev/null and b/docs/stylesheets/fonts/inter-latin.woff2 differ
diff --git a/docs/stylesheets/fonts/jetbrains-mono-latin.woff2 b/docs/stylesheets/fonts/jetbrains-mono-latin.woff2
new file mode 100644
index 00000000..4d09cda4
Binary files /dev/null and b/docs/stylesheets/fonts/jetbrains-mono-latin.woff2 differ
diff --git a/docs/stylesheets/fonts/jetbrainsmono-OFL.txt b/docs/stylesheets/fonts/jetbrainsmono-OFL.txt
new file mode 100644
index 00000000..821a3dac
--- /dev/null
+++ b/docs/stylesheets/fonts/jetbrainsmono-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+
+This license is copied below, and is also available with a FAQ at: https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/docs/stylesheets/fonts/manifest.json b/docs/stylesheets/fonts/manifest.json
new file mode 100644
index 00000000..7eff1ef3
--- /dev/null
+++ b/docs/stylesheets/fonts/manifest.json
@@ -0,0 +1,38 @@
+{
+ "css_source": "https://fonts.googleapis.com/css2?family=Inter:wght@400..600&family=JetBrains+Mono:wght@400..600&family=Space+Grotesk:wght@600&display=swap",
+ "fonts": [
+ {
+ "bytes": 48256,
+ "family": "Inter",
+ "file": "inter-latin.woff2",
+ "license_file": "inter-OFL.txt",
+ "license_sha256": "5b9321a4298cfeb6b34354164a1c3afc3db114569984c502b9b35d988fd58c57",
+ "license_source": "https://raw.githubusercontent.com/google/fonts/24ddc1f324cac5d9f5aedca126d298f56e3f1640/ofl/inter/OFL.txt",
+ "sha256": "3100e775e8616cd2611beecfa23a4263d7037586789b43f035236a2e6fbd4c62",
+ "source": "https://fonts.gstatic.com/s/inter/v20/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa1ZL7.woff2"
+ },
+ {
+ "bytes": 31432,
+ "family": "JetBrains Mono",
+ "file": "jetbrains-mono-latin.woff2",
+ "license_file": "jetbrainsmono-OFL.txt",
+ "license_sha256": "b2fe5e8987594e9ffd1d2ca52a2f5d73eb8335243893c5d6254b5ad69269591d",
+ "license_source": "https://raw.githubusercontent.com/google/fonts/24ddc1f324cac5d9f5aedca126d298f56e3f1640/ofl/jetbrainsmono/OFL.txt",
+ "sha256": "83c005d49d8a6a50474c73a5a36ac0468076e9c4a29da7bdb14995d80560a5be",
+ "source": "https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxDcwg.woff2"
+ },
+ {
+ "bytes": 13284,
+ "family": "Space Grotesk",
+ "file": "space-grotesk-latin.woff2",
+ "license_file": "spacegrotesk-OFL.txt",
+ "license_sha256": "564ce565c371c5e5bbf286006565a7c9aa55a9f56e7ca58d56e05d649dd61a72",
+ "license_source": "https://raw.githubusercontent.com/google/fonts/24ddc1f324cac5d9f5aedca126d298f56e3f1640/ofl/spacegrotesk/OFL.txt",
+ "sha256": "685bbbf69fa616df1ef81847c85fc76be097ddfb3468ff2257be54511ab3130f",
+ "source": "https://fonts.gstatic.com/s/spacegrotesk/v22/V8mQoQDjQSkFtoMM3T6r8E7mF71Q-gOoraIAEj42VnskPMA.woff2"
+ }
+ ],
+ "license_repository_commit": "24ddc1f324cac5d9f5aedca126d298f56e3f1640",
+ "runtime_cdn": false,
+ "self_hosted": true
+}
diff --git a/docs/stylesheets/fonts/space-grotesk-latin.woff2 b/docs/stylesheets/fonts/space-grotesk-latin.woff2
new file mode 100644
index 00000000..a1db41a0
Binary files /dev/null and b/docs/stylesheets/fonts/space-grotesk-latin.woff2 differ
diff --git a/docs/stylesheets/fonts/spacegrotesk-OFL.txt b/docs/stylesheets/fonts/spacegrotesk-OFL.txt
new file mode 100644
index 00000000..cb512b9a
--- /dev/null
+++ b/docs/stylesheets/fonts/spacegrotesk-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2020 The Space Grotesk Project Authors (https://github.com/floriankarsten/space-grotesk)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/docs/stylesheets/identity-fonts.css b/docs/stylesheets/identity-fonts.css
new file mode 100644
index 00000000..fb6e203c
--- /dev/null
+++ b/docs/stylesheets/identity-fonts.css
@@ -0,0 +1,25 @@
+/* Self-hosted OFL fonts. Sources and hashes: fonts/manifest.json. */
+@font-face {
+ font-family: 'Inter';
+ src: url('./fonts/inter-latin.woff2') format('woff2');
+ font-style: normal;
+ font-weight: 400 600;
+ font-display: swap;
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+@font-face {
+ font-family: 'JetBrains Mono';
+ src: url('./fonts/jetbrains-mono-latin.woff2') format('woff2');
+ font-style: normal;
+ font-weight: 400 600;
+ font-display: swap;
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+@font-face {
+ font-family: 'Space Grotesk';
+ src: url('./fonts/space-grotesk-latin.woff2') format('woff2');
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
diff --git a/docs/stylesheets/identity-tokens.css b/docs/stylesheets/identity-tokens.css
new file mode 100644
index 00000000..ab57ae7a
--- /dev/null
+++ b/docs/stylesheets/identity-tokens.css
@@ -0,0 +1,36 @@
+/* One Durable Workflow. Shared role tokens; no framework or remote imports. */
+:root,
+[data-theme='light'] {
+ color-scheme: light;
+ --dw-paper: #f6f7f5;
+ --dw-surface: #ffffff;
+ --dw-ink: #0f1513;
+ --dw-ink-2: #3b4541;
+ --dw-muted: #5e6964;
+ --dw-line: #dde2df;
+ --dw-action: #0f7a52;
+ --dw-tint: #e3f4ec;
+ --dw-on-action: #ffffff;
+ --dw-font-display: 'Space Grotesk', Inter, system-ui, sans-serif;
+ --dw-font-body: Inter, system-ui, sans-serif;
+ --dw-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Consolas, monospace;
+ --dw-code-background: #0f1513;
+ --dw-code-foreground: #e8edea;
+ --dw-code-muted: #9aa6a1;
+ --dw-logo-paper: #f6f7f5;
+ --dw-radius-action: 6px;
+ --dw-radius-card: 8px;
+}
+
+[data-theme='dark'] {
+ color-scheme: dark;
+ --dw-paper: #0f1513;
+ --dw-surface: #172019;
+ --dw-ink: #e8edea;
+ --dw-ink-2: #b7c2bd;
+ --dw-muted: #9aa6a1;
+ --dw-line: #2e3934;
+ --dw-action: #5fe0a6;
+ --dw-tint: #123b2a;
+ --dw-on-action: #0f1513;
+}
diff --git a/docs/stylesheets/layout.css b/docs/stylesheets/layout.css
index 3101e89b..7dc1ec0e 100644
--- a/docs/stylesheets/layout.css
+++ b/docs/stylesheets/layout.css
@@ -1,783 +1,1181 @@
-:root,
-[data-md-color-scheme="default"] {
- --dw-blue: #3156d3;
- --dw-blue-deep: #1f3ca5;
- --dw-yellow: #ffd43b;
- --dw-ink: #172033;
- --dw-muted: #566078;
- --dw-panel: #f5f7ff;
- --dw-panel-raised: #ffffff;
- --dw-line: #dce2f2;
- --dw-code: #111827;
- --dw-code-ink: #edf2ff;
+/* Material maps its persisted scheme onto the canonical shared role tokens. */
+:root, [data-md-color-scheme="default"] {
+ --dw-blue: var(--dw-action);
+ --dw-blue-deep: var(--dw-action);
+ --dw-panel: var(--dw-paper);
+ --dw-panel-raised: var(--dw-surface);
+ --dw-code: var(--dw-code-background);
+ --dw-code-ink: var(--dw-code-foreground);
}
[data-md-color-scheme="slate"] {
- --dw-blue: #91a7ff;
- --dw-blue-deep: #b6c4ff;
- --dw-yellow: #ffe278;
- --dw-ink: #f2f5ff;
- --dw-muted: #bac3d9;
- --dw-panel: #17202f;
- --dw-panel-raised: #1d2839;
- --dw-line: #344159;
- --dw-code: #0a101b;
- --dw-code-ink: #edf2ff;
+ color-scheme: dark;
+ --dw-paper: #0f1513;
+ --dw-surface: #172019;
+ --dw-ink: #e8edea;
+ --dw-ink-2: #b7c2bd;
+ --dw-muted: #9aa6a1;
+ --dw-line: #2e3934;
+ --dw-action: #5fe0a6;
+ --dw-tint: #123b2a;
+ --dw-on-action: #0f1513;
}
-@media screen and (max-width: 44.984375em) {
- .md-header__topic {
- font-size: 0.7rem;
- }
-}
-
-.dw-landing {
- color: var(--dw-ink);
- margin-top: 0.75rem;
-}
+/* Structural primitives; visual values come from the shared role tokens. */
.dw-landing * {
box-sizing: border-box;
}
.dw-landing a {
- text-underline-offset: 0.18em;
+ text-underline-offset: .18em;
}
-.dw-hero {
+.dw-hero__actions, .dw-inline-actions, .dw-runtime-card__actions {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.dw-role-grid, .dw-link-grid, .dw-runtime-grid {
display: grid;
- gap: 1.5rem;
- grid-template-columns: minmax(0, 1.3fr) minmax(18rem, 0.7fr);
- padding: 1.5rem 0 1.75rem;
+ gap: 16px;
}
-.dw-eyebrow,
-.dw-tag {
- color: var(--dw-blue);
- display: block;
- font-size: 0.68rem;
- font-weight: 750;
- letter-spacing: 0;
- margin: 0 0 0.8rem;
- text-transform: uppercase;
+.dw-link-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
}
-.dw-hero h1 {
- color: var(--dw-ink);
- font-size: 3.2rem;
- letter-spacing: 0;
- line-height: 0.98;
- margin: 0;
- max-width: 13ch;
+.dw-role-card, .dw-link-card, .dw-runtime-card {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+ text-decoration: none;
}
-.dw-hero__copy > p:not(.dw-eyebrow, .dw-hero__facts) {
- color: var(--dw-muted);
- font-size: 1.02rem;
- line-height: 1.65;
- margin: 1.25rem 0 0;
- max-width: 35rem;
+.dw-role-card__link, .dw-link-card > span:last-child {
+ margin-top: auto;
+ padding-top: 12px;
+ color: var(--dw-action);
+ font-size: 13px;
+ font-weight: 600;
}
-.dw-hero__actions {
- align-items: center;
- display: flex;
- flex-wrap: wrap;
- gap: 0.65rem;
- margin-top: 1.55rem;
+.dw-link-card {
+ gap: 8px;
}
-.dw-button {
- align-items: center;
- border: 1px solid var(--dw-blue);
- border-radius: 0.5rem;
- display: inline-flex;
- font-size: 0.74rem;
- font-weight: 720;
- justify-content: center;
- min-height: 2.55rem;
- padding: 0.58rem 0.7rem;
- text-decoration: none;
+.dw-link-card strong {
+ font-size: 16px;
+ color: var(--dw-ink);
+}
+
+.dw-link-card > span:nth-child(2) {
+ font-size: 14px;
+ color: var(--dw-ink-2);
}
-.dw-button--primary {
- background: var(--dw-blue);
- color: #fff !important;
+.dw-first-run > h3 {
+ font-size: 24px;
+ margin: 32px 0 12px;
}
-[data-md-color-scheme="slate"] .dw-button--primary {
- color: #111827 !important;
+.dw-first-run h3 code {
+ background: transparent;
+ color: inherit;
+ font: inherit;
}
-.dw-button--secondary {
- background: var(--dw-panel-raised);
- color: var(--dw-blue-deep) !important;
+.dw-runtime-card .dw-text-link {
+ align-self: flex-start;
}
-.dw-text-link {
- color: var(--dw-blue-deep) !important;
- font-size: 0.72rem;
- font-weight: 700;
- padding: 0.4rem 0.2rem;
+.dw-runtime-card__actions {
+ gap: 12px;
+ margin-top: auto;
}
-.dw-button:hover,
-.dw-button:focus-visible,
-.dw-role-card:hover,
-.dw-role-card:focus-visible,
-.dw-link-card:hover,
-.dw-link-card:focus-visible {
- border-color: var(--dw-blue-deep);
- box-shadow: 0 0 0 0.12rem var(--md-default-bg-color), 0 0 0 0.25rem var(--dw-blue);
+.dw-cloud-promotion__action {
+ color: var(--dw-action);
+ font-size: 13px;
+ font-weight: 600;
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
}
-.dw-button:focus-visible,
-.dw-text-link:focus-visible,
-.dw-role-card:focus-visible,
-.dw-link-card:focus-visible,
-.dw-cloud-promotion__action:focus-visible {
- outline: none;
+.dw-credential-grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 16px;
+ list-style: none;
+ padding: 0;
+ margin: 0 0 24px;
}
-.dw-hero__facts {
- color: var(--dw-muted);
+.dw-credential-grid li {
display: flex;
- flex-wrap: wrap;
- font-size: 0.68rem;
- font-weight: 650;
- gap: 0.45rem 1rem;
- margin: 1.3rem 0 0;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 0;
}
-.dw-hero__facts span::before {
- background: var(--dw-blue);
- border-radius: 50%;
- content: "";
- display: inline-block;
- height: 0.28rem;
- margin-right: 0.35rem;
- vertical-align: 0.08rem;
- width: 0.28rem;
+.dw-credential-grid strong {
+ font-size: 16px;
}
-.dw-install-card {
- align-self: center;
- background: var(--dw-code);
- border: 1px solid rgb(255 255 255 / 12%);
- border-radius: 0.4rem;
- box-shadow: 0 1.2rem 2.8rem rgb(20 31 60 / 15%);
- color: var(--dw-code-ink);
- min-width: 0;
- overflow: hidden;
- padding: 1.1rem;
+.dw-credential-grid span {
+ font-size: 14px;
+ color: var(--dw-ink-2);
}
-.dw-install-card h2 {
- color: var(--dw-code-ink);
- font-size: 0.72rem;
- letter-spacing: 0;
- margin: 0 0 0.7rem;
- text-transform: uppercase;
+.dw-credential-grid code {
+ overflow-wrap: anywhere;
}
-.dw-install-card .highlight {
- margin: 0;
+.dw-inline-actions {
+ gap: 12px;
+ margin-top: 20px;
}
-.dw-install-card .highlight pre {
- background: rgb(255 255 255 / 6%);
- border: 1px solid rgb(255 255 255 / 9%);
- border-radius: 0.4rem;
- color: var(--dw-code-ink);
- font-size: 0.68rem;
- max-width: 100%;
- overflow-x: auto;
+.dw-checkpoint > p {
+ color: var(--dw-ink-2);
+ font-size: 14px;
}
-.dw-install-card > p:not(.dw-install-card__links) {
- color: #aebad0;
- font-size: 0.7rem;
- line-height: 1.55;
- margin: 0.85rem 0 0;
+@media (prefers-reduced-motion: reduce) {
+ .dw-landing *,
+ .dw-landing *::before,
+ .dw-landing *::after {
+ scroll-behavior: auto;
+ transition: none;
+ }
}
-.dw-install-card__links {
- display: flex;
- flex-wrap: wrap;
- gap: 0.8rem;
- margin: 0.8rem 0 0;
+@media screen and (max-width: 59.984375em) {
+ .md-sidebar--primary .md-nav__list.dw-navigation__ancestor-list {
+ overflow: visible;
+ }
+ .md-nav--primary > .dw-navigation__close {
+ display: flex;
+ }
+ .md-sidebar--primary .md-source__repository {
+ font-size: 0.6rem;
+ }
+ #__search:checked ~ header .md-search__overlay {
+ border-radius: 0;
+ height: 100dvh;
+ inset: 0;
+ position: fixed;
+ transform: none;
+ width: 100vw;
+ }
+ #__search:checked ~ .md-container {
+ visibility: hidden;
+ }
}
-.dw-install-card__links a {
- color: var(--dw-yellow) !important;
- font-size: 0.68rem;
- font-weight: 700;
+.md-header__button[role="button"]:focus-visible,
+.dw-navigation__close:focus-visible,
+.md-search__icon[role="button"]:focus-visible {
+ border-radius: 0.1rem;
+ outline: 0.15rem solid currentColor;
+ outline-offset: 0.1rem;
}
-.dw-section {
- border-top: 1px solid var(--dw-line);
- margin-top: 2.5rem;
- padding-top: 2rem;
+.dw-navigation__close {
+ align-items: center;
+ background-color: var(--md-primary-bg-color);
+ border: 0;
+ border-radius: 50%;
+ color: var(--md-primary-fg-color);
+ cursor: pointer;
+ display: none;
+ height: 2rem;
+ justify-content: center;
+ position: absolute;
+ right: 0.4rem;
+ top: 0.4rem;
+ width: 2rem;
+ z-index: 2;
}
-.dw-section > h2 {
- color: var(--dw-ink);
- font-size: 2.2rem;
- letter-spacing: 0;
- line-height: 1.08;
- margin: 0;
+.dw-navigation__close svg {
+ height: 1.2rem;
+ width: 1.2rem;
}
-.dw-section > h2 + p {
- color: var(--dw-muted);
- line-height: 1.7;
- margin: 0.75rem 0 1.5rem;
- max-width: 42rem;
+.dw-navigation__close:focus-visible {
+ border-radius: 50%;
}
-.dw-role-grid,
-.dw-link-grid {
- display: grid;
- gap: 0.75rem;
- grid-template-columns: repeat(4, minmax(0, 1fr));
+#__search:checked ~ header .md-search__icon[for="__search"][role="button"] {
+ pointer-events: auto;
}
-.dw-role-card,
-.dw-link-card {
- background: var(--dw-panel-raised);
- border: 1px solid var(--dw-line);
- border-radius: 0.4rem;
- color: var(--dw-ink) !important;
- display: flex;
- flex-direction: column;
- min-width: 0;
- padding: 1rem;
- text-decoration: none;
+#__search:checked ~ header .md-search__icon[for="__search"][role="button"] svg:first-child {
+ display: none;
}
-.dw-role-card {
- min-height: 10rem;
+#__search:checked ~ header .md-search__icon[for="__search"][role="button"] svg:last-child {
+ display: block;
}
-.dw-role-card__step {
- color: var(--dw-blue);
- font-family: var(--md-code-font-family);
- font-size: 0.62rem;
- font-weight: 700;
+/* Keep result-group expanders in flow inside the modal instead of floating
+ above unrelated inert document content at the overlay boundary. */
+.md-search-result__more > summary {
+ position: static;
}
-.dw-role-card strong,
-.dw-link-card strong {
- font-size: 0.9rem;
- margin: 0.65rem 0 0.35rem;
+#__search:checked ~ header [inert] {
+ visibility: hidden;
}
-.dw-role-card > span:not(.dw-role-card__step, .dw-role-card__link),
-.dw-link-card > span:nth-child(2) {
- color: var(--dw-muted);
- font-size: 0.7rem;
- line-height: 1.5;
+/* One Identity: shared chrome and Python documentation components. */
+:root, [data-md-color-scheme="default"], [data-md-color-scheme="slate"] {
+ --md-text-font: var(--dw-font-body);
+ --md-code-font: var(--dw-font-mono);
+ --md-default-fg-color: var(--dw-ink);
+ --md-default-fg-color--light: var(--dw-muted);
+ --md-default-bg-color: var(--dw-paper);
+ --md-primary-fg-color: var(--dw-action);
+ --md-primary-bg-color: var(--dw-on-action);
+ --md-accent-fg-color: var(--dw-action);
+ --md-code-bg-color: var(--dw-code-background);
+ --md-code-fg-color: var(--dw-code-foreground);
+ --md-code-hl-color: #123b2a;
+ --md-code-hl-number-color: #e8edea;
+ --md-code-hl-special-color: #e8edea;
+ --md-code-hl-function-color: #e8edea;
+ --md-code-hl-constant-color: #e8edea;
+ --md-code-hl-keyword-color: #5fe0a6;
+ --md-code-hl-string-color: #5fe0a6;
+ --md-code-hl-name-color: #e8edea;
+ --md-code-hl-operator-color: #e8edea;
+ --md-code-hl-punctuation-color: #e8edea;
+ --md-code-hl-comment-color: #9aa6a1;
+ --md-code-hl-generic-color: #9aa6a1;
+ --md-code-hl-variable-color: #e8edea;
+}
+
+body, .md-main {
+ background: var(--dw-paper);
+ color: var(--dw-ink);
}
-.dw-role-card__link,
-.dw-link-card > span:last-child {
- color: var(--dw-blue-deep);
- font-size: 0.68rem;
- font-weight: 700;
- margin-top: auto;
- padding-top: 0.9rem;
+body {
+ font-family: var(--dw-font-body);
}
-.dw-first-run {
- counter-reset: local-step;
+.md-grid {
+ max-width: 1200px;
}
-.dw-first-run > h3 {
- color: var(--dw-ink);
- counter-increment: local-step;
- font-size: 1.05rem;
- margin: 2.2rem 0 0.7rem;
+.md-typeset {
+ font-size: 16px;
+ line-height: 1.5;
}
-.dw-first-run > h3::before {
- align-items: center;
- background: var(--dw-blue);
- border-radius: 50%;
- color: #fff;
- content: counter(local-step);
- display: inline-flex;
- font-size: 0.65rem;
- height: 1.55rem;
- justify-content: center;
- margin-right: 0.55rem;
- vertical-align: 0.1rem;
- width: 1.55rem;
+.md-typeset h1, .md-typeset h2, .md-typeset h3 {
+ color: var(--dw-ink);
+ font-family: var(--dw-font-display);
+ font-weight: 600;
+ letter-spacing: -.03em;
+ scroll-margin-top: 150px;
}
-.dw-first-run > p,
-.dw-version > p {
- color: var(--dw-muted);
- line-height: 1.7;
- max-width: 45rem;
+.md-typeset a {
+ color: var(--dw-action);
}
-.dw-first-run .highlight {
- border: 1px solid var(--dw-line);
- border-radius: 0.4rem;
- max-width: 100%;
- overflow: hidden;
+a, button, summary, input {
+ -webkit-tap-highlight-color: transparent;
}
-.dw-first-run .highlight pre {
- max-width: 100%;
- overflow-x: auto;
+a:focus-visible, button:focus-visible, summary:focus-visible, input:focus-visible {
+ outline: 2px solid var(--dw-action);
+ outline-offset: 4px;
}
-.dw-runtime-grid {
- display: grid;
- gap: 0.9rem;
- grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
+.dw-header {
+ color: var(--dw-ink);
+ background: var(--dw-surface);
+ box-shadow: none;
}
-.dw-runtime-card {
- border: 1px solid var(--dw-line);
- border-radius: 0.4rem;
+.dw-global-bar {
display: flex;
- flex-direction: column;
- min-width: 0;
- padding: 1.25rem;
+ align-items: center;
+ gap: 32px;
+ min-height: 76px;
+ padding: 12px 24px;
}
-.dw-runtime-card--primary {
- background: var(--dw-panel);
- border-left: 0.25rem solid var(--dw-blue);
+.dw-brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ flex-shrink: 0;
+ color: var(--dw-ink);
+ text-decoration: none;
}
-.dw-runtime-card--secondary {
- background: transparent;
- border-style: dashed;
+.dw-brand__mark {
+ display: block;
+ object-fit: contain;
+ width: 30px;
+ height: 30px;
}
-.dw-runtime-card h3,
-.dw-runtime-card p {
- margin: 0;
+[data-md-color-scheme="slate"] .dw-brand__mark {
+ box-sizing: content-box;
+ padding: 4px;
+ border-radius: 8px;
+ background: var(--dw-logo-paper);
}
-.dw-runtime-card h3 {
- color: var(--dw-ink);
- font-size: 1.1rem;
+.dw-brand__name {
+ font-family: var(--dw-font-display);
+ font-weight: 600;
+ font-size: 17px;
+ white-space: nowrap;
}
-.dw-runtime-card p {
+.dw-brand__slash {
color: var(--dw-muted);
- font-size: 0.74rem;
- line-height: 1.6;
- margin: 0.55rem 0 1rem;
+ font-size: 16px;
}
-.dw-runtime-card .dw-button {
- align-self: flex-start;
- margin-top: auto;
+.dw-brand__surface {
+ color: var(--dw-action);
+ font: 500 13px var(--dw-font-mono);
}
-.dw-runtime-card__actions {
- align-items: center;
+.dw-global-nav {
display: flex;
- flex-wrap: wrap;
- gap: 0.55rem 0.85rem;
- margin-top: auto;
+ align-items: center;
+ flex: 1;
+ gap: 24px;
+ font-size: 14px;
}
-.dw-runtime-card__actions .dw-button,
-.dw-runtime-card__actions .dw-cloud-promotion__action {
- margin-top: 0;
+.dw-global-nav > a, .dw-sdk-switcher summary {
+ color: var(--dw-ink-2);
+ text-decoration: none;
+ min-height: 44px;
+ display: flex;
+ align-items: center;
}
-.dw-cloud-promotion__eyebrow {
- letter-spacing: 0;
+.dw-sdk-switcher {
+ position: relative;
}
-.dw-cloud-promotion__action {
- align-self: flex-start;
- color: var(--dw-blue-deep) !important;
- font-size: 0.72rem;
- font-weight: 700;
- margin-top: auto;
- padding: 0.3rem 0;
+.dw-sdk-switcher summary {
+ cursor: pointer;
+ gap: 6px;
+ font-weight: 600;
+ color: var(--dw-ink);
+ list-style: none;
}
-.dw-cloud-promotion__action:hover,
-.dw-cloud-promotion__action:focus-visible {
- box-shadow: 0 0.15rem 0 var(--dw-blue);
+.dw-sdk-switcher summary::-webkit-details-marker {
+ display: none;
}
-.dw-checkpoint {
- background: var(--dw-panel);
+.dw-sdk-switcher ul {
+ position: absolute;
+ z-index: 20;
+ min-width: 160px;
+ top: 44px;
+ left: -16px;
+ list-style: none;
+ margin: 0;
+ padding: 8px;
border: 1px solid var(--dw-line);
- border-left: 0.25rem solid var(--dw-blue);
- border-radius: 0.4rem;
- margin-top: 1.25rem;
- padding: 0.9rem 1rem;
+ border-radius: 8px;
+ background: var(--dw-surface);
}
-.dw-checkpoint > p {
- color: var(--dw-muted);
- font-size: 0.74rem;
- line-height: 1.6;
- margin: 0 0 0.7rem;
+.dw-sdk-switcher a {
+ display: flex;
+ justify-content: space-between;
+ padding: 12px;
+ border-radius: 4px;
+ color: var(--dw-ink-2);
}
-.dw-checkpoint .highlight {
- margin: 0;
+.dw-sdk-switcher a:hover, .dw-sdk-switcher a[aria-current] {
+ background: var(--dw-tint);
+ color: var(--dw-action);
}
-.dw-cloud-path > p {
- color: var(--dw-muted);
- line-height: 1.7;
- max-width: 48rem;
+.dw-global-actions {
+ display: flex;
+ align-items: center;
+ gap: 20px;
+ font-size: 14px;
}
-.dw-cloud-path > h3 {
- color: var(--dw-ink);
- font-size: 1.05rem;
- margin: 1.8rem 0 0.8rem;
+.dw-github-link {
+ color: var(--dw-ink-2);
}
-.dw-cloud-path .highlight {
- border: 1px solid var(--dw-line);
- border-radius: 0.4rem;
- max-width: 100%;
- overflow: hidden;
+.dw-docs-toolbar {
+ padding: 0 20px;
+ min-height: 48px;
+ border-block: 1px solid var(--dw-line);
+ gap: 12px;
}
-.dw-cloud-path .highlight pre {
- max-width: 100%;
- overflow-x: auto;
+.dw-docs-toolbar__title {
+ font-size: 13px;
+ color: var(--dw-ink-2);
}
-.dw-credential-grid {
- display: grid;
- gap: 0.75rem;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- list-style: none;
- margin: 0 0 1.25rem;
- padding: 0;
+.dw-channel-label {
+ font: 400 11px var(--dw-font-mono);
+ color: var(--dw-muted);
}
-.dw-credential-grid li {
- background: var(--dw-panel-raised);
- border: 1px solid var(--dw-line);
- border-radius: 0.4rem;
+.dw-docs-toolbar__controls {
+ margin-left: auto;
display: flex;
- flex-direction: column;
- gap: 0.5rem;
- min-width: 0;
- padding: 1rem;
+ align-items: center;
}
-.dw-credential-grid strong {
+.dw-docs-toolbar .md-header__button {
+ width: 40px;
+ height: 44px;
+ padding: 10px;
+ margin: 0;
+ color: var(--dw-ink-2);
+}
+
+.dw-docs-toolbar .md-search__form {
+ background: var(--dw-paper);
+ border: 1px solid var(--dw-line);
color: var(--dw-ink);
- font-size: 0.82rem;
}
-.dw-credential-grid span {
+.dw-docs-toolbar .md-search__input {
+ color: var(--dw-ink);
+}
+
+.dw-docs-toolbar .md-search__input::placeholder {
color: var(--dw-muted);
- font-size: 0.7rem;
+}
+
+.md-sidebar {
+ color: var(--dw-ink-2);
+}
+
+.md-nav__link--active {
+ color: var(--dw-action);
+}
+
+@media (min-width: 1220px) {
+ body:has(.dw-landing) .md-sidebar--primary {
+ display: none;
+ }
+}
+
+body:has(.dw-landing) .md-main__inner {
+ margin-top: 0;
+}
+
+body:has(.dw-landing) .md-content__inner {
+ margin: 0 24px;
+ padding: 0;
+}
+
+.dw-landing {
+ color: var(--dw-ink);
+
+ display: flex;
+ flex-direction: column;
+ gap: 88px;
+ padding: 72px 0 96px;
+ margin: 0;
+}
+
+.dw-hero {
+ display: grid;
+
+ padding: 0;
+ gap: 48px;
+ align-items: center;
+ grid-template-columns: minmax(0, 1fr) 340px;
+}
+
+.dw-eyebrow, .dw-tag {
+ color: var(--dw-action);
+ font: 500 12px var(--dw-font-mono);
+ letter-spacing: .08em;
+ line-height: 1.5;
+ margin-bottom: 22px;
+}
+
+.dw-hero h1 {
+ margin: 0;
+ font-size: 64px;
+ line-height: 1;
+ letter-spacing: -.03em;
+ max-width: none;
+}
+
+.dw-hero__copy > p:not(.dw-eyebrow, .dw-hero__facts) {
+ color: var(--dw-ink-2);
+ font-size: 19px;
line-height: 1.55;
+ margin: 22px 0 0;
+ max-width: 580px;
}
-.dw-credential-grid code {
- overflow-wrap: anywhere;
+.dw-hero__actions {
+ gap: 12px;
+ margin-top: 22px;
}
-.dw-inline-actions {
+.dw-button, .dw-text-link {
+ min-height: 44px;
+ font-size: 15px;
+ font-weight: 600;
+}
+
+.dw-button {
+ display: inline-flex;
align-items: center;
+ justify-content: center;
+ border: 1px solid var(--dw-line);
+ text-decoration: none;
+
+ border-radius: 6px;
+ padding: 0 20px;
+ border-color: var(--dw-line);
+}
+
+.dw-button.dw-button--primary {
+ background: var(--dw-action);
+ border-color: var(--dw-action);
+ color: var(--dw-on-action);
+}
+
+.dw-button.dw-button--secondary {
+ color: var(--dw-ink);
+ background: var(--dw-surface);
+}
+
+.dw-button:hover, .dw-role-card:hover, .dw-link-card:hover {
+ box-shadow: none;
+ border-color: var(--dw-action);
+}
+
+.dw-text-link {
+ display: inline-flex;
+ align-items: center;
+ color: var(--dw-action);
+ text-decoration: none;
+
+ padding: 10px 8px;
+}
+
+.dw-hero__facts {
display: flex;
flex-wrap: wrap;
- gap: 0.65rem 1rem;
- margin-top: 1.2rem;
+
+ font: 400 13px var(--dw-font-mono);
+ color: var(--dw-muted);
+ margin-top: 22px;
+ gap: 8px 20px;
}
-.dw-inline-actions .dw-button,
-.dw-inline-actions .dw-text-link {
- width: auto;
+.dw-hero__facts span::before {
+ display: none;
}
-.dw-link-card {
- min-height: 9rem;
+.dw-install-card {
+ align-self: center;
+ min-width: 0;
+ background: var(--dw-code-background);
+ color: var(--dw-code-foreground);
+
+ border: none;
+ border-radius: 8px;
+ box-shadow: none;
+ padding: 0;
+ overflow: hidden;
}
-.dw-version {
- background: var(--dw-panel);
+.dw-install-card h2 {
+ display: none;
+}
+
+.dw-install-card > p:not(.dw-install-card__links) {
+ padding: 0 16px 18px;
+ font-size: 13px;
+ line-height: 1.55;
+ color: var(--dw-code-muted);
+}
+
+.dw-install-card__links {
+ display: flex;
+ justify-content: space-between;
+
+ padding: 14px 16px;
+ margin: 0;
+ border-top: 1px solid #26302c;
+ font-size: 13px;
+ gap: 20px;
+}
+
+.dw-install-card__links a {
+ color: #5fe0a6;
+}
+
+.dw-section {
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.dw-section > h2 {
+ margin: 0 0 8px;
+ font-size: 36px;
+}
+
+.dw-section > p {
+ color: var(--dw-ink-2);
+ margin: 0 0 24px;
+ line-height: 1.5;
+}
+
+.dw-role-grid {
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 16px;
+}
+
+.dw-role-card, .dw-link-card, .dw-runtime-card, .dw-credential-grid li {
+ background: var(--dw-surface);
border: 1px solid var(--dw-line);
- border-radius: 0.4rem;
- margin-bottom: 2rem;
- padding: 1.2rem;
+ border-radius: 8px;
+ box-shadow: none;
+ padding: 20px;
}
-.dw-version > h2 {
- font-size: 1rem;
+.dw-role-card__step {
+ color: var(--dw-action);
+ background: none;
+ padding: 0;
+ font: 400 12px var(--dw-font-mono);
}
-.dw-version > p {
- font-size: 0.74rem;
- margin-bottom: 0;
+.dw-role-card strong {
+ color: var(--dw-ink);
+ font: 600 20px var(--dw-font-display);
}
-@media screen and (max-width: 76rem) {
- .dw-role-grid,
- .dw-link-grid {
- grid-template-columns: repeat(2, minmax(0, 1fr));
- }
+.dw-role-card > span:not(.dw-role-card__step, .dw-role-card__link) {
+ color: var(--dw-ink-2);
+ font-size: 14px;
+ line-height: 1.5;
+}
- .dw-credential-grid {
- grid-template-columns: 1fr;
- }
+.dw-role-card__link {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--dw-action);
+}
+
+.dw-quickstart-grid {
+ display: grid;
+ grid-template-columns: minmax(0, .7fr) minmax(0, 1.3fr);
+ gap: 48px;
+ align-items: start;
+ margin-block: 64px;
+}
+
+.dw-quickstart-copy h3 {
+ font-size: 32px;
+ margin: 0 0 16px;
+}
+
+.dw-quickstart-copy p {
+ color: var(--dw-ink-2);
+ font-size: 16px;
+}
+
+.dw-quickstart-copy .dw-eyebrow {
+ color: var(--dw-action);
+ font-size: 12px;
+}
+
+.dw-quickstart-code {
+ min-width: 0;
+}
+
+.dw-runtime-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+
+ gap: 16px;
+}
+
+.dw-runtime-card {
+ gap: 12px;
+
+ border-top: 1px solid var(--dw-line);
+ min-height: 220px;
+}
+
+.dw-runtime-card h3 {
+ font: 600 20px var(--dw-font-display);
+ margin: 0;
+}
+
+.dw-runtime-card p {
+ color: var(--dw-ink-2);
+ font-size: 14px;
+ line-height: 1.5;
+}
+
+.dw-runtime-card .dw-tag {
+ font-size: 11px;
+ margin-bottom: 12px;
+}
+
+.dw-runtime-card--secondary .dw-tag {
+ color: var(--dw-muted);
+}
+
+.dw-checkpoint, .dw-version {
+ background: var(--dw-surface);
+ border: 1px solid var(--dw-line);
+ border-radius: 8px;
+}
+
+.dw-checkpoint {
+ padding: 16px;
+}
+
+.dw-version {
+ margin: 0;
+ padding: 20px;
+}
+
+.md-typeset pre, .md-typeset code {
+ font-family: var(--dw-font-mono);
+}
+
+.md-typeset pre {
+ background: var(--dw-code-background);
+ color: var(--dw-code-foreground);
+}
+
+.dw-landing .highlight {
+ position: relative;
+ background: var(--dw-code-background);
+ border: 1px solid #26302c;
+ border-radius: 8px;
+ overflow: hidden;
}
-@media screen and (max-width: 64rem) {
+.dw-landing .highlight::before {
+ content: "terminal";
+ display: block;
+ border-bottom: 1px solid #26302c;
+ padding: 12px 16px;
+ color: var(--dw-code-muted);
+ font: 400 12px var(--dw-font-mono);
+}
+
+.dw-install-card .highlight::before {
+ content: "INSTALL";
+ letter-spacing: .08em;
+}
+
+.dw-quickstart-code .highlight::before {
+ content: "greeter.py";
+}
+
+.dw-checkpoint .highlight::before {
+ content: "output";
+}
+
+.dw-landing .highlight pre code {
+ padding: 18px 16px;
+ background: var(--dw-code-background);
+ color: var(--dw-code-foreground);
+ font-size: 13px;
+ line-height: 1.75;
+ white-space: pre;
+ overflow-x: auto;
+}
+
+.dw-quickstart-code .highlight pre code {
+ max-height: 640px;
+ overflow: auto;
+}
+
+.dw-footer {
+ background: var(--dw-surface);
+ border-top: 1px solid var(--dw-line);
+}
+
+.dw-footer__inner {
+ padding: 48px 24px 28px;
+}
+
+.dw-footer__columns {
+ display: grid;
+ grid-template-columns: 1.5fr 1fr 1fr 1fr;
+ gap: 48px;
+}
+
+.dw-footer__brand p {
+ max-width: 270px;
+ color: var(--dw-muted);
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.dw-footer nav {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ font-size: 13px;
+}
+
+.dw-footer h2 {
+ margin: 0 0 4px;
+ font: 400 11px var(--dw-font-mono);
+ letter-spacing: .08em;
+ text-transform: uppercase;
+ color: var(--dw-muted);
+}
+
+.dw-footer nav a {
+ color: var(--dw-ink-2);
+}
+
+.dw-footer__bottom {
+ border-top: 1px solid var(--dw-line);
+ margin-top: 40px;
+ padding-top: 24px;
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ color: var(--dw-muted);
+ font-size: 12px;
+}
+
+.dw-status-pill {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: var(--dw-paper);
+ color: var(--dw-muted);
+ padding: 5px 9px;
+ border-radius: 20px;
+ font: 400 11px var(--dw-font-mono);
+}
+
+.dw-status-dot {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: currentColor;
+}
+
+@media (max-width: 1100px) {
+ .dw-role-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .dw-global-bar {
+ flex-wrap: wrap;
+ gap: 8px 24px;
+ }
+ .dw-global-nav {
+ order: 3;
+ flex-basis: 100%;
+ }
+ .dw-global-actions {
+ margin-left: auto;
+ }
.dw-hero {
- gap: 1.75rem;
- grid-template-columns: 1fr;
- padding-block: 1.75rem 2.25rem;
+ grid-template-columns: minmax(0, 1fr) minmax(300px, .8fr);
+ gap: 32px;
}
-
.dw-hero h1 {
- font-size: 2.75rem;
- }
-
- .dw-install-card {
- width: 100%;
+ font-size: 56px;
}
}
-@media screen and (max-width: 44.984375em) {
- body:has(.dw-landing) .md-main__inner,
+@media (max-width: 760px) {
+ .dw-global-bar {
+ padding: 12px 20px 0;
+ gap: 4px 12px;
+ }
+ .dw-brand {
+ gap: 7px;
+ }
+ .dw-brand__name {
+ font-size: 14px;
+ }
+ .dw-brand__surface {
+ font-size: 11px;
+ }
+ .dw-brand__mark {
+ width: 26px;
+ height: 26px;
+ }
+ .dw-global-actions {
+ gap: 0;
+ }
+ .dw-global-actions .dw-button {
+ display: inline-flex;
+ font-size: 13px;
+ padding: 0 12px;
+ }
+ .dw-github-link {
+ display: none;
+ }
+ .dw-global-nav {
+ gap: 22px;
+ font-size: 13px;
+ justify-content: space-between;
+ }
+ .dw-docs-toolbar {
+ padding: 0 12px;
+ gap: 8px;
+ }
+ .dw-docs-toolbar__title {
+ font-size: 12px;
+ }
+ .dw-channel-label {
+ font-size: 10px;
+ }
body:has(.dw-landing) .md-content__inner {
- margin-top: 0;
+ margin: 0 20px;
}
-
.dw-landing {
- margin-top: 0;
+ padding: 48px 0 64px;
+ gap: 56px;
}
-
.dw-hero {
- gap: 0.75rem;
- padding-block: 0.75rem 0.5rem;
+ grid-template-columns: 1fr;
+ gap: 32px;
}
-
.dw-hero h1 {
- font-size: 1.65rem;
- line-height: 1.05;
- max-width: none;
- }
-
- .dw-eyebrow {
- margin-bottom: 0.45rem;
+ font-size: clamp(40px, 10.7vw, 56px);
+ max-width: 12ch;
}
-
.dw-hero__copy > p:not(.dw-eyebrow, .dw-hero__facts) {
- font-size: 0.82rem;
- line-height: 1.45;
- margin-top: 0.65rem;
+ font-size: 18px;
}
-
.dw-hero__actions {
- display: grid;
- gap: 0.4rem;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- margin-top: 0.8rem;
+ align-items: stretch;
}
-
- .dw-hero__actions .dw-button--primary {
- grid-column: 1 / -1;
+ .dw-hero__actions .dw-button, .dw-hero__actions .dw-text-link {
+ width: auto;
}
-
- .dw-hero__actions .dw-button,
- .dw-hero__actions .dw-text-link {
- font-size: 0.66rem;
- min-height: 2.15rem;
- padding: 0.4rem;
+ .dw-install-card {
+ width: 100%;
}
-
- .dw-hero__facts {
- gap: 0.35rem 0.7rem;
- margin-top: 0.65rem;
+ .dw-role-grid {
+ grid-template-columns: 1fr;
}
-
- .dw-install-card {
- padding: 0.75rem;
+ .dw-section > h2 {
+ font-size: 32px;
}
-
- .dw-install-card h2 {
- margin-bottom: 0.4rem;
+ .dw-role-card {
+ padding: 16px;
+ min-height: 205px;
}
-
- .dw-install-card > p:not(.dw-install-card__links) {
- font-size: 0.65rem;
- line-height: 1.4;
- margin-top: 0.55rem;
+ .dw-quickstart-grid {
+ grid-template-columns: 1fr;
+ gap: 24px;
+ margin-block: 48px;
}
-
- .dw-install-card__links {
- flex-direction: row;
- justify-content: space-between;
- margin-top: 0.55rem;
+ .dw-runtime-grid, .dw-link-grid, .dw-credential-grid {
+ grid-template-columns: 1fr;
}
-
- .dw-section {
- margin-top: 2.75rem;
- padding-top: 2rem;
+ .dw-runtime-card, .dw-role-card, .dw-link-card {
+ box-shadow: none;
}
-
- .dw-model {
- margin-top: 0.75rem;
- padding-top: 1rem;
+ .dw-footer__columns {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 32px;
}
-
- .dw-section > h2 {
- font-size: 1.65rem;
+ .dw-footer__brand {
+ grid-column: 1 / -1;
}
-
- .dw-install-card__links {
- align-items: stretch;
+ .dw-footer__bottom {
+ flex-wrap: wrap;
}
+}
- .dw-button,
- .dw-text-link {
- text-align: center;
- width: 100%;
+@media (max-width: 370px) {
+ .dw-global-bar {
+ padding-inline: 16px;
}
-
- .dw-role-grid,
- .dw-link-grid,
- .dw-runtime-grid {
+ .dw-brand__name {
+ font-size: 13px;
+ }
+ .dw-brand {
+ gap: 5px;
+ }
+ .dw-github-link {
+ font-size: 11px;
+ }
+ .dw-docs-toolbar__title {
+ font-size: 11px;
+ }
+ .dw-channel-label {
+ display: none;
+ }
+ .dw-role-grid {
grid-template-columns: 1fr;
}
+}
- .dw-runtime-card__actions,
- .dw-inline-actions {
- align-items: stretch;
- flex-direction: column;
- }
+.dw-status-pill[data-public-status="operational"] {
+ color: var(--dw-action);
+ background: var(--dw-tint);
+}
- .dw-runtime-card__actions .dw-button,
- .dw-runtime-card__actions .dw-cloud-promotion__action,
- .dw-inline-actions .dw-button,
- .dw-inline-actions .dw-text-link {
- text-align: center;
- width: 100%;
- }
+.dw-status-pill[data-public-status="degraded"] {
+ color: #8c5600;
+ background: #fff0d6;
+}
- .dw-role-card,
- .dw-link-card {
- min-height: 0;
- }
+.dw-status-pill[data-public-status="incident"] {
+ color: #b3261e;
+ background: #fce8e8;
+}
- .dw-first-run .highlight pre,
- .dw-install-card .highlight pre {
- font-size: 0.66rem;
- }
+.dw-status-pill[data-public-status="maintenance"] {
+ color: #3f51b5;
+ background: #e7edff;
}
-@media screen and (min-width: 32rem) and (max-width: 44.984375em) and (max-height: 24rem) {
- .dw-hero__actions {
- grid-template-columns: repeat(3, minmax(0, 1fr));
- }
+[data-md-color-scheme="slate"] .dw-status-pill[data-public-status="degraded"] {
+ color: #f2c46d;
+ background: #3b2d17;
+}
- .dw-hero__actions .dw-button--primary {
- grid-column: auto;
- }
+[data-md-color-scheme="slate"] .dw-status-pill[data-public-status="incident"] {
+ color: #ffaaa5;
+ background: #3c211f;
}
-@media (prefers-reduced-motion: reduce) {
- .dw-landing *,
- .dw-landing *::before,
- .dw-landing *::after {
- scroll-behavior: auto !important;
- transition: none !important;
- }
+[data-md-color-scheme="slate"] .dw-status-pill[data-public-status="maintenance"] {
+ color: #b6c4ff;
+ background: #242a43;
}
-@media screen and (max-width: 59.984375em) {
- .md-sidebar--primary .md-nav__list.dw-navigation__ancestor-list {
- overflow: visible;
- }
+.dw-install-card .highlight {
+ border: 0;
+ border-radius: 0;
+ margin: 0;
+}
- .md-nav--primary > .dw-navigation__close {
- display: flex;
- }
+.dw-install-card .highlight pre code {
+ font-size: 15px;
+}
- .md-sidebar--primary .md-source__repository {
- font-size: 0.6rem;
- }
+.dw-landing button[data-clipboard-target]::after {
+ content: "Copy";
+ display: block;
+ width: auto;
+ height: auto;
+ margin: 0;
+ font: 400 12px var(--dw-font-mono);
+ background: none;
+ mask: none;
+ -webkit-mask: none;
+}
- #__search:checked ~ header .md-search__overlay {
- border-radius: 0;
- height: 100dvh;
- inset: 0;
- position: fixed;
- transform: none;
- width: 100vw;
- }
+.dw-global-nav a:hover, .dw-github-link:hover, .dw-footer nav a:hover {
+ color: var(--dw-action);
+ text-decoration: underline;
+ text-underline-offset: 3px;
+}
- #__search:checked ~ .md-container {
- visibility: hidden;
- }
+.md-nav--primary .md-nav__title {
+ background: var(--dw-surface);
+ color: var(--dw-ink);
+ box-shadow: none;
+}
+[data-md-color-scheme="slate"] .md-logo img {
+ background: var(--dw-logo-paper);
+ padding: 4px;
+ border-radius: 8px;
}
-.md-header__button[role="button"]:focus-visible,
-.dw-navigation__close:focus-visible,
-.md-search__icon[role="button"]:focus-visible {
- border-radius: 0.1rem;
- outline: 0.15rem solid currentColor;
- outline-offset: 0.1rem;
+/* Keep the maintained theme's copy action in the code header in both theme versions. */
+.dw-landing .highlight pre {
+ margin: 0;
+ max-width: 100%;
+
+ position: static;
+}
+.dw-landing .md-code__nav {
+ position: absolute;
+ top: 8px;
+ right: 8px;
}
+.dw-landing button[data-clipboard-target] {
+ top: 8px;
+ right: 8px;
+ color: var(--dw-code-muted);
+ border: 1px solid #2e3934;
+ border-radius: 4px;
+ opacity: 1;
-.dw-navigation__close {
+ width: auto;
+ height: 28px;
+ padding: 4px 8px;
+ background: transparent;
+
+ display: inline-flex;
align-items: center;
- background-color: var(--md-primary-bg-color);
- border: 0;
- border-radius: 50%;
- color: var(--md-primary-fg-color);
- cursor: pointer;
- display: none;
- height: 2rem;
justify-content: center;
- position: absolute;
- right: 0.4rem;
- top: 0.4rem;
- width: 2rem;
- z-index: 2;
+ min-width: 44px;
}
-
-.dw-navigation__close svg {
- height: 1.2rem;
- width: 1.2rem;
+@media print {
+ button[data-clipboard-target] {
+ display: none;
+ }
}
-.dw-navigation__close:focus-visible {
- border-radius: 50%;
+.md-typeset ul.dw-role-grid {
+ display: grid;
+ margin: 0;
+ padding: 0;
+ list-style: none;
}
-
-#__search:checked ~ header .md-search__icon[for="__search"][role="button"] {
- pointer-events: auto;
+.md-typeset ul.dw-role-grid > li {
+ display: flex;
+ min-width: 0;
+ margin: 0;
+ padding: 0;
}
+.dw-role-card {
+ min-height: 190px;
+ gap: 8px;
-#__search:checked ~ header .md-search__icon[for="__search"][role="button"] svg:first-child {
- display: none;
+ width: 100%;
}
-#__search:checked ~ header .md-search__icon[for="__search"][role="button"] svg:last-child {
- display: block;
+button[data-clipboard-target] {
+ color: var(--dw-code-muted);
}
-/* Keep result-group expanders in flow inside the modal instead of floating
- above unrelated inert document content at the overlay boundary. */
-.md-search-result__more > summary {
- position: static;
+.md-code__nav {
+ background: var(--dw-code-background);
}
-#__search:checked ~ header [inert] {
- visibility: hidden;
+button[data-clipboard-target]:is(:hover, :focus-visible) {
+ background: var(--dw-code-background);
+ color: var(--dw-code-foreground);
}
diff --git a/mkdocs.yml b/mkdocs.yml
index e4c03548..c25709b5 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -9,7 +9,7 @@ theme:
name: material
custom_dir: overrides
font: false
- logo: assets/favicon.svg
+ logo: assets/logo.svg
favicon: assets/favicon.svg
features:
- navigation.sections
@@ -18,23 +18,27 @@ theme:
- content.code.annotate
- toc.follow
palette:
- - media: "(prefers-color-scheme: light)"
- scheme: default
- primary: indigo
+ - scheme: default
+ primary: custom
+ accent: custom
toggle:
icon: material/weather-night
name: Switch to dark mode
- - media: "(prefers-color-scheme: dark)"
- scheme: slate
- primary: indigo
+ - scheme: slate
+ primary: custom
+ accent: custom
toggle:
icon: material/weather-sunny
name: Switch to light mode
extra_javascript:
+ - path: javascripts/identity.js
+ type: module
- path: javascripts/analytics.js
type: module
extra_css:
+ - stylesheets/identity-tokens.css
+ - stylesheets/identity-fonts.css
- stylesheets/layout.css
plugins:
@@ -63,6 +67,7 @@ plugins:
- "!^to_dict$"
markdown_extensions:
+ - attr_list
- admonition
- md_in_html
- pymdownx.details
@@ -101,6 +106,8 @@ nav:
- Testing: reference/testing.md
extra:
+ published_sdk_version: "2.14.1"
+ docs_channel: source-preview
social:
- icon: fontawesome/brands/github
link: https://github.com/durable-workflow/sdk-python
diff --git a/overrides/partials/footer.html b/overrides/partials/footer.html
new file mode 100644
index 00000000..0c148a94
--- /dev/null
+++ b/overrides/partials/footer.html
@@ -0,0 +1,17 @@
+
diff --git a/overrides/partials/header.html b/overrides/partials/header.html
new file mode 100644
index 00000000..31b66e16
--- /dev/null
+++ b/overrides/partials/header.html
@@ -0,0 +1,49 @@
+
diff --git a/requirements-docs.txt b/requirements-docs.txt
new file mode 100644
index 00000000..3f4d7947
--- /dev/null
+++ b/requirements-docs.txt
@@ -0,0 +1,85 @@
+# Resolved wheel artifacts for the Linux amd64 / Python 3.12 portal builder.
+# anyio-4.16.0-py3-none-any.whl
+anyio==4.16.0 --hash=sha256:c66ae634e0b8228396b801741beb3294326d4b6102f7bba4041c0dfb58c29026
+# babel-2.18.0-py3-none-any.whl
+babel==2.18.0 --hash=sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35
+# backrefs-8.2-py312-none-any.whl
+backrefs==8.2 --hash=sha256:e64a50f31e8eee2837009ca7744999ba59125acfd50996ba2349d8f18061e608
+# certifi-2026.10.23-py3-none-any.whl
+certifi==2026.10.23 --hash=sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1
+# charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+charset-normalizer==3.5.2 --hash=sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649
+# click-8.5.0-py3-none-any.whl
+click==8.5.0 --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360
+# colorama-0.4.6-py2.py3-none-any.whl
+colorama==0.4.6 --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
+# fastavro-1.13.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+fastavro==1.13.1 --hash=sha256:2f56a127d71e45083306d2650efff827cad0f4b0744dd42cb69c631d77943b1d
+# ghp_import-2.1.0-py3-none-any.whl
+ghp-import==2.1.0 --hash=sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619
+# greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+greenlet==3.5.6 --hash=sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1
+# griffelib-2.4.1-py3-none-any.whl
+griffelib==2.4.1 --hash=sha256:76bc562f936ad1ce198d2cf6d0731bd5c92a7d934b69ef828ef22ee0ffdb62ce
+# h11-0.16.0-py3-none-any.whl
+h11==0.16.0 --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
+# httpcore-1.0.9-py3-none-any.whl
+httpcore==1.0.9 --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55
+# httpx-0.28.1-py3-none-any.whl
+httpx==0.28.1 --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad
+# idna-3.20-py3-none-any.whl
+idna==3.20 --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c
+# jinja2-3.1.6-py3-none-any.whl
+Jinja2==3.1.6 --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
+# markdown-3.11.3-py3-none-any.whl
+Markdown==3.11.3 --hash=sha256:e1a31a112232504d753e5c01bb8c2de90d3f0ba986b063e10770cf7695b09e73
+# markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+MarkupSafe==3.0.4 --hash=sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581
+# mergedeep-1.3.4-py3-none-any.whl
+mergedeep==1.3.4 --hash=sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307
+# mkdocs-1.6.1-py3-none-any.whl
+mkdocs==1.6.1 --hash=sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e
+# mkdocs_autorefs-1.4.5-py3-none-any.whl
+mkdocs-autorefs==1.4.5 --hash=sha256:68ee484e0800f3ba50cda1bc4a94293adbdfcc010243c322b3f5d66e15d06770
+# mkdocs_get_deps-0.2.2-py3-none-any.whl
+mkdocs-get-deps==0.2.2 --hash=sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650
+# mkdocs_material-9.8.0-py3-none-any.whl
+mkdocs-material==9.8.0 --hash=sha256:19304a7263c5700428227122b3b2699aa73f432c9de8816876adaec7d7293ce3
+# mkdocs_material_extensions-1.3.1-py3-none-any.whl
+mkdocs-material-extensions==1.3.1 --hash=sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31
+# mkdocstrings-1.0.8-py3-none-any.whl
+mkdocstrings==1.0.8 --hash=sha256:4b03ed2b863009f1153f0d04aacb22a85b92afc28a0636d85717c92eecca638c
+# mkdocstrings_python-2.0.10-py3-none-any.whl
+mkdocstrings-python==2.0.10 --hash=sha256:4a3aaeebb25ba5ae57a8f9e75432fe4571984889602f2b2f7d672faa760e9062
+# packaging-26.3-py3-none-any.whl
+packaging==26.3 --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c
+# paginate-0.5.7-py2.py3-none-any.whl
+paginate==0.5.7 --hash=sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591
+# pathspec-1.1.1-py3-none-any.whl
+pathspec==1.1.1 --hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189
+# platformdirs-4.14.5-py3-none-any.whl
+platformdirs==4.14.5 --hash=sha256:8e778c6fa421516f4d3369449f8df3b0e1b72c0e0005fe88e3ec772d0a14e750
+# playwright-1.64.1-py3-none-manylinux1_x86_64.whl
+playwright==1.64.1 --hash=sha256:577b8d88778c245cfbd145ae17cf53044f084b1b46c55c69081d4069d56057e2
+# pyee-14.0.0-py3-none-any.whl
+pyee==14.0.0 --hash=sha256:3ac2d3229a9677f7de2c33d7f52fe25b638a46b19c413fea2edc8c6d0a644e4d
+# pygments-2.21.0-py3-none-any.whl
+Pygments==2.21.0 --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9
+# pymdown_extensions-12.3-py3-none-any.whl
+pymdown-extensions==12.3 --hash=sha256:345493a8e3497e24382902903a1c43a28e34a52f2b7571a1c9641429b820ca74
+# python_dateutil-4.0.0-py3-none-any.whl
+python-dateutil==4.0.0 --hash=sha256:a5e246f79eb80475e691bf93862cead98decd8a498cf53ba0516fcdc0f50dc1c
+# pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+PyYAML==6.0.3 --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc
+# pyyaml_env_tag-1.1-py3-none-any.whl
+pyyaml_env_tag==1.1 --hash=sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04
+# requests-2.34.2-py3-none-any.whl
+requests==2.34.2 --hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0
+# typing_extensions-4.16.0-py3-none-any.whl
+typing_extensions==4.16.0 --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8
+# tzdata-2026.6-py2.py3-none-any.whl
+tzdata==2026.6 --hash=sha256:0a10eec6e13ef16b627fabed9a5f2ba61a62b1c67f327305f4e7e412a70b4aec
+# urllib3-2.8.0-py3-none-any.whl
+urllib3==2.8.0 --hash=sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3
+# watchdog-7.1.0-py3-none-manylinux_2_17_x86_64.whl
+watchdog==7.1.0 --hash=sha256:ad6d0b473cb8cde0721692b22b30fcbea8b52102430528874b3593549888bd81
diff --git a/scripts/check-docs-analytics.py b/scripts/check-docs-analytics.py
index c4b2e634..fa953c41 100644
--- a/scripts/check-docs-analytics.py
+++ b/scripts/check-docs-analytics.py
@@ -13,12 +13,9 @@
build_directory = args.build_directory
runtime = Path("docs/javascripts/analytics.js").read_text(encoding="utf-8")
-styles = Path("docs/stylesheets/layout.css").read_text(encoding="utf-8")
if (build_directory / "javascripts/analytics.js").read_text(encoding="utf-8") != runtime:
raise SystemExit("Rendered MkDocs analytics runtime is stale")
-if not re.search(r"\.dw-cloud-promotion__eyebrow\s*\{[^}]*letter-spacing:\s*0;", styles, re.DOTALL):
- raise SystemExit("Promotion eyebrow letter spacing must remain zero")
for required in (
"https://static.cloudflareinsights.com/beacon.min.js",
diff --git a/scripts/check-docs-layout.py b/scripts/check-docs-layout.py
index 60a44e17..92427f47 100644
--- a/scripts/check-docs-layout.py
+++ b/scripts/check-docs-layout.py
@@ -7,12 +7,18 @@
import contextlib
import functools
import http.server
+import importlib.metadata
+import json
+import platform
import threading
from pathlib import Path
from typing import Any
+import playwright as playwright_package
from playwright.sync_api import Browser, Page, sync_playwright
+FOCUSED = "element => element === document.activeElement"
+
class SiteHandler(http.server.SimpleHTTPRequestHandler):
def log_message(self, _format: str, *_args: object) -> None:
@@ -74,7 +80,8 @@ def assert_rendered_health(page: Page, label: str, errors: list[str]) -> None:
const style = getComputedStyle(element);
const scrollable = ['auto', 'scroll'].includes(style.overflowX);
return {
- label: (element.getAttribute('aria-label') || element.textContent || element.tagName).trim().slice(0, 80),
+ label: (element.getAttribute('aria-label') || element.textContent || element.tagName)
+ .trim().slice(0, 80),
left: box.left,
right: box.right,
clippedText: ['A', 'BUTTON', 'SUMMARY'].includes(element.tagName)
@@ -136,7 +143,13 @@ def assert_landing_contract(page: Page) -> None:
def open_page(browser: Browser, base_url: str, path: str, viewport: dict[str, int]) -> tuple[Page, list[str]]:
- page = browser.new_page(viewport=viewport)
+ page = browser.new_page(
+ viewport=viewport, color_scheme="dark", permissions=["clipboard-read", "clipboard-write"],
+ )
+ page.route(
+ "https://status.durable-workflow.com/api/current.json",
+ lambda route: route.fulfill(status=200, content_type="application/json", body="{}"),
+ )
errors = runtime_errors(page)
response = page.goto(f"{base_url}{path}", wait_until="networkidle")
assert response is not None and response.ok, f"{path} returned a non-success response"
@@ -151,11 +164,12 @@ def exercise_mobile_controls(page: Page) -> None:
drawer = page.locator(".md-sidebar--primary")
assert drawer.is_visible(), "mobile navigation did not open"
drawer_box = drawer.bounding_box()
- assert drawer_box is not None and drawer_box["x"] >= -1 and drawer_box["width"] <= 390
+ width = page.viewport_size["width"] if page.viewport_size else 390
+ assert drawer_box is not None and drawer_box["x"] >= -1 and drawer_box["width"] <= width
close_navigation = drawer.locator(".dw-navigation__close")
close_navigation.click()
page.wait_for_function("!document.querySelector('#__drawer').checked")
- assert navigation.evaluate("element => element === document.activeElement"), "navigation focus was not restored"
+ assert navigation.evaluate(FOCUSED), "navigation focus was not restored"
search = page.locator(".md-header__button[for='__search']")
search.click()
@@ -166,15 +180,128 @@ def exercise_mobile_controls(page: Page) -> None:
page.wait_for_timeout(250)
assert search_input.is_visible(), "mobile search did not open"
search_box = page.locator(".md-search").bounding_box()
- assert search_box is not None and search_box["x"] >= -1 and search_box["width"] <= 391
+ assert search_box is not None and search_box["x"] >= -1 and search_box["width"] <= width + 1
page.locator(".md-search__icon[for='__search']").click()
page.wait_for_function("!document.querySelector('#__search').checked")
- assert search.evaluate("element => element === document.activeElement"), "search focus was not restored"
+ assert search.evaluate(FOCUSED), "search focus was not restored"
+
+
+def exercise_identity(page: Page, theme: str) -> None:
+ page.wait_for_function("document.body.dataset.mdColorScheme === 'default'")
+ if theme == "dark":
+ toggle = page.get_by_role("button", name="Switch to dark mode", exact=True)
+ toggle.focus()
+ toggle.press("Enter")
+ page.wait_for_function("document.body.dataset.mdColorScheme === 'slate'")
+ page.wait_for_function(
+ "document.querySelector('.md-header__option input:checked')?.nextElementSibling === document.activeElement",
+ )
+ page.reload(wait_until="networkidle")
+ page.wait_for_function("document.body.dataset.mdColorScheme === 'slate'")
+ page.evaluate("document.fonts.ready")
+ fonts = page.evaluate("[...document.fonts].filter(font => font.status === 'loaded').map(font => font.family)")
+ assert {"Inter", "Space Grotesk", "JetBrains Mono"} <= set(fonts), fonts
+ assert page.locator(".highlight pre").first.evaluate(
+ "element => getComputedStyle(element).backgroundColor",
+ ) == "rgb(15, 21, 19)"
+ switcher = page.locator("[data-sdk-switcher]")
+ trigger = switcher.locator("summary")
+ trigger.focus()
+ trigger.press("Enter")
+ page.wait_for_function("document.querySelector('[data-sdk-switcher]').open")
+ assert switcher.get_by_role("link", name="Rust", exact=True).get_attribute("href") == "https://rust.durable-workflow.com/"
+ trigger.press("Escape")
+ assert not switcher.evaluate("element => element.open")
+ assert trigger.evaluate(FOCUSED)
+ assert page.locator("[data-status-label]").text_content() == "Status unknown"
+ assert page.locator(".dw-global-actions .dw-button").is_visible()
+
+
+def exercise_copy_and_steps(page: Page) -> None:
+ spacing = page.locator(".dw-cloud-promotion__eyebrow").evaluate(
+ """element => {
+ const style = getComputedStyle(element);
+ return parseFloat(style.letterSpacing) / parseFloat(style.fontSize);
+ }""",
+ )
+ assert abs(spacing - 0.08) < 0.001, "Eyebrow tracking differs from the canonical design"
+ targets = page.locator("button[data-clipboard-target]").evaluate_all(
+ "buttons => buttons.map(button => button.dataset.clipboardTarget)",
+ )
+ assert len(set(targets)) == len(targets), "Code blocks share a copy target"
+ for selector in (".dw-install-card", ".dw-quickstart-code"):
+ block = page.locator(selector)
+ expected = block.locator("pre code").text_content()
+ # Never inspect a shared host's previous clipboard contents.
+ page.evaluate("navigator.clipboard.writeText('sdk-python-public-copy-control')")
+ block.locator("button[data-clipboard-target]").click()
+ page.wait_for_function(
+ "async () => (await navigator.clipboard.readText()) !== 'sdk-python-public-copy-control'",
+ )
+ copied = page.evaluate("navigator.clipboard.readText()")
+ assert copied.rstrip("\n") == expected.rstrip("\n"), "Copy changed or truncated the code"
+ assert page.locator("#start-server").count() == 1
+ assert page.locator("#save-greeter").count() == 1
+ assert page.locator("#run-greeter").count() == 1
+ assert page.locator(".dw-quickstart-copy a[href='#start-server']").count() == 1
+ assert page.locator(".dw-quickstart-copy a[href='#run-greeter']").count() == 1
+ code = page.locator(".dw-quickstart-code pre code").text_content() or ""
+ assert "asyncio.run(main())" in code
+ assert "workflows=[GreeterWorkflow]" in code
+ assert "activities=[greet]" in code
+ assert "task_queue=TASK_QUEUE" in code
+
+
+def check_landing_views(browser: Browser, base_url: str, screenshots: Path | None) -> None:
+ for width in (1440, 1024, 390, 320):
+ for theme in ("light", "dark"):
+ page, errors = open_page(browser, base_url, "/", {"width": width, "height": 920})
+ exercise_identity(page, theme)
+ assert_landing_contract(page)
+ assert_rendered_health(page, f"{width} {theme} landing", errors)
+ exercise_copy_and_steps(page)
+ if width < 960:
+ exercise_mobile_controls(page)
+ assert_rendered_health(page, f"{width} {theme} controls", errors)
+ if screenshots:
+ page.screenshot(path=str(screenshots / f"python-{width}-{theme}.png"), full_page=True)
+ page.close()
+
+
+def check_api_views(browser: Browser, base_url: str) -> None:
+ for path in ("/sdk-reference/", "/reference/client/"):
+ for width in (1440, 390):
+ for theme in ("light", "dark"):
+ page, errors = open_page(browser, base_url, path, {"width": width, "height": 920})
+ exercise_identity(page, theme)
+ assert_rendered_health(page, f"{width} {theme} {path}", errors)
+ if width < 960:
+ exercise_mobile_controls(page)
+ page.close()
+
+
+def browser_evidence(browser: Browser) -> dict[str, str]:
+ manifest_path = Path(playwright_package.__file__).parent / "driver" / "package" / "browsers.json"
+ manifest = json.loads(manifest_path.read_text())
+ selected = next(item for item in manifest["browsers"] if item["name"] == "chromium-headless-shell")
+ proof = {
+ "playwright_version": importlib.metadata.version("playwright"),
+ "selection": "Playwright default bundled headless Chromium",
+ "bundled_revision": selected["revision"],
+ "bundled_version": selected["browserVersion"],
+ "launched_version": browser.version,
+ "os": platform.system(),
+ "architecture": platform.machine(),
+ }
+ print(json.dumps(proof, sort_keys=True))
+ assert proof["launched_version"] == proof["bundled_version"], "Launched browser differs from the locked bundle"
+ return proof
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("site", nargs="?", type=Path, default=Path("site"))
+ parser.add_argument("--screenshots", type=Path)
args = parser.parse_args()
site = args.site.resolve()
if not (site / "index.html").is_file():
@@ -183,22 +310,13 @@ def main() -> int:
with serve(site) as base_url, sync_playwright() as playwright:
browser = playwright.chromium.launch()
try:
- desktop, desktop_errors = open_page(browser, base_url, "/", {"width": 1440, "height": 920})
- assert_landing_contract(desktop)
- assert_rendered_health(desktop, "desktop landing", desktop_errors)
- desktop.close()
-
- for path in ("/sdk-reference/", "/reference/client/"):
- page, errors = open_page(browser, base_url, path, {"width": 1440, "height": 920})
- assert_rendered_health(page, f"desktop {path}", errors)
- page.close()
+ proof = browser_evidence(browser)
+ if args.screenshots:
+ args.screenshots.mkdir(parents=True, exist_ok=True)
+ (args.screenshots / "browser-evidence.json").write_text(json.dumps(proof, indent=2) + "\n")
+ check_landing_views(browser, base_url, args.screenshots)
+ check_api_views(browser, base_url)
- mobile, mobile_errors = open_page(browser, base_url, "/", {"width": 390, "height": 844})
- assert_landing_contract(mobile)
- assert_rendered_health(mobile, "mobile landing", mobile_errors)
- exercise_mobile_controls(mobile)
- assert_rendered_health(mobile, "mobile controls", mobile_errors)
- mobile.close()
finally:
browser.close()
diff --git a/scripts/prepare_portal.py b/scripts/prepare_portal.py
new file mode 100644
index 00000000..608fc083
--- /dev/null
+++ b/scripts/prepare_portal.py
@@ -0,0 +1,146 @@
+#!/usr/bin/env python3
+"""Bind current presentation to an explicitly selected published SDK source."""
+
+from __future__ import annotations
+
+import hashlib
+import importlib.metadata
+import json
+import os
+import re
+import shutil
+import subprocess
+import tempfile
+from pathlib import Path
+from typing import Any
+
+import tomllib
+import yaml
+
+PRESENTATION_DIRECTORIES = ("assets", "stylesheets", "javascripts")
+CODE_BLOCKS = re.compile(r"```([^\n]*)\n(.*?)```", re.DOTALL)
+
+
+def git(root: Path, *arguments: str) -> str:
+ return subprocess.check_output(["git", "-C", str(root), *arguments], text=True).strip()
+
+
+def source_files(root: Path, directory: str) -> list[Path]:
+ names = subprocess.check_output(["git", "-C", str(root), "ls-files", "-z", directory]).decode().split("\0")
+ files = []
+ for name in filter(None, names):
+ source = root / name
+ if source.is_symlink() or not source.resolve().is_relative_to(root.resolve()) or not source.is_file():
+ raise ValueError("Selected SDK source contains an unsupported file")
+ files.append(source)
+ return sorted(files)
+
+
+def sdk_inventory(sdk: Path, *, verify_installed: bool) -> list[dict[str, Any]]:
+ distribution = importlib.metadata.distribution("durable-workflow") if verify_installed else None
+ rows = []
+ for source in source_files(sdk, "src/durable_workflow"):
+ data = source.read_bytes()
+ relative = source.relative_to(sdk / "src").as_posix()
+ if distribution is not None and distribution.locate_file(relative).read_bytes() != data:
+ raise ValueError("Installed published SDK differs from selected API source")
+ rows.append({"path": relative, "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()})
+ if not rows:
+ raise ValueError("Selected SDK has no API source")
+ return rows
+
+
+def published_identity(root: Path, sdk: Path, tag: str, environment: dict[str, str]) -> tuple[str, dict[str, Any]]:
+ if not re.fullmatch(r"\d+\.\d+\.\d+", tag):
+ raise ValueError("Stable SDK requires an exact stable tag")
+ revision = git(sdk, "rev-parse", "HEAD")
+ if revision != git(root, "rev-parse", f"refs/tags/{tag}^{{commit}}"):
+ raise ValueError("API checkout differs from the published tag")
+ if git(sdk, "status", "--porcelain", "--untracked-files=all"):
+ raise ValueError("Selected SDK source is modified")
+ project = tomllib.loads((sdk / "pyproject.toml").read_text())["project"]
+ if project["version"] != tag or importlib.metadata.version("durable-workflow") != tag:
+ raise ValueError("Published SDK source/package version differs from the selected tag")
+ if not re.fullmatch(r"[0-9a-f]{64}", environment.get("DOCS_WHEEL_SHA256", "")):
+ raise ValueError("Published wheel digest is required")
+ return revision, project
+
+
+def published_landing(root: Path, sdk: Path, project: dict[str, Any], tag: str) -> str:
+ landing = (root / "docs/index.md").read_text()
+ if CODE_BLOCKS.findall(landing) != CODE_BLOCKS.findall((sdk / "docs/index.md").read_text()):
+ raise ValueError("Landing examples differ from the selected published SDK documentation")
+ floors = re.findall(r"
Python (\d+\.\d+)\+ ", landing)
+ if len(floors) != 1 or project.get("requires-python") != f">={floors[0]}":
+ raise ValueError("Landing Python support floor differs from the published SDK")
+ installed_python = importlib.metadata.distribution("durable-workflow").metadata.get("Requires-Python")
+ if installed_python != project["requires-python"]:
+ raise ValueError("Installed Python support metadata differs from the selected SDK source")
+ landing, matches = re.subn(
+ r"Python SDK · \d+\.\d+\.\d+ published", f"Python SDK · {tag} published", landing,
+ )
+ if matches != 1:
+ raise ValueError("Published package label is missing or ambiguous")
+ return landing
+
+
+def stage_published_docs(root: Path, sdk: Path, config: dict[str, Any], project: dict[str, Any], tag: str) -> None:
+ stage = Path(tempfile.mkdtemp(prefix=".docs-stage-", dir=root))
+ # The complete SDK documentation content comes from tracked tag files.
+ # Only this closed set of presentation assets and the reviewed landing
+ # page come from the current documentation revision.
+ for source in source_files(sdk, "docs"):
+ target = stage / source.relative_to(sdk / "docs")
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(source, target)
+ for directory in PRESENTATION_DIRECTORIES:
+ shutil.copytree(root / "docs" / directory, stage / directory, dirs_exist_ok=True)
+ landing = published_landing(root, sdk, project, tag)
+ (stage / "index.md").write_text(landing)
+ config["docs_dir"] = str(stage)
+ config["nav"] = yaml.safe_load((sdk / "mkdocs.yml").read_text())["nav"]
+ documentation = next(plugin["mkdocstrings"] for plugin in config["plugins"]
+ if isinstance(plugin, dict) and "mkdocstrings" in plugin)
+ documentation["handlers"]["python"]["paths"] = [str(sdk / "src")]
+ config["extra"]["published_sdk_version"] = tag
+
+
+def prepare(root: Path, environment: dict[str, str]) -> dict[str, Any]:
+ for name in (".docs-build.yml", ".docs-api-inventory.json", ".docs-provenance.json"):
+ (root / name).unlink(missing_ok=True)
+ config = yaml.safe_load((root / "mkdocs.yml").read_text())
+ docs_revision = git(root, "rev-parse", "HEAD")
+ if environment.get("GITHUB_SHA", docs_revision) != docs_revision:
+ raise ValueError("Documentation checkout differs from the selected workflow revision")
+ sdk = root
+ channel = "source-preview"
+ tag = environment.get("STABLE_TAG", "")
+ if tag:
+ sdk = root / ".docs-sdk"
+ revision, project = published_identity(root, sdk, tag, environment)
+ version = project["version"]
+ stage_published_docs(root, sdk, config, project, tag)
+ channel = "stable"
+ else:
+ revision = docs_revision
+ version = tomllib.loads((root / "pyproject.toml").read_text())["project"]["version"]
+ inventory = sdk_inventory(sdk, verify_installed=bool(tag))
+ config["extra"]["docs_channel"] = channel
+ (root / ".docs-build.yml").write_text(yaml.safe_dump(config, sort_keys=False))
+ inventory_bytes = (json.dumps(inventory, sort_keys=True, separators=(",", ":")) + "\n").encode()
+ (root / ".docs-api-inventory.json").write_bytes(inventory_bytes)
+ audit = {
+ "schema": "durable-workflow.sdk-python.docs-deployment/v2",
+ "channel": channel,
+ "docs_source_revision": docs_revision,
+ "sdk_source_revision": revision,
+ "sdk_version": version,
+ "sdk_source_inventory_sha256": hashlib.sha256(inventory_bytes).hexdigest(),
+ "published_wheel_sha256": environment.get("DOCS_WHEEL_SHA256") if tag else None,
+ }
+ (root / ".docs-provenance.json").write_text(json.dumps(audit, sort_keys=True) + "\n")
+ return audit
+
+
+if __name__ == "__main__":
+ prepare(Path.cwd(), dict(os.environ))
diff --git a/tests/test_docs_release_channel.py b/tests/test_docs_release_channel.py
index ee57694f..995276fa 100644
--- a/tests/test_docs_release_channel.py
+++ b/tests/test_docs_release_channel.py
@@ -4,6 +4,7 @@
import json
import os
+import shlex
import subprocess
import sys
from pathlib import Path
@@ -30,6 +31,7 @@ def select_channel(
root: Path, *, version: str = "2.14.1", tag: str = "", event: str = "workflow_dispatch",
ref: str = "refs/heads/main", published: bool = True, lookup_exit: int = 0,
matching_source: bool = True, registry: dict[str, Any] | None = None,
+ docs_version: str | None = None, matching_docs: bool = True,
) -> tuple[subprocess.CompletedProcess[str], dict[str, str], str]:
steps = yaml.safe_load(WORKFLOW.read_text())["jobs"]["build"]["steps"]
validation = next(step["run"] for step in steps if step.get("name") == "Validate the selected stable tag")
@@ -46,7 +48,8 @@ def select_channel(
if registry is None:
registry = {"info": {"name": "durable-workflow", "version": "2.14.1"}, "urls": [
{"filename": "durable_workflow-2.14.1-py3-none-any.whl", "packagetype": "bdist_wheel",
- "yanked": False, "digests": {"sha256": "1" * 64}},
+ "yanked": False, "digests": {"sha256": "1" * 64},
+ "url": "https://files.pythonhosted.org/packages/fixture/durable_workflow-2.14.1-py3-none-any.whl"},
{"filename": "durable_workflow-2.14.1.tar.gz", "packagetype": "sdist",
"yanked": False, "digests": {"sha256": "2" * 64}},
]}
@@ -81,8 +84,16 @@ def git(*arguments: str) -> None:
git("add", "pyproject.toml")
git("commit", "-m", "Fixture source")
git("tag", "2.14.1")
+ git("worktree", "add", "--detach", ".docs-sdk", "2.14.1")
+ if docs_version is not None:
+ (root / "pyproject.toml").write_text(f'[project]\nversion = "{docs_version}"\n')
+ git("add", "pyproject.toml")
+ git("commit", "-m", "Documentation source successor")
if not matching_source:
- git("commit", "--allow-empty", "-m", "Unpublished successor")
+ git("-C", str(root / ".docs-sdk"), "commit", "--allow-empty", "-m", "Wrong API source")
+ environment["GITHUB_SHA"] = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=root, text=True).strip()
+ if not matching_docs:
+ environment["GITHUB_SHA"] = "0" * 40
script = (validation + "\n" if tag else "") + selector
result = subprocess.run(
["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", script],
@@ -96,14 +107,21 @@ def git(*arguments: str) -> None:
def test_main_source_is_preview_even_with_a_numeric_version(tmp_path: Path, version: str) -> None:
result, values, calls = select_channel(tmp_path, version=version, event="push")
assert result.returncode == 0, result.stderr
- assert values == {"stable": "false", "version": version}
+ assert values["stable"] == "false"
+ assert values["version"] == version
+ assert values["docs_revision"] == values["sdk_revision"]
+ assert len(values["docs_revision"]) == 40
assert calls == ""
def test_explicit_published_tag_with_exact_source_can_select_stable(tmp_path: Path) -> None:
result, values, calls = select_channel(tmp_path, tag="2.14.1")
assert result.returncode == 0, result.stderr
- assert values == {"stable": "true", "version": "2.14.1"}
+ assert values["stable"] == "true"
+ assert values["version"] == "2.14.1"
+ assert values["docs_revision"] == values["sdk_revision"]
+ assert values["wheel_sha256"] == "1" * 64
+ assert values["wheel_url"].startswith("https://files.pythonhosted.org/packages/")
assert "release view 2.14.1 --repo durable-workflow/sdk-python" in calls
@@ -134,3 +152,55 @@ def test_registry_identity_or_missing_artifacts_refuse_stable(tmp_path: Path, re
result, values, _ = select_channel(tmp_path, tag="2.14.1", registry=registry)
assert result.returncode != 0
assert values.get("stable") != "true"
+
+
+def test_current_docs_can_select_the_separate_published_sdk(tmp_path: Path) -> None:
+ result, values, _ = select_channel(tmp_path, tag="2.14.1", docs_version="2.15.0")
+ assert result.returncode == 0, result.stderr
+ assert values["stable"] == "true"
+ assert values["version"] == "2.14.1"
+ assert values["docs_revision"] != values["sdk_revision"]
+
+
+def test_wrong_documentation_revision_refuses_before_publication_lookup(tmp_path: Path) -> None:
+ result, values, calls = select_channel(tmp_path, tag="2.14.1", matching_docs=False)
+ assert result.returncode != 0
+ assert values.get("stable") != "true"
+ assert calls == ""
+
+
+def test_current_workflow_owns_docs_and_published_sdk_checkouts() -> None:
+ steps = yaml.safe_load(WORKFLOW.read_text())["jobs"]["build"]["steps"]
+ checkouts = [step for step in steps if step.get("uses", "").startswith("actions/checkout@")]
+ assert checkouts[0]["with"]["ref"] == "${{ github.sha }}"
+ assert checkouts[1]["with"]["path"] == ".docs-sdk"
+ assert all(step["with"]["persist-credentials"] is False for step in checkouts)
+
+
+@pytest.mark.parametrize("workflow", ["docs.yml", "docs-pr.yml"])
+def test_bundled_browser_command_has_no_system_dependency_install_or_fallback(workflow: str) -> None:
+ document = yaml.safe_load(WORKFLOW.with_name(workflow).read_text())
+ steps = next(iter(document["jobs"].values()))["steps"]
+ chromium = next(step for step in steps if step.get("name") == "Install Chromium")
+ assert shlex.split(chromium["run"]) == ["python", "-m", "playwright", "install", "chromium"]
+ assert chromium["env"]["NODE_TLS_REJECT_UNAUTHORIZED"] == "1"
+ assert chromium["env"]["NODE_OPTIONS"] == "--use-openssl-ca"
+ install = next(step["run"] for step in steps if "-r requirements-docs.txt" in step.get("run", ""))
+ assert "--require-hashes" in shlex.split(install.splitlines()[0])
+
+
+@pytest.mark.parametrize("url", [
+ "https://files.pythonhosted.org/packages/fixture/durable_workflow-2.14.1-py3-none-any.whl?other=1",
+ "https://example.test/packages/fixture/durable_workflow-2.14.1-py3-none-any.whl",
+ "https://files.pythonhosted.org/packages/fixture/\ndurable_workflow-2.14.1-py3-none-any.whl",
+])
+def test_wheel_requirement_uses_only_an_exact_public_artifact_url(tmp_path: Path, url: str) -> None:
+ registry = {"info": {"name": "durable-workflow", "version": "2.14.1"}, "urls": [
+ {"filename": "durable_workflow-2.14.1-py3-none-any.whl", "packagetype": "bdist_wheel",
+ "yanked": False, "digests": {"sha256": "1" * 64}, "url": url},
+ {"filename": "durable_workflow-2.14.1.tar.gz", "packagetype": "sdist",
+ "yanked": False, "digests": {"sha256": "2" * 64}},
+ ]}
+ result, values, _ = select_channel(tmp_path, tag="2.14.1", registry=registry)
+ assert result.returncode != 0
+ assert values.get("stable") != "true"
diff --git a/tests/test_portal_source_split.py b/tests/test_portal_source_split.py
new file mode 100644
index 00000000..56f4cdd8
--- /dev/null
+++ b/tests/test_portal_source_split.py
@@ -0,0 +1,139 @@
+"""Exercise source/package/content separation with local inert Git fixtures."""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+import subprocess
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+import pytest
+import yaml
+
+if sys.version_info < (3, 11):
+ pytest.skip("The portal build uses Python 3.12 tomllib", allow_module_level=True)
+
+SCRIPT = Path(__file__).parents[1] / "scripts/prepare_portal.py"
+SPEC = importlib.util.spec_from_file_location("prepare_portal", SCRIPT)
+assert SPEC is not None
+assert SPEC.loader is not None
+portal = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(portal)
+
+
+def git(root: Path, *args: str) -> str:
+ return subprocess.check_output(
+ ["git", "-c", "core.hooksPath=/dev/null", "-c", "user.name=Portal fixture",
+ "-c", "user.email=portal@example.test", "-C", str(root), *args], text=True,
+ stderr=subprocess.DEVNULL,
+ ).strip()
+
+
+@pytest.fixture
+def sources(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, dict[str, str], Path]:
+ root = tmp_path / "repo"
+ root.mkdir()
+ files = {
+ "pyproject.toml": '[project]\nversion = "2.14.1"\nrequires-python = ">=3.10"\n',
+ ".gitignore": ".docs-sdk/\n.docs-stage-*/\n.docs-*.json\n.docs-build.yml\n",
+ "src/durable_workflow/__init__.py": 'PUBLIC = "published"\n',
+ "docs/index.md": '# Published\n```python\nprint("complete example")\n```\n',
+ "docs/reference/metrics.md": "Published metric contract\n",
+ "docs/assets/logo.svg": "published-logo",
+ "docs/stylesheets/layout.css": "published-style",
+ "docs/javascripts/identity.js": "published-script",
+ }
+ config: dict[str, Any] = {
+ "docs_dir": "docs", "plugins": ["search", {"mkdocstrings": {"handlers": {"python": {"paths": ["src"]}}}}],
+ "extra": {"published_sdk_version": "2.14.1"}, "nav": [{"Published metrics": "reference/metrics.md"}],
+ }
+ for name, body in files.items():
+ path = root / name
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(body)
+ (root / "mkdocs.yml").write_text(yaml.safe_dump(config))
+ git(root, "init")
+ git(root, "add", ".")
+ git(root, "commit", "-m", "Published fixture")
+ git(root, "tag", "2.14.1")
+ git(root, "worktree", "add", "--detach", ".docs-sdk", "2.14.1")
+ (root / "pyproject.toml").write_text('[project]\nversion = "2.15.0"\n')
+ (root / "src/durable_workflow/__init__.py").write_text('PUBLIC = "unreleased"\n')
+ (root / "docs/reference/metrics.md").write_text("Unreleased metric contract\n")
+ (root / "docs/index.md").write_text(
+ '# Redesigned\nPython SDK · 2.14.1 published\n
Python 3.10+ \n'
+ '```python\nprint("complete example")\n```\n',
+ )
+ (root / "docs/stylesheets/layout.css").write_text("redesigned-style")
+ config["nav"] = [{"Unreleased page": "future.md"}]
+ (root / "mkdocs.yml").write_text(yaml.safe_dump(config))
+ git(root, "add", ".")
+ git(root, "commit", "-m", "Current docs source")
+ installed = tmp_path / "installed/durable_workflow"
+ installed.mkdir(parents=True)
+ (installed / "__init__.py").write_text('PUBLIC = "published"\n')
+ monkeypatch.setattr(portal.importlib.metadata, "version", lambda _name: "2.14.1")
+ monkeypatch.setattr(portal.importlib.metadata, "distribution", lambda _name: SimpleNamespace(
+ locate_file=lambda path: installed.parent / path, metadata={"Requires-Python": ">=3.10"},
+ ))
+ env = {"STABLE_TAG": "2.14.1", "DOCS_WHEEL_SHA256": "1" * 64, "GITHUB_SHA": git(root, "rev-parse", "HEAD")}
+ return root, env, installed
+
+
+def test_stable_build_combines_current_presentation_and_published_content(sources: tuple) -> None:
+ root, env, _installed = sources
+ audit = portal.prepare(root, env)
+ config = yaml.safe_load((root / ".docs-build.yml").read_text())
+ stage = Path(config["docs_dir"])
+ assert (stage / "reference/metrics.md").read_text() == "Published metric contract\n"
+ assert (stage / "stylesheets/layout.css").read_text() == "redesigned-style"
+ assert (stage / "index.md").read_text().startswith("# Redesigned")
+ assert config["nav"] == [{"Published metrics": "reference/metrics.md"}]
+ assert config["plugins"][1]["mkdocstrings"]["handlers"]["python"]["paths"] == [str(root / ".docs-sdk/src")]
+ assert audit["docs_source_revision"] != audit["sdk_source_revision"]
+ assert audit["sdk_version"] == "2.14.1"
+ assert audit["channel"] == "stable"
+ inventory = json.loads((root / ".docs-api-inventory.json").read_text())
+ assert [row["path"] for row in inventory] == ["durable_workflow/__init__.py"]
+ assert str(root) not in (root / ".docs-provenance.json").read_text()
+
+
+@pytest.mark.parametrize("fault", [
+ "sdk-source", "installed-code", "installed-version", "example", "wheel", "docs-revision", "support-floor",
+])
+def test_changed_identity_or_examples_refuse_and_remove_stale_audit(
+ sources: tuple, monkeypatch: pytest.MonkeyPatch, fault: str,
+) -> None:
+ root, env, installed = sources
+ portal.prepare(root, env)
+ if fault == "sdk-source":
+ (root / ".docs-sdk/src/durable_workflow/__init__.py").write_text("tampered")
+ elif fault == "installed-code":
+ (installed / "__init__.py").write_text("different wheel bytes")
+ elif fault == "installed-version":
+ monkeypatch.setattr(portal.importlib.metadata, "version", lambda _name: "2.15.0")
+ elif fault == "example":
+ (root / "docs/index.md").write_text('```python\nprint("new API")\n```\n')
+ elif fault == "wheel":
+ env["DOCS_WHEEL_SHA256"] = ""
+ elif fault == "support-floor":
+ page = root / "docs/index.md"
+ page.write_text(page.read_text().replace("Python 3.10+", "Python 3.11+"))
+ else:
+ env["GITHUB_SHA"] = "0" * 40
+ with pytest.raises(ValueError):
+ portal.prepare(root, env)
+ assert not (root / ".docs-provenance.json").exists()
+
+
+def test_source_build_stays_preview_with_current_api_and_no_package_claim(sources: tuple) -> None:
+ root, env, _installed = sources
+ env["STABLE_TAG"] = ""
+ audit = portal.prepare(root, env)
+ assert audit["channel"] == "source-preview"
+ assert audit["sdk_version"] == "2.15.0"
+ assert audit["docs_source_revision"] == audit["sdk_source_revision"]
+ assert audit["published_wheel_sha256"] is None