diff --git a/.github/workflows/docs-preview-deploy.yml b/.github/workflows/docs-preview-deploy.yml index 4c0b3599..2420f368 100644 --- a/.github/workflows/docs-preview-deploy.yml +++ b/.github/workflows/docs-preview-deploy.yml @@ -109,7 +109,7 @@ jobs: const docsChanged = files.some( ({ filename }) => filename.startsWith('docs/') || filename.startsWith('overrides/') || - /^projects\/[^/]+\/docs\//.test(filename) || + /^projects\/[^/]+\/(docs|assets)\//.test(filename) || exactInputs.has(filename), ); operation = docsChanged ? 'deploy' : 'remove'; diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index cc1b03e9..695497dd 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -57,7 +57,7 @@ jobs: const docsChanged = files.some( ({ filename }) => filename.startsWith('docs/') || filename.startsWith('overrides/') || - /^projects\/[^/]+\/docs\//.test(filename) || + /^projects\/[^/]+\/(docs|assets)\//.test(filename) || exactInputs.has(filename), ); core.setOutput('operation', docsChanged ? 'deploy' : 'remove'); diff --git a/docs/development/index.md b/docs/development/index.md index a5c99ec4..fe48f766 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -73,6 +73,11 @@ Commit any generated changes with the source change. ## Theme and brand assets +Project logos shared by a README and the site belong in `projects//assets/`. +Register that directory in `PROJECT_ASSETS` in `scripts/stage-project-docs.py`; +the build copies it into the staged documentation's `assets/` directory. +README image links should be relative so they work on feature branches. + Keep shared brand assets in `docs/assets/brand/`. Use the compact SVG mark for `project.theme.logo`, `favicon.svg` for the browser icon, and the light and dark PNG banners for full OpenShell Research lockups. Never reference files from a diff --git a/docs/stylesheets/documentation.css b/docs/stylesheets/documentation.css index aa3da39e..c3c5ec81 100644 --- a/docs/stylesheets/documentation.css +++ b/docs/stylesheets/documentation.css @@ -1,6 +1,19 @@ /* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. */ /* SPDX-License-Identifier: Apache-2.0 */ +.md-typeset .documentation-logo { + display: block; + width: 540px; + max-width: 100%; + height: auto; + margin: 1.5rem auto 2rem; + color-scheme: light; +} + +body[data-md-color-scheme="slate"] .md-typeset .documentation-logo { + color-scheme: dark; +} + /* Documentation figures use one quiet visual treatment across light and dark pages. */ .md-typeset .documentation-figure { width: 100%; diff --git a/projects/openshell-agent-runner/README.md b/projects/openshell-agent-runner/README.md index 3ef1ecf9..940093c3 100644 --- a/projects/openshell-agent-runner/README.md +++ b/projects/openshell-agent-runner/README.md @@ -1,5 +1,9 @@ # OpenShell Agent Runner +

+ OAR — OpenShell Agent Runner +

+ OpenShell Agent Runner (OAR) launches ephemeral [Pi coding agents](https://github.com/earendil-works/pi/tree/main/packages/coding-agent) in OpenShell sandboxes. Each agent works on a task using the prompts, skills, diff --git a/projects/openshell-agent-runner/assets/oar-logo.svg b/projects/openshell-agent-runner/assets/oar-logo.svg new file mode 100644 index 00000000..a100a91d --- /dev/null +++ b/projects/openshell-agent-runner/assets/oar-logo.svg @@ -0,0 +1,25 @@ + + OAR — OpenShell Agent Runner + An oar forms the left stroke of the A, with a terminal prompt cut out of its blade and two water ripples. NVIDIA green accents remain constant; lettering adapts to light and dark color schemes. + + + + + + + + + + + + + diff --git a/projects/openshell-agent-runner/docs/index.md b/projects/openshell-agent-runner/docs/index.md index 041750d0..ef104091 100644 --- a/projects/openshell-agent-runner/docs/index.md +++ b/projects/openshell-agent-runner/docs/index.md @@ -6,6 +6,8 @@ agent_markdown: true # OpenShell Agent Runner + + OpenShell Agent Runner (OAR) launches ephemeral [Pi coding agents](https://github.com/earendil-works/pi/tree/main/packages/coding-agent) in OpenShell sandboxes. Each agent works on a task using the prompts, skills, diff --git a/projects/openshell-agent-runner/pyproject.toml b/projects/openshell-agent-runner/pyproject.toml index 0762d7a5..e3b48ea4 100644 --- a/projects/openshell-agent-runner/pyproject.toml +++ b/projects/openshell-agent-runner/pyproject.toml @@ -59,6 +59,7 @@ packages = ["src/openshell_agent_runner"] [tool.hatch.build.targets.sdist] include = [ + "/assets", "/src/openshell_agent_runner", "/LICENSE", "/README.md", diff --git a/scripts/stage-project-docs.py b/scripts/stage-project-docs.py index 5fd5cd11..df686a07 100644 --- a/scripts/stage-project-docs.py +++ b/scripts/stage-project-docs.py @@ -14,15 +14,17 @@ ROOT = Path(__file__).resolve().parents[1] PROJECT_DOCUMENTATION = { "egress-gate": ROOT / "projects" / "egress-gate" / "docs", - "openshell-agent-runner": ROOT - / "projects" - / "openshell-agent-runner" - / "docs", + "openshell-agent-runner": ROOT / "projects" / "openshell-agent-runner" / "docs", } DOCUMENTATION_ROOT = ROOT / "docs" / "documentation" +PROJECT_ASSETS = { + "openshell-agent-runner": ROOT / "projects" / "openshell-agent-runner" / "assets", +} -def stage_project_docs(source: Path, destination: Path) -> None: +def stage_project_docs( + source: Path, destination: Path, assets: Path | None = None +) -> None: """Replace one generated site mirror with its canonical project-docs tree.""" source = source.resolve() @@ -31,7 +33,9 @@ def stage_project_docs(source: Path, destination: Path) -> None: if not source.is_dir(): raise ValueError(f"documentation source does not exist: {source}") if destination_is_symlink: - raise ValueError(f"documentation destination must not be a symlink: {destination}") + raise ValueError( + f"documentation destination must not be a symlink: {destination}" + ) if source.is_relative_to(destination) or destination.is_relative_to(source): raise ValueError("documentation source and destination must not overlap") @@ -40,11 +44,27 @@ def stage_project_docs(source: Path, destination: Path) -> None: paths = "\n".join(f" - {path}" for path in symlinks) raise ValueError(f"project documentation must not contain symlinks:\n{paths}") + if assets is not None: + if assets.is_symlink() or any(path.is_symlink() for path in assets.rglob("*")): + raise ValueError("project assets must not contain symlinks") + if not assets.is_dir(): + raise ValueError(f"project assets directory does not exist: {assets}") + for path in assets.rglob("*"): + if ( + path.is_file() + and (source / "assets" / path.relative_to(assets)).exists() + ): + raise ValueError( + f"project asset conflicts with documentation asset: {path}" + ) + destination.parent.mkdir(parents=True, exist_ok=True) staging = destination.with_name(f".{destination.name}.staging") if staging.exists(): shutil.rmtree(staging) shutil.copytree(source, staging) + if assets is not None: + shutil.copytree(assets, staging / "assets", dirs_exist_ok=True) if destination.exists(): shutil.rmtree(destination) @@ -53,7 +73,9 @@ def stage_project_docs(source: Path, destination: Path) -> None: def main() -> int: for project, source in PROJECT_DOCUMENTATION.items(): - stage_project_docs(source, DOCUMENTATION_ROOT / project) + stage_project_docs( + source, DOCUMENTATION_ROOT / project, PROJECT_ASSETS.get(project) + ) print(f"Staged {project} documentation from {source}.") return 0 diff --git a/tests/test_stage_project_docs.py b/tests/test_stage_project_docs.py index fbca7017..2ceb75e6 100644 --- a/tests/test_stage_project_docs.py +++ b/tests/test_stage_project_docs.py @@ -81,5 +81,38 @@ def test_stage_rejects_source_inside_destination(self) -> None: STAGER.stage_project_docs(source, destination) +def test_stage_includes_project_assets_without_changing_canonical_docs(tmp_path): + source = tmp_path / "project" / "docs" + assets = tmp_path / "project" / "assets" + destination = tmp_path / "published" + (source / "assets").mkdir(parents=True) + assets.mkdir() + (source / "index.md").write_text('') + (source / "assets" / "diagram.svg").write_text("") + (assets / "logo.svg").write_text('') + + STAGER.stage_project_docs(source, destination, assets) + + assert (destination / "assets" / "logo.svg").read_bytes() == ( + assets / "logo.svg" + ).read_bytes() + assert (destination / "assets" / "diagram.svg").is_file() + assert not (source / "assets" / "logo.svg").exists() + + +def test_stage_rejects_conflicting_project_assets(tmp_path): + import pytest + + source = tmp_path / "docs" + assets = tmp_path / "assets" + (source / "assets").mkdir(parents=True) + assets.mkdir() + (source / "assets" / "logo.svg").write_text("") + (assets / "logo.svg").write_text("") + + with pytest.raises(ValueError, match="conflicts"): + STAGER.stage_project_docs(source, tmp_path / "published", assets) + + if __name__ == "__main__": unittest.main()