diff --git a/.eleventy.cjs b/.eleventy.cjs index ad3cc6a1..c5f2b03a 100644 --- a/.eleventy.cjs +++ b/.eleventy.cjs @@ -1,7 +1,9 @@ const fs = require('node:fs'); const path = require('node:path'); -const QUICKSTART_CONTRACT = require('./docs/quickstart-contract.json'); +// Deployment tooling can be newer than the selected published SDK source. +const sourceDirectory = path.resolve(process.env.DOCS_SOURCE_DIRECTORY || __dirname); +const QUICKSTART_CONTRACT = require(path.join(sourceDirectory, 'docs/quickstart-contract.json')); const QUICKSTART_BASES = QUICKSTART_CONTRACT.reference_resolution?.bases; const QUICKSTART_SOURCES = Object.freeze(Object.fromEntries( Object.entries(QUICKSTART_CONTRACT.sources).map(([name, reference]) => { @@ -83,8 +85,8 @@ module.exports = function configure(eleventyConfig) { if (typeof sourcePath !== 'string') { throw new Error(`Unknown package-owned quickstart source: ${sourceName}`); } - const resolved = path.resolve(__dirname, sourcePath); - const examplesDirectory = path.resolve(__dirname, 'examples'); + const resolved = path.resolve(sourceDirectory, sourcePath); + const examplesDirectory = path.resolve(sourceDirectory, 'examples'); if (!resolved.startsWith(`${examplesDirectory}${path.sep}`)) { throw new Error(`Quickstart source must stay under ${examplesDirectory}: ${sourcePath}`); } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 437e892f..b630d30d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -528,13 +528,36 @@ jobs: - run: composer analyse docs: + name: ${{ matrix.source == 'current' && 'docs' || format('docs compatibility ({0})', matrix.source) }} runs-on: ubuntu-24.04 timeout-minutes: 15 + strategy: + fail-fast: false + max-parallel: 2 + matrix: + source: [current, '3.9.4', '6a821568db5c72983bd3990fe27c7990f092db12'] + env: + DOCS_SOURCE_DIRECTORY: ${{ github.workspace }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ matrix.source == 'current' && github.sha || matrix.source }} + fetch-depth: 0 - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.12' + - name: Select the temporary tooling directory + run: echo "DOCS_TOOLING_DIRECTORY=$RUNNER_TEMP/docs-tooling" >> "$GITHUB_ENV" + - name: Prepare exact current tooling against the selected source + env: + DOCS_WORKFLOW_REVISION: ${{ github.sha }} + run: | + git fetch origin "$DOCS_WORKFLOW_REVISION" + git show "$DOCS_WORKFLOW_REVISION:scripts/prepare-docs-tooling.py" > "$RUNNER_TEMP/prepare-docs-tooling.py" + python "$RUNNER_TEMP/prepare-docs-tooling.py" "$DOCS_WORKFLOW_REVISION" "$DOCS_TOOLING_DIRECTORY" + - name: Test documentation release-channel guards + if: matrix.source == 'current' + run: python "$DOCS_TOOLING_DIRECTORY/scripts/test-docs-release-channel.py" - name: Select authenticated dependency trust uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust with: @@ -547,19 +570,18 @@ jobs: - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: '24' - cache: npm - name: Verify Composer trust before requests uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust - - run: composer install --no-interaction --prefer-dist - - run: npm ci --ignore-scripts - - run: npm audit --audit-level=moderate - - run: npm run check:docs-examples - - run: npm run test:docs-portal-links + - run: composer --working-dir="$DOCS_TOOLING_DIRECTORY" install --no-interaction --prefer-dist + - run: npm --prefix "$DOCS_TOOLING_DIRECTORY" ci --ignore-scripts + - run: npm --prefix "$DOCS_TOOLING_DIRECTORY" audit --audit-level=moderate + - run: node "$DOCS_TOOLING_DIRECTORY/scripts/check-docs-examples.mjs" + - run: npm --prefix "$DOCS_TOOLING_DIRECTORY" run test:docs-portal-links - name: Verify Composer trust before requests uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust - - run: composer docs - - run: npm run check:docs - - run: php scripts/check-docs-analytics.php build/site + - run: bash "$DOCS_TOOLING_DIRECTORY/scripts/build-docs.sh" + - run: node "$DOCS_TOOLING_DIRECTORY/scripts/check-docs-portal.mjs" build/site + - run: php "$DOCS_TOOLING_DIRECTORY/scripts/check-docs-analytics.php" build/site - name: Check rendered portal links before merge uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2 with: @@ -569,9 +591,11 @@ jobs: --root-dir build/site build/site - name: Install the locked Chromium browser - run: ./node_modules/.bin/playwright install --with-deps chromium + run: '"$DOCS_TOOLING_DIRECTORY/node_modules/.bin/playwright" install --with-deps chromium' - name: Verify missing-resource browser evidence - run: npm run test:docs-browser-failures + run: npm --prefix "$DOCS_TOOLING_DIRECTORY" run test:docs-browser-failures + - name: Check portal and API layouts in Chromium + run: node "$DOCS_TOOLING_DIRECTORY/scripts/check-docs-browser.mjs" build/site - name: Check repository-owned documentation links offline uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2 with: @@ -581,6 +605,12 @@ jobs: --include-fragments --root-dir . README.md docs/*.md + - name: Verify the selected source remains unchanged + run: | + git diff --exit-code HEAD + test "$(git rev-parse HEAD)" = "$(git rev-parse '${{ matrix.source == 'current' && github.sha || matrix.source }}^{commit}')" + test "$(git -C "$DOCS_TOOLING_DIRECTORY" rev-parse HEAD)" = "$GITHUB_SHA" + python "$DOCS_TOOLING_DIRECTORY/scripts/bind-docs-artifact.py" "$(git rev-parse '${{ matrix.source == 'current' && github.sha || matrix.source }}^{commit}')" package-smoke: runs-on: ubuntu-24.04 timeout-minutes: 10 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5c8bc3af..44ccfba3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,6 +10,9 @@ on: - '.eleventy.cjs' - 'scripts/check-docs-*' - 'scripts/finalize-api-reference.php' + - 'scripts/build-docs.sh' + - 'scripts/prepare-docs-tooling.py' + - 'scripts/bind-docs-artifact.py' - 'README.md' - 'composer.json' - 'package.json' @@ -36,6 +39,8 @@ concurrency: jobs: build: runs-on: ubuntu-24.04 + env: + DOCS_SOURCE_DIRECTORY: ${{ github.workspace }} outputs: stable: ${{ steps.release-channel.outputs.stable }} steps: @@ -72,51 +77,35 @@ jobs: php-version: '8.3' tools: composer:2.10.3 coverage: none + - name: Select the temporary tooling directory + run: echo "DOCS_TOOLING_DIRECTORY=$RUNNER_TEMP/docs-tooling" >> "$GITHUB_ENV" + - name: Prepare the exact workflow tooling separately + env: + DOCS_WORKFLOW_REVISION: ${{ github.workflow_sha }} + run: | + if [[ ! "$DOCS_WORKFLOW_REVISION" =~ ^[0-9a-f]{40}$ ]]; then + echo 'The documentation tooling requires the exact workflow revision' >&2 + exit 1 + fi + git show "$DOCS_WORKFLOW_REVISION:scripts/prepare-docs-tooling.py" > "$RUNNER_TEMP/prepare-docs-tooling.py" + python "$RUNNER_TEMP/prepare-docs-tooling.py" "$DOCS_WORKFLOW_REVISION" "$DOCS_TOOLING_DIRECTORY" - name: Resolve the public documentation release channel id: release-channel env: STABLE_TAG: ${{ inputs.stable_tag }} STABLE_SOURCE: ${{ inputs.stable_source }} GH_TOKEN: ${{ github.token }} - run: | - version=$(php -r '$manifest = json_decode(file_get_contents("composer.json"), true, flags: JSON_THROW_ON_ERROR); echo $manifest["extra"]["durable-workflow"]["product-train"];') - if [ -n "$STABLE_TAG" ]; then - test "$version" = "$STABLE_TAG" - test "$(gh release view "$STABLE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease --jq '(.isDraft == false and .isPrerelease == false)')" = true - if [ -z "$STABLE_SOURCE" ]; then - test "$(git rev-parse HEAD)" = "$(git rev-parse "refs/tags/$STABLE_TAG^{commit}")" - else - test "$(git rev-parse HEAD)" = "$STABLE_SOURCE" - git merge-base --is-ancestor HEAD origin/main - git diff --exit-code "refs/tags/$STABLE_TAG" HEAD -- src examples resources config - git show "refs/tags/$STABLE_TAG:composer.json" > "$RUNNER_TEMP/published-sdk-composer.json" - PUBLISHED_MANIFEST="$RUNNER_TEMP/published-sdk-composer.json" php <<'PHP' - > "$GITHUB_OUTPUT" - else - echo stable=false >> "$GITHUB_OUTPUT" - fi + run: python "$DOCS_TOOLING_DIRECTORY/scripts/check-docs-release-channel.py" - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: node-version: '24' - cache: npm - name: Verify Composer trust before requests uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust - - run: composer install --no-interaction --prefer-dist - - run: npm ci --ignore-scripts - - run: npm audit --audit-level=moderate - - run: npm run check:docs-examples - - run: npm run test:docs-portal-links + - run: composer --working-dir="$DOCS_TOOLING_DIRECTORY" install --no-interaction --prefer-dist + - run: npm --prefix "$DOCS_TOOLING_DIRECTORY" ci --ignore-scripts + - run: npm --prefix "$DOCS_TOOLING_DIRECTORY" audit --audit-level=moderate + - run: node "$DOCS_TOOLING_DIRECTORY/scripts/check-docs-examples.mjs" + - run: npm --prefix "$DOCS_TOOLING_DIRECTORY" run test:docs-portal-links - name: Check repository-owned documentation links offline uses: docker://lycheeverse/lychee@sha256:e2d19e57cf6ab037026f20b8e449a1f30d9d7f81eef4194763aab2eab20bd28d # 0.24.2 with: @@ -130,19 +119,12 @@ jobs: uses: durable-workflow/.github/.github/actions/verify-composer-trust@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust - name: Build authored portal and generated API reference env: + DOCS_REQUIRE_ANALYTICS_TOKEN: 'true' CLOUDFLARE_WEB_ANALYTICS_TOKEN: ${{ vars.CLOUDFLARE_WEB_ANALYTICS_TOKEN }} run: | - if [ "$GITHUB_SERVER_URL" = https://github.com ]; then - 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 - exit 1 - fi - sed -i "s/__CLOUDFLARE_WEB_ANALYTICS_TOKEN__/$CLOUDFLARE_WEB_ANALYTICS_TOKEN/" \ - .phpdoc/template/analytics/analytics.js - fi - composer docs - npm run check:docs - php scripts/check-docs-analytics.php build/site + bash "$DOCS_TOOLING_DIRECTORY/scripts/build-docs.sh" + node "$DOCS_TOOLING_DIRECTORY/scripts/check-docs-portal.mjs" build/site + php "$DOCS_TOOLING_DIRECTORY/scripts/check-docs-analytics.php" build/site test -s build/site/index.html test -s build/site/api/index.html test "$(cat build/site/CNAME)" = "php.durable-workflow.com" @@ -154,20 +136,17 @@ jobs: --offline --root-dir build/site build/site - - run: ./node_modules/.bin/playwright install --with-deps chromium + - run: '"$DOCS_TOOLING_DIRECTORY/node_modules/.bin/playwright" install --with-deps chromium' - name: Test missing-resource browser evidence - run: npm run test:docs-browser-failures + run: npm --prefix "$DOCS_TOOLING_DIRECTORY" run test:docs-browser-failures - name: Check portal and API layouts in Chromium - run: npm run check:docs-browser + run: node "$DOCS_TOOLING_DIRECTORY/scripts/check-docs-browser.mjs" build/site - name: Bind Pages artifact to the exact source revision + env: + EXPECTED_SOURCE_REVISION: ${{ steps.release-channel.outputs.source_revision }} run: | - SOURCE_REVISION=$(git rev-parse HEAD) - if ! printf '%s' "$SOURCE_REVISION" | grep -Eq '^[a-f0-9]{40}$'; then - echo 'SOURCE_REVISION must be an exact lowercase commit SHA' >&2 - exit 1 - fi - printf '{"schema":"durable-workflow.sdk-php.docs-deployment/v1","source_revision":"%s"}\n' \ - "$SOURCE_REVISION" > build/site/deployment-audit.json + test "$(git -C "$DOCS_TOOLING_DIRECTORY" rev-parse HEAD)" = "$GITHUB_WORKFLOW_SHA" + python "$DOCS_TOOLING_DIRECTORY/scripts/bind-docs-artifact.py" "$EXPECTED_SOURCE_REVISION" - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 if: github.server_url == 'https://github.com' with: diff --git a/scripts/bind-docs-artifact.py b/scripts/bind-docs-artifact.py new file mode 100644 index 00000000..5296d61f --- /dev/null +++ b/scripts/bind-docs-artifact.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Bind a rendered site to its verified, unchanged selected Git source.""" + +import json +import re +import subprocess +import sys +from pathlib import Path + + +def main() -> int: + if len(sys.argv) != 2 or not re.fullmatch(r"[0-9a-f]{40}", sys.argv[1]): + print("Expected the exact selected source commit.", file=sys.stderr) + return 1 + selected = subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() + if selected != sys.argv[1] or subprocess.run(["git", "diff", "--exit-code", "HEAD"], check=False).returncode: + print("The selected documentation source changed before artifact binding.", file=sys.stderr) + return 1 + Path("build/site/deployment-audit.json").write_text(json.dumps({ + "schema": "durable-workflow.sdk-php.docs-deployment/v1", "source_revision": selected, + }) + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh new file mode 100644 index 00000000..86b12345 --- /dev/null +++ b/scripts/build-docs.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run workflow-owned tools and dependencies against the selected source inputs. +tooling_directory=$(cd "$(dirname "$0")/.." && pwd) +export DOCS_SOURCE_DIRECTORY=${DOCS_SOURCE_DIRECTORY:-$PWD} +cd "$DOCS_SOURCE_DIRECTORY" +"$tooling_directory/vendor/bin/phpdoc" \ + -d "$PWD/src" -t "$PWD/build/api" --cache-folder "$PWD/build/phpdoc-cache" \ + --title='Durable Workflow PHP SDK — API Reference' --template="$PWD/.phpdoc/template" +php "$tooling_directory/scripts/finalize-api-reference.php" build/api +"$tooling_directory/node_modules/.bin/eleventy" --config="$tooling_directory/.eleventy.cjs" + +# Substitute only the generated runtime; keep the selected checkout unchanged. +if [[ "${DOCS_REQUIRE_ANALYTICS_TOKEN:-}" = true ]]; then + if [[ ! "${CLOUDFLARE_WEB_ANALYTICS_TOKEN:-}" =~ ^[a-f0-9]{32}$ ]]; then + echo 'CLOUDFLARE_WEB_ANALYTICS_TOKEN must be the canonical 32-character site token' >&2 + exit 1 + fi + python - <<'PY' +import os +from pathlib import Path +runtime = Path("build/site/analytics/analytics.js") +runtime.write_text(runtime.read_text().replace("__CLOUDFLARE_WEB_ANALYTICS_TOKEN__", os.environ["CLOUDFLARE_WEB_ANALYTICS_TOKEN"])) +PY +fi diff --git a/scripts/check-docs-analytics.php b/scripts/check-docs-analytics.php index ba3c0edb..53a30278 100644 --- a/scripts/check-docs-analytics.php +++ b/scripts/check-docs-analytics.php @@ -3,10 +3,14 @@ declare(strict_types=1); $siteDirectory = rtrim($argv[1] ?? __DIR__.'/../build/site', '/\\'); -$runtimeSource = file_get_contents(__DIR__.'/../.phpdoc/template/analytics/analytics.js'); +$sourceDirectory = getenv('DOCS_SOURCE_DIRECTORY') ?: __DIR__.'/..'; +$runtimeSource = file_get_contents($sourceDirectory.'/.phpdoc/template/analytics/analytics.js'); +if ($runtimeSource !== false && getenv('CLOUDFLARE_WEB_ANALYTICS_TOKEN')) { + $runtimeSource = str_replace('__CLOUDFLARE_WEB_ANALYTICS_TOKEN__', getenv('CLOUDFLARE_WEB_ANALYTICS_TOKEN'), $runtimeSource); +} $runtime = file_get_contents($siteDirectory.'/analytics/analytics.js'); -$referenceStyles = file_get_contents(__DIR__.'/../.phpdoc/template/assets/api-reference.css'); -$referenceRuntime = file_get_contents(__DIR__.'/../.phpdoc/template/assets/api-reference.js'); +$referenceStyles = file_get_contents($sourceDirectory.'/.phpdoc/template/assets/api-reference.css'); +$referenceRuntime = file_get_contents($sourceDirectory.'/.phpdoc/template/assets/api-reference.js'); if ($runtimeSource === false || $runtime === false || $runtime !== $runtimeSource) { throw new RuntimeException('Rendered analytics runtime is missing or stale.'); diff --git a/scripts/check-docs-examples.mjs b/scripts/check-docs-examples.mjs index 2178a431..c6145914 100644 --- a/scripts/check-docs-examples.mjs +++ b/scripts/check-docs-examples.mjs @@ -1,10 +1,14 @@ import {readFile} from 'node:fs/promises'; import Ajv2020 from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; +import path from 'node:path'; +import {pathToFileURL} from 'node:url'; -const repoRoot = new URL('../', import.meta.url); +const repoRoot = process.env.DOCS_SOURCE_DIRECTORY + ? pathToFileURL(path.resolve(process.env.DOCS_SOURCE_DIRECTORY) + path.sep) + : new URL('../', import.meta.url); const contract = JSON.parse( - await readFile(new URL('scripts/check-docs-examples-contract.json', repoRoot), 'utf8'), + await readFile(new URL('./check-docs-examples-contract.json', import.meta.url), 'utf8'), ); const quickstart = JSON.parse( await readFile(new URL(contract.quickstartContract, repoRoot), 'utf8'), diff --git a/scripts/check-docs-portal.mjs b/scripts/check-docs-portal.mjs index 19ea7a1c..cf803814 100644 --- a/scripts/check-docs-portal.mjs +++ b/scripts/check-docs-portal.mjs @@ -7,8 +7,8 @@ import Ajv2020 from 'ajv/dist/2020.js'; import {assertPrimaryGuideNavigation} from './check-docs-portal-navigation.mjs'; import {fragmentLinks} from './check-docs-portal-links.mjs'; -const siteDirectory = path.resolve(process.argv[2] ?? 'build/site'); -const projectDirectory = path.resolve(import.meta.dirname, '..'); +const projectDirectory = path.resolve(process.env.DOCS_SOURCE_DIRECTORY || path.join(import.meta.dirname, '..')); +const siteDirectory = path.resolve(process.argv[2] ?? path.join(projectDirectory, 'build/site')); const composer = JSON.parse(fs.readFileSync(path.join(projectDirectory, 'composer.json'), 'utf8')); const navigation = JSON.parse(fs.readFileSync(path.join(projectDirectory, 'docs/portal/_data/navigation.json'), 'utf8')); const quickstartContract = JSON.parse(fs.readFileSync(path.join(projectDirectory, 'docs/quickstart-contract.json'), 'utf8')); diff --git a/scripts/check-docs-release-channel.py b/scripts/check-docs-release-channel.py new file mode 100644 index 00000000..72250bf5 --- /dev/null +++ b/scripts/check-docs-release-channel.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""Select stable documentation only for a published, unchanged SDK source.""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + + +STABLE_VERSION = re.compile(r"\d+\.\d+\.\d+", re.ASCII) +SOURCE_REVISION = re.compile(r"[0-9a-f]{40}") +SDK_PATHS = ("src", "examples", "resources", "config") +AUTOMATIC_PATHS = SDK_PATHS + ("composer.lock", "package.json", "package-lock.json") + + +class ChannelError(ValueError): + """The requested stable source cannot be established.""" + + +def run(*arguments: str) -> subprocess.CompletedProcess[str]: + return subprocess.run(arguments, check=False, capture_output=True, text=True, timeout=60) + + +def git(*arguments: str) -> str: + result = run("git", *arguments) + if result.returncode: + raise ChannelError("Unable to verify the documentation source in Git.") + return result.stdout.strip() + + +def published_stable_release(tag: str) -> bool: + repository = os.environ.get("GITHUB_REPOSITORY", "") + if not repository: + return False + result = run("gh", "release", "view", tag, "--repo", repository, "--json", "isDraft,isPrerelease") + if result.returncode: + return False + try: + release = json.loads(result.stdout) + except json.JSONDecodeError: + return False + return isinstance(release, dict) and release.get("isDraft") is False and release.get("isPrerelease") is False + + +def same_published_product(tag_ref: str, *, explicit: bool) -> bool: + paths = SDK_PATHS if explicit else AUTOMATIC_PATHS + difference = run("git", "diff", "--quiet", tag_ref, "--", *paths) + if difference.returncode not in (0, 1): + raise ChannelError("Unable to compare the published SDK source.") + if difference.returncode: + return False + if git("ls-files", "--others", "--exclude-standard", "--", *paths): + return False + published = json.loads(git("show", f"{tag_ref}:composer.json")) + selected = json.loads(Path("composer.json").read_text()) + if explicit: + # Retain the existing reviewed stable_source manifest comparison. + for manifest in (published, selected): + manifest.get("extra", {}).get("durable-workflow", {}).pop("product-train", None) + # Preserve JSON value types and key order, as the previous strict PHP comparison did. + return json.dumps(published, ensure_ascii=False) == json.dumps(selected, ensure_ascii=False) + + +def explicit_stable_source(stable_tag: str, stable_source: str, selected: str, version: str) -> bool: + if version != stable_tag or not published_stable_release(stable_tag): + raise ChannelError("stable_tag must match the manifest and a published stable release.") + tag_ref = f"refs/tags/{stable_tag}" + published = git("rev-parse", "--verify", f"{tag_ref}^{{commit}}") + if not stable_source: + if selected != published: + raise ChannelError("The selected documentation source does not match stable_tag.") + elif selected != stable_source or run("git", "merge-base", "--is-ancestor", selected, "origin/main").returncode: + raise ChannelError("stable_source must select the exact main-branch source commit.") + if not same_published_product(tag_ref, explicit=True): + raise ChannelError("The selected source changes the published SDK product or manifest.") + return True + + +def release_channel(stable_tag: str, stable_source: str) -> tuple[bool, str]: + if stable_tag and not STABLE_VERSION.fullmatch(stable_tag): + raise ChannelError("stable_tag must name an exact stable SDK version.") + if stable_source and (not stable_tag or not SOURCE_REVISION.fullmatch(stable_source)): + raise ChannelError("stable_source requires a stable_tag and an exact lowercase source commit.") + selected = git("rev-parse", "HEAD") + if not SOURCE_REVISION.fullmatch(selected): + raise ChannelError("The selected documentation source must be an exact lowercase commit.") + manifest = json.loads(Path("composer.json").read_text()) + version = manifest.get("extra", {}).get("durable-workflow", {}).get("product-train") + if not isinstance(version, str): + raise ChannelError("The selected SDK manifest has no product-train version.") + + if stable_tag: + return explicit_stable_source(stable_tag, stable_source, selected, version), selected + + if not STABLE_VERSION.fullmatch(version): + return False, selected + tag_ref = f"refs/tags/{version}" + if run("git", "rev-parse", "--verify", f"{tag_ref}^{{commit}}").returncode: + return False, selected + if not published_stable_release(version): + return False, selected + return same_published_product(tag_ref, explicit=False), selected + + +def main() -> int: + try: + stable, source = release_channel(os.environ.get("STABLE_TAG", ""), os.environ.get("STABLE_SOURCE", "")) + except (ChannelError, json.JSONDecodeError, OSError, subprocess.TimeoutExpired) as error: + print(f"Documentation channel refused: {error}", file=sys.stderr) + return 1 + output = f"stable={'true' if stable else 'false'}\nsource_revision={source}\n" + if os.environ.get("GITHUB_OUTPUT"): + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as destination: + destination.write(output) + print(output, end="") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/finalize-api-reference.php b/scripts/finalize-api-reference.php index 4ca62dc0..b9c64fce 100644 --- a/scripts/finalize-api-reference.php +++ b/scripts/finalize-api-reference.php @@ -53,7 +53,8 @@ function removeIncompleteGraphPages(string $buildDirectory): int $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($buildDirectory)); $description = 'Generated classes, methods, models, exceptions, and worker types for the Durable Workflow PHP SDK.'; $count = 0; -$faviconSource = __DIR__.'/../docs/portal/assets/favicon.svg'; +$sourceDirectory = getenv('DOCS_SOURCE_DIRECTORY') ?: __DIR__.'/..'; +$faviconSource = $sourceDirectory.'/docs/portal/assets/favicon.svg'; $faviconTarget = $buildDirectory.'/assets/favicon.svg'; if (! is_file($faviconSource) || ! is_dir(dirname($faviconTarget)) || ! copy($faviconSource, $faviconTarget)) { diff --git a/scripts/prepare-docs-tooling.py b/scripts/prepare-docs-tooling.py new file mode 100644 index 00000000..bb9bbb52 --- /dev/null +++ b/scripts/prepare-docs-tooling.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Check out exact workflow tooling without changing the selected source.""" + +import re +import subprocess +import sys + + +def main() -> int: + if len(sys.argv) != 3: + print("Expected an exact workflow commit and a separate tooling directory.", file=sys.stderr) + return 1 + revision = re.fullmatch(r"[0-9a-f]{40}", sys.argv[1]) + destination = re.fullmatch(r"/[\w ./-]+", sys.argv[2], re.ASCII) + if revision is None or destination is None: + print("Expected an exact workflow commit and an absolute tooling directory.", file=sys.stderr) + return 1 + return subprocess.run( + ["git", "worktree", "add", "--detach", "--", destination.group(0), revision.group(0)], check=False, + ).returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/test-docs-release-channel.py b/scripts/test-docs-release-channel.py new file mode 100644 index 00000000..a2ade3f6 --- /dev/null +++ b/scripts/test-docs-release-channel.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python3 +"""Exercise channel selection against real Git histories and an offline release API.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +GUARD = Path(__file__).with_name("check-docs-release-channel.py").resolve() +PREPARE = Path(__file__).with_name("prepare-docs-tooling.py").resolve() +BIND = Path(__file__).with_name("bind-docs-artifact.py").resolve() + + +class DocumentationChannelTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) / "source" + self.root.mkdir() + self.bin = Path(self.temporary.name) / "bin" + self.bin.mkdir() + self.output = Path(self.temporary.name) / "outputs" + self.environment = { + "PATH": str(self.bin) + os.pathsep + os.environ["PATH"], + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GITHUB_REPOSITORY": "fixture/sdk-php", + "GITHUB_OUTPUT": str(self.output), + "FAKE_RELEASE": json.dumps({"isDraft": False, "isPrerelease": False}), + } + gh = self.bin / "gh" + gh.write_text( + "#!/usr/bin/env python3\n" + "import os,sys\n" + "expected=['release','view','3.9.4','--repo','fixture/sdk-php','--json','isDraft,isPrerelease']\n" + "if sys.argv[1:]!=expected:sys.exit(9)\n" + "print(os.environ['FAKE_RELEASE'])\n" + "sys.exit(int(os.environ.get('FAKE_RELEASE_STATUS','0')))\n" + ) + gh.chmod(0o700) + self.git("init", "--object-format=sha1", "-b", "main") + self.git("config", "user.name", "Documentation test") + self.git("config", "user.email", "docs@example.test") + self.manifest = {"require": {"php": "^8.1"}, "extra": {"durable-workflow": {"product-train": "3.9.4"}}} + self.write_manifest() + for path in ["src/Client.php", "examples/worker.php", "resources/quickstart.json", "config/sdk.php", "package.json", "package-lock.json", "composer.lock", "docs/index.md"]: + self.write(path, "published\n") + self.published = self.commit() + self.git("tag", "-a", "3.9.4", "-m", "Published SDK") + self.git("update-ref", "refs/remotes/origin/main", self.published) + + def git(self, *arguments: str) -> str: + result = subprocess.run(["git", *arguments], cwd=self.root, env=self.environment, text=True, capture_output=True, check=True) + return result.stdout.strip() + + def write(self, name: str, content: str) -> None: + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + def write_manifest(self) -> None: + self.write("composer.json", json.dumps(self.manifest)) + + def commit(self) -> str: + self.git("add", ".") + self.git("commit", "-m", "Fixture source") + return self.git("rev-parse", "HEAD") + + def changed(self, path: str) -> str: + self.write(path, "changed\n") + return self.commit() + + def channel(self, stable: bool | None, **environment: str) -> None: + before = self.git("rev-parse", "HEAD") + self.output.unlink(missing_ok=True) + result = subprocess.run([sys.executable, str(GUARD)], cwd=self.root, env=self.environment | environment, text=True, capture_output=True) + if stable is None: + self.assertNotEqual(0, result.returncode, result.stdout) + self.assertFalse(self.output.exists()) + else: + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(f"stable={'true' if stable else 'false'}\nsource_revision={before}\n", self.output.read_text()) + self.assertEqual(before, self.git("rev-parse", "HEAD"), "Channel selection must not change the audited source.") + + def testPublishedStableTagIsStable(self) -> None: + self.channel(True) + self.channel(True, STABLE_TAG="3.9.4") + + def test_tooling_is_exact_and_separate_from_an_old_selected_tag(self) -> None: + self.write("scripts/check-docs-release-channel.py", GUARD.read_text()) + self.write("package.json", json.dumps({"scripts": {"test:docs-portal-links": "workflow-owned"}})) + workflow = self.commit() + self.git("checkout", "--detach", self.published) + self.assertFalse((self.root / "scripts/check-docs-release-channel.py").exists()) + selected_manifest = (self.root / "package.json").read_bytes() + tooling = Path(self.temporary.name) / "tooling" + prepared = subprocess.run( + [sys.executable, str(PREPARE), workflow, str(tooling)], + cwd=self.root, env=self.environment, text=True, capture_output=True, + ) + self.assertEqual(0, prepared.returncode, prepared.stderr) + self.assertEqual(workflow, subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=tooling, env=self.environment, text=True, + ).strip()) + self.assertIn("test:docs-portal-links", (tooling / "package.json").read_text()) + result = subprocess.run( + [sys.executable, str(tooling / "scripts/check-docs-release-channel.py")], + cwd=self.root, env=self.environment | {"STABLE_TAG": "3.9.4"}, text=True, capture_output=True, + ) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual(f"stable=true\nsource_revision={self.published}\n", self.output.read_text()) + self.assertEqual(selected_manifest, (self.root / "package.json").read_bytes()) + self.assertEqual("", self.git("diff", "HEAD")) + self.git("worktree", "remove", str(tooling)) + + def test_tooling_refuses_a_mutable_revision_or_existing_directory(self) -> None: + for revision, destination in [("main", Path(self.temporary.name) / "new"), (self.published, self.root), (self.published, Path("--orphan"))]: + with self.subTest(revision=revision): + result = subprocess.run( + [sys.executable, str(PREPARE), revision, str(destination)], + cwd=self.root, env=self.environment, text=True, capture_output=True, + ) + self.assertNotEqual(0, result.returncode) + self.assertEqual(self.published, self.git("rev-parse", "HEAD")) + + def test_artifact_audit_uses_exact_selected_source(self) -> None: + for source in [self.published, self.changed("docs/index.md")]: + with self.subTest(source=source): + self.git("checkout", "--detach", source) + (self.root / "build/site").mkdir(parents=True, exist_ok=True) + result = subprocess.run([sys.executable, str(BIND), source], cwd=self.root, env=self.environment, capture_output=True) + self.assertEqual(0, result.returncode, result.stderr) + self.assertEqual({"schema": "durable-workflow.sdk-php.docs-deployment/v1", "source_revision": source}, + json.loads((self.root / "build/site/deployment-audit.json").read_text())) + + def test_artifact_refuses_wrong_identity_and_modified_selected_source(self) -> None: + (self.root / "build/site").mkdir(parents=True) + for expected, dirty in [("main", False), ("a" * 40, False), (self.published, True)]: + with self.subTest(expected=expected, dirty=dirty): + if dirty: + self.write("docs/index.md", "uncommitted documentation") + result = subprocess.run([sys.executable, str(BIND), expected], cwd=self.root, env=self.environment, capture_output=True) + self.assertNotEqual(0, result.returncode) + self.assertFalse((self.root / "build/site/deployment-audit.json").exists()) + + def testDocumentationOnlyMainChangesRemainStable(self) -> None: + self.changed("docs/index.md") + self.channel(True) + + def testStableLookingTrainDoesNotPublishChangedProductPaths(self) -> None: + for path in ["src/Client.php", "examples/worker.php", "resources/quickstart.json", "config/sdk.php", "package.json", "package-lock.json", "composer.lock"]: + with self.subTest(path=path): + self.git("reset", "--hard", self.published) + self.changed(path) + self.channel(False) + + def testManifestChangesRemainPreview(self) -> None: + self.manifest["require"]["php"] = "^8.2" + self.write_manifest() + self.commit() + self.channel(False) + + def testWorkingProductChangesCannotPassAsTheTag(self) -> None: + self.write("src/Client.php", "uncommitted API\n") + self.channel(False) + self.channel(None, STABLE_TAG="3.9.4") + + def testUntrackedProductFilesRemainPreview(self) -> None: + self.write("src/FutureApi.php", "future API\n") + self.channel(False) + + def testMissingPublishedTagRemainsPreview(self) -> None: + self.git("tag", "-d", "3.9.4") + self.channel(False) + + def testUnpublishedDraftPrereleaseAndMalformedReleasesRemainPreview(self) -> None: + for release, status in [("{}", "1"), ('{"isDraft":true,"isPrerelease":false}', "0"), ('{"isDraft":false,"isPrerelease":true}', "0"), ("{}", "0"), ('{"isDraft":0,"isPrerelease":false}', "0"), ("not JSON", "0")]: + with self.subTest(release=release, status=status): + self.channel(False, FAKE_RELEASE=release, FAKE_RELEASE_STATUS=status) + self.channel(None, STABLE_TAG="3.9.4", FAKE_RELEASE=release, FAKE_RELEASE_STATUS=status) + + def testPrereleaseTrainRemainsPreview(self) -> None: + self.manifest["extra"]["durable-workflow"]["product-train"] = "3.10.0-rc.1" + self.write_manifest() + self.commit() + self.channel(False) + + def testExplicitTagRejectsADifferentSelectedCommit(self) -> None: + self.changed("docs/index.md") + self.channel(None, STABLE_TAG="3.9.4") + + def testReviewedDocumentationOverrideWorksWithoutAGuardInTheOldTag(self) -> None: + self.assertFalse((self.root / "scripts/check-docs-release-channel.py").exists()) + source = self.changed("docs/index.md") + self.git("update-ref", "refs/remotes/origin/main", source) + self.channel(True, STABLE_TAG="3.9.4", STABLE_SOURCE=source) + + def testExplicitSourceRequiresExactMainAncestryAndIdentity(self) -> None: + source = self.changed("docs/index.md") + self.channel(None, STABLE_TAG="3.9.4", STABLE_SOURCE=source) + self.git("update-ref", "refs/remotes/origin/main", source) + self.channel(None, STABLE_TAG="3.9.4", STABLE_SOURCE=self.published) + + def testExplicitSourceRejectsProductAndManifestChanges(self) -> None: + for path in ["src/Client.php", "examples/worker.php", "resources/quickstart.json", "config/sdk.php"]: + with self.subTest(path=path): + self.git("reset", "--hard", self.published) + source = self.changed(path) + self.git("update-ref", "refs/remotes/origin/main", source) + self.channel(None, STABLE_TAG="3.9.4", STABLE_SOURCE=source) + self.git("reset", "--hard", self.published) + self.manifest["require"]["php"] = "^8.2" + self.write_manifest() + source = self.commit() + self.git("update-ref", "refs/remotes/origin/main", source) + self.channel(None, STABLE_TAG="3.9.4", STABLE_SOURCE=source) + + def testExplicitSourceRetainsStrictManifestTypesAndOrder(self) -> None: + # The prior PHP !== comparison rejects changed JSON value types/order. + self.manifest["require"]["php"] = 81 + self.write_manifest() + source = self.commit() + self.git("update-ref", "refs/remotes/origin/main", source) + self.channel(None, STABLE_TAG="3.9.4", STABLE_SOURCE=source) + self.git("reset", "--hard", self.published) + self.manifest = {"extra": self.manifest["extra"], "require": {"php": "^8.1"}} + self.write_manifest() + source = self.commit() + self.git("update-ref", "refs/remotes/origin/main", source) + self.channel(None, STABLE_TAG="3.9.4", STABLE_SOURCE=source) + + def testInvalidExplicitInputsFailClosed(self) -> None: + for inputs in [{"STABLE_TAG": "3.9.4-rc.1"}, {"STABLE_TAG": "../main"}, {"STABLE_SOURCE": self.published}, {"STABLE_TAG": "3.9.4", "STABLE_SOURCE": "HEAD"}, {"STABLE_TAG": "3.9.4", "STABLE_SOURCE": self.published.upper()}, {"STABLE_TAG": "3.9.3"}]: + with self.subTest(inputs=inputs): + self.channel(None, **inputs) + + +if __name__ == "__main__": + unittest.main()