Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 66 additions & 36 deletions .github/workflows/docs-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,17 @@ on:
required: false
type: string
artifact_run_id:
description: "Existing CI/CD Pipeline run ID to deploy from instead of rebuilding"
description: "Existing workflow run ID to deploy from instead of rebuilding"
required: false
type: string
artifact_name:
description: "Site artifact to recover (set ref to the version it rebuilt)"
required: false
default: docs-site
type: choice
options:
- docs-site
- docs-site-manual
release:
types: [published]

Expand All @@ -25,8 +33,8 @@ permissions:
id-token: write

concurrency:
# The release run waits for its dispatched deployment, so it must not hold
# the deployment concurrency group while the child workflow is queued.
# Release dispatches must not hold the deployment group while queuing a child.
# All actual deployments share this lock through merge, publish and verification.
group: ${{ github.event_name == 'release' && format('github-pages-release-{0}', github.run_id) || 'github-pages' }}
cancel-in-progress: false

Expand All @@ -41,10 +49,9 @@ jobs:
github.event.action == 'published' &&
startsWith(github.event.release.tag_name, 'v')
runs-on: ubuntu-latest
timeout-minutes: 60
timeout-minutes: 5
permissions:
actions: write
checks: read
contents: read
steps:
- name: Dispatch release docs build
Expand All @@ -62,12 +69,9 @@ jobs:
echo "Could not determine dispatched workflow run ID from: ${dispatched_url}" >&2
exit 1
fi
echo "Waiting for dispatched docs workflow ${dispatched_run_id}."
gh run watch "${dispatched_run_id}" \
--repo "${GITHUB_REPOSITORY}" \
--compact \
--exit-status \
--interval 10
echo "Release docs queued: ${dispatched_url}"
echo "### Release documentation" >> "${GITHUB_STEP_SUMMARY}"
echo "Build and deployment status: [${RELEASE_TAG}](${dispatched_url})" >> "${GITHUB_STEP_SUMMARY}"

inspect-docs-artifact:
if: >
Expand Down Expand Up @@ -112,11 +116,32 @@ jobs:
- name: Download docs site artifact from CI/CD Pipeline run
uses: actions/download-artifact@v4
with:
name: docs-site
name: ${{ inputs.artifact_name || 'docs-site' }}
run-id: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.id || inputs.artifact_run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
path: ./docs-site

# Deployment tooling comes from this workflow revision, not the release tag.
- uses: actions/checkout@v4
with:
ref: ${{ github.workflow_sha }}
path: deployment-tools
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install deployment dependencies
run: python -m pip install --index-url https://pypi.org/simple packaging

- name: Preserve currently published versions
env:
GH_TOKEN: ${{ github.token }}
SITE_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}
BUILT_VERSION: ${{ github.event_name == 'workflow_run' && 'main' || startsWith(inputs.ref, 'v') && inputs.ref || 'main' }}
run: |
python deployment-tools/docs/scripts/prepare_docs_deployment.py prepare \
--build-dir ./docs-site --site-url "${SITE_URL}" \
--version "${BUILT_VERSION}" --repository "${GITHUB_REPOSITORY}"

- name: Stamp Pages artifact
env:
DEPLOYMENT_ID: ${{ github.run_id }}-${{ github.run_attempt }}
Expand All @@ -126,6 +151,7 @@ jobs:
uses: actions/upload-pages-artifact@v3
with:
name: github-pages-${{ github.run_attempt }}
retention-days: 90
path: ./docs-site

- name: Deploy GitHub Pages
Expand All @@ -139,18 +165,9 @@ jobs:
EXPECTED_DEPLOYMENT_ID: ${{ github.run_id }}-${{ github.run_attempt }}
PAGE_URL: ${{ steps.deployment.outputs.page_url }}
run: |
for attempt in {1..30}; do
marker_url="${PAGE_URL%/}/pages-deployment-id.txt?run=${EXPECTED_DEPLOYMENT_ID}&attempt=${attempt}"
deployed_id="$(curl --location --connect-timeout 5 --max-time 10 --fail --silent --show-error "${marker_url}" || true)"
if [[ "${deployed_id}" == "${EXPECTED_DEPLOYMENT_ID}" ]]; then
echo "Verified Pages deployment ${EXPECTED_DEPLOYMENT_ID}."
exit 0
fi
echo "Waiting for Pages deployment ${EXPECTED_DEPLOYMENT_ID} (attempt ${attempt}/30)..."
sleep 10
done
echo "Pages did not serve deployment ${EXPECTED_DEPLOYMENT_ID}." >&2
exit 1
python deployment-tools/docs/scripts/prepare_docs_deployment.py verify \
--build-dir ./docs-site --site-url "${PAGE_URL}" \
--deployment-id "${EXPECTED_DEPLOYMENT_ID}"

build-and-deploy:
if: github.event_name == 'workflow_dispatch' && inputs.artifact_run_id == ''
Expand Down Expand Up @@ -269,6 +286,27 @@ jobs:
name: docs-site-manual
path: ./docs-site

# Deployment tooling comes from this workflow revision, not the release tag.
- uses: actions/checkout@v4
with:
ref: ${{ github.workflow_sha }}
path: deployment-tools
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install deployment dependencies
run: python -m pip install --index-url https://pypi.org/simple packaging

- name: Preserve currently published versions
env:
GH_TOKEN: ${{ github.token }}
SITE_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}
BUILT_VERSION: ${{ github.event_name == 'workflow_run' && 'main' || startsWith(inputs.ref, 'v') && inputs.ref || 'main' }}
run: |
python deployment-tools/docs/scripts/prepare_docs_deployment.py prepare \
--build-dir ./docs-site --site-url "${SITE_URL}" \
--version "${BUILT_VERSION}" --repository "${GITHUB_REPOSITORY}"

- name: Stamp Pages artifact
env:
DEPLOYMENT_ID: ${{ github.run_id }}-${{ github.run_attempt }}
Expand All @@ -278,6 +316,7 @@ jobs:
uses: actions/upload-pages-artifact@v3
with:
name: github-pages-${{ github.run_attempt }}
retention-days: 90
path: ./docs-site

- name: Deploy GitHub Pages
Expand All @@ -291,15 +330,6 @@ jobs:
EXPECTED_DEPLOYMENT_ID: ${{ github.run_id }}-${{ github.run_attempt }}
PAGE_URL: ${{ steps.deployment.outputs.page_url }}
run: |
for attempt in {1..30}; do
marker_url="${PAGE_URL%/}/pages-deployment-id.txt?run=${EXPECTED_DEPLOYMENT_ID}&attempt=${attempt}"
deployed_id="$(curl --location --connect-timeout 5 --max-time 10 --fail --silent --show-error "${marker_url}" || true)"
if [[ "${deployed_id}" == "${EXPECTED_DEPLOYMENT_ID}" ]]; then
echo "Verified Pages deployment ${EXPECTED_DEPLOYMENT_ID}."
exit 0
fi
echo "Waiting for Pages deployment ${EXPECTED_DEPLOYMENT_ID} (attempt ${attempt}/30)..."
sleep 10
done
echo "Pages did not serve deployment ${EXPECTED_DEPLOYMENT_ID}." >&2
exit 1
python deployment-tools/docs/scripts/prepare_docs_deployment.py verify \
--build-dir ./docs-site --site-url "${PAGE_URL}" \
--deployment-id "${EXPECTED_DEPLOYMENT_ID}"
9 changes: 9 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,12 @@ jobs:
container: *container_template
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
# Workflow contract tests execute JavaScript; the simulator image has no node.
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Preserve Node path for login shells
run: echo "CI_NODE_BIN_DIR=$(dirname "$(command -v node)")" >> "$GITHUB_ENV"
- uses: ./.github/actions/activate-conda
- name: Download test plan
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
Expand Down Expand Up @@ -390,6 +396,9 @@ jobs:
shell: bash -l {0}
run: |
source activate py311
# The image's login profile can reset the PATH set by setup-node.
export PATH="${CI_NODE_BIN_DIR}:${PATH}"
node --version
# Run GPU processes before the long DexSim lane. Native Vulkan/CUDA
# teardown can briefly retain allocations after the sim subprocess;
# this order prevents the GPU lane from inheriting that pressure.
Expand Down
46 changes: 42 additions & 4 deletions docs/scripts/merge_published_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
Expand All @@ -48,24 +49,35 @@ def load_versions_manifest(
*,
site_base_url: str | None = None,
published_root: Path | None = None,
required: bool = False,
) -> dict[str, Any] | None:
"""Load ``versions.json`` from a local tree or the live site URL."""
if published_root is not None:
manifest_path = published_root / "versions.json"
if not manifest_path.is_file():
if required:
raise FileNotFoundError(manifest_path)
return None
return json.loads(manifest_path.read_text(encoding="utf-8"))

if not site_base_url:
return None

manifest_url = f"{site_base_url.rstrip('/')}/versions.json"
manifest_url = f"{site_base_url.rstrip('/')}/versions.json?fresh={time.time_ns()}"
try:
with urlopen(manifest_url, timeout=30) as response:
if response.status != 200:
if required:
raise RuntimeError(
f"Cannot read published manifest: {response.status}"
)
return None
return json.loads(response.read().decode("utf-8"))
except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as exc:
if required:
raise RuntimeError(
f"Cannot read published manifest at {manifest_url}"
) from exc
print(f"No published manifest at {manifest_url}: {exc}", file=sys.stderr)
return None

Expand Down Expand Up @@ -165,7 +177,9 @@ def flatten_nested_version_dirs(build_dir: Path) -> list[tuple[Path, Path | None
return changes


def _download_version_wget(site_base_url: str, version: str, dest: Path) -> None:
def _download_version_wget(
site_base_url: str, version: str, dest: Path, *, required: bool = False
) -> None:
"""Download one version subtree with wget (available in CI containers)."""
url = f"{site_base_url.rstrip('/')}/{version}/"
dest.parent.mkdir(parents=True, exist_ok=True)
Expand All @@ -177,6 +191,8 @@ def _download_version_wget(site_base_url: str, version: str, dest: Path) -> None
[
"wget",
"-q",
"--timeout=30",
"--tries=2",
"-r",
"-l",
"50",
Expand All @@ -189,6 +205,10 @@ def _download_version_wget(site_base_url: str, version: str, dest: Path) -> None
check=False,
)
if result.returncode != 0:
if required:
raise RuntimeError(
f"Incomplete download of {url} (exit {result.returncode})"
)
print(f"wget failed for {url} (exit {result.returncode})", file=sys.stderr)

# wget may create dest.parent/<version>/ or preserve extra URL path
Expand Down Expand Up @@ -222,6 +242,8 @@ def merge_published_site(
site_base_url: str | None = None,
published_root: Path | None = None,
skip_versions: frozenset[str] | None = None,
refresh_versions: frozenset[str] = frozenset(),
required: bool = False,
) -> list[str]:
"""Copy missing version dirs from published site into ``build_dir``.

Expand All @@ -230,6 +252,8 @@ def merge_published_site(
site_base_url: Live Pages base, e.g. ``https://org.github.io/Repo``.
published_root: Local published tree for tests (``versions.json`` + dirs).
skip_versions: Version names to leave for a fresh build (e.g. ``main``).
refresh_versions: Existing versions to replace with their published tree.
required: Fail if the manifest or a published version homepage is unavailable.

Returns:
Names of versions merged from the published site.
Expand All @@ -246,6 +270,7 @@ def merge_published_site(
manifest = load_versions_manifest(
site_base_url=site_base_url,
published_root=published_root,
required=required,
)
if not manifest:
print("No published versions manifest; skipping merge.")
Expand All @@ -254,14 +279,23 @@ def merge_published_site(
merged: list[str] = []
for entry in manifest.get("versions", []):
name = entry.get("name")
if name and (Path(name).name != name or name in {".", ".."}):
raise ValueError(f"Invalid published version: {name!r}")
if not name or name in skip:
continue
if (build_dir / name).is_dir():
exists = (
(build_dir / name / "index.html").is_file()
if required
else (build_dir / name).is_dir()
)
if exists and name not in refresh_versions:
continue

if published_root is not None:
src = published_root / name
if not src.is_dir():
if required:
raise FileNotFoundError(src)
print(
f"Published root missing directory {name}; skip.", file=sys.stderr
)
Expand All @@ -271,14 +305,18 @@ def merge_published_site(
merged.append(name)
elif site_base_url:
print(f"Downloading published version: {name}")
_download_version_wget(site_base_url, name, build_dir / name)
_download_version_wget(
site_base_url, name, build_dir / name, required=required
)
if (build_dir / name).is_dir():
merged.append(name)
else:
print(
"Neither published_root nor site_base_url set; cannot merge.",
file=sys.stderr,
)
if required and not (build_dir / name / "index.html").is_file():
raise RuntimeError(f"Published version {name} has no downloaded homepage")

final_changes = normalize_artifact_paths(build_dir)
if final_changes:
Expand Down
Loading
Loading