refactor(docs): separate Pages site source (#6176)

* refactor(docs): separate Pages site source

* fix(docs): preserve published guide pages

* fix(ci): run asset ownership tests for site changes

* fix(docs): track future website media

* fix(ci): let Pages deployments finish

* build(deps): update Pages checkout action

* fix(ci): follow moved setup guide

* fix(docs): repair published setup guide

* fix(docs): retarget preview encoder
This commit is contained in:
RaresKeY
2026-08-27 10:20:36 +02:00
committed by GitHub
parent 7026cf40b5
commit c9dd68d890
34 changed files with 277 additions and 43 deletions
+2
View File
@@ -30,6 +30,8 @@ secrets.env~
.idea/
dev-docs/
docs/
website/
assets/branding/
*.md
*.db
*.sqlite
+1 -1
View File
@@ -6,4 +6,4 @@
# A per-area ownership map (security/auth, CI, frontend, agent internals, with
# multiple named owners per line) is being worked out in issue #593; once
# agreed it replaces this file. Until then, required reviews and the security
# CI gate (docs/security-ci.md) remain in force via branch protection.
# CI gate (website/security-ci.md) remain in force via branch protection.
+4 -3
View File
@@ -110,7 +110,7 @@ jobs:
fetch-depth: 0
persist-credentials: false
# Detect whether this PR only touches documentation files.
# Detect whether this PR only touches repository prose outside the Pages site.
# If so, skip the expensive pytest run while still reporting a passing check.
- name: Check for docs-only changes
id: docs-check
@@ -122,9 +122,10 @@ jobs:
BASE="${{ github.event.before }}"
HEAD="${{ github.sha }}"
fi
# List all changed files; if every file matches docs/markdown patterns, skip pytest.
# Keep website/ and assets/branding/ out of this bypass: pytest owns
# regression guards for their published-file and orphan-asset contracts.
changed=$(git diff --name-only "$BASE" "$HEAD" 2>/dev/null || git diff --name-only HEAD~1 HEAD)
non_docs=$(echo "$changed" | grep -Ev '^(docs/|.*\.md$|\.github/[^/]+\.md$)' || true)
non_docs=$(echo "$changed" | grep -Ev '^(docs/|[^/]+\.md$|\.github/[^/]+\.md$)' || true)
if [ -z "$non_docs" ]; then
echo "docs_only=true" >> "$GITHUB_OUTPUT"
echo "Docs-only change detected — skipping pytest."
+4
View File
@@ -23,12 +23,16 @@ on:
paths-ignore:
- '**.md'
- 'docs/**'
- 'website/**'
- 'assets/branding/**'
- '.github/ISSUE_TEMPLATE/**'
push:
branches: [main]
paths-ignore:
- '**.md'
- 'docs/**'
- 'website/**'
- 'assets/branding/**'
- '.github/ISSUE_TEMPLATE/**'
workflow_dispatch:
+50
View File
@@ -0,0 +1,50 @@
name: Deploy GitHub Pages
on:
push:
branches: [main]
paths:
- 'website/**'
- '.github/workflows/deploy-pages.yml'
workflow_dispatch:
permissions: {}
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
name: Package static site
runs-on: ubuntu-latest
permissions:
contents: read
pages: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- uses: actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697 # v1.0.13
with:
source: website
destination: _site
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: _site
deploy:
name: Deploy static site
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
+2
View File
@@ -14,6 +14,8 @@ on:
paths-ignore:
- '**.md'
- 'docs/**'
- 'website/**'
- 'assets/branding/**'
- '.github/ISSUE_TEMPLATE/**'
concurrency:
+18
View File
@@ -85,6 +85,24 @@ output.txt.txt
!docs/**/*.gif
!docs/**/*.webp
# …except shipped website and branding media.
!website/**/*.jpg
!website/**/*.jpeg
!website/**/*.png
!website/**/*.gif
!website/**/*.bmp
!website/**/*.webp
!website/**/*.tiff
!website/**/*.pdf
!assets/branding/**/*.jpg
!assets/branding/**/*.jpeg
!assets/branding/**/*.png
!assets/branding/**/*.gif
!assets/branding/**/*.bmp
!assets/branding/**/*.webp
!assets/branding/**/*.tiff
!assets/branding/**/*.pdf
# Reports and temp files
reports/
tasks/
+6 -6
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="docs/odysseus-wordmark.png" alt="Odysseus" width="238">
<img src="assets/branding/odysseus-wordmark.png" alt="Odysseus" width="238">
</p>
<p align="center">
@@ -8,7 +8,7 @@
<p align="center">
<a href="#quick-start">Quick Start</a> ·
<a href="docs/setup.md">Setup Guide</a> ·
<a href="website/setup.md">Setup Guide</a> ·
<a href="CONTRIBUTING.md">Contributing</a> ·
<a href="ROADMAP.md">Roadmap</a>
</p>
@@ -18,7 +18,7 @@
</p>
<p align="center">
<img src="docs/odysseus-browser.jpg" alt="Odysseus interface">
<img src="assets/branding/odysseus-browser.jpg" alt="Odysseus interface">
</p>
---
@@ -36,7 +36,7 @@ docker compose up -d --build
Open `http://localhost:7000` when the containers are healthy. The first admin password is printed in `docker compose logs odysseus`.
Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the [setup guide](docs/setup.md).
Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration live in the [setup guide](website/setup.md).
## Features
@@ -51,7 +51,7 @@ Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration
## Demo
A full hover-to-play tour lives on the landing page: [`docs/index.html`](docs/index.html).
A full hover-to-play tour lives on the [Odysseus landing page](https://odysseus-dev.github.io/odysseus/). Its source lives under [`website/`](website/).
## Contributing
@@ -64,7 +64,7 @@ Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled
- Keep `AUTH_ENABLED=true` for any network-accessible deployment.
- Keep `LOCALHOST_BYPASS=false` outside local development.
Deployment details are in the [setup guide](docs/setup.md#security-notes).
Deployment details are in the [setup guide](website/setup.md#security-notes).
## Star History

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 185 KiB

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

+4 -4
View File
@@ -27,13 +27,13 @@ echo " port: $PORT"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
# ── Icon (best effort) — center-crop docs/odysseus.jpg to a square .icns ──
if [ -f "$REPO_DIR/docs/odysseus.jpg" ] && command -v sips >/dev/null 2>&1; then
# ── Icon (best effort) — center-crop the branding image to a square .icns ──
if [ -f "$REPO_DIR/assets/branding/odysseus.jpg" ] && command -v sips >/dev/null 2>&1; then
TMPIMG="$(mktemp -d)"
# Center-crop to a square, scale to 512 (sips' icns encoder caps at 512), and
# let sips emit the .icns directly — more robust across macOS versions than
# building an .iconset by hand.
sips -c 720 720 "$REPO_DIR/docs/odysseus.jpg" --out "$TMPIMG/sq.png" >/dev/null 2>&1 || cp "$REPO_DIR/docs/odysseus.jpg" "$TMPIMG/sq.png"
sips -c 720 720 "$REPO_DIR/assets/branding/odysseus.jpg" --out "$TMPIMG/sq.png" >/dev/null 2>&1 || cp "$REPO_DIR/assets/branding/odysseus.jpg" "$TMPIMG/sq.png"
sips -z 512 512 "$TMPIMG/sq.png" --out "$TMPIMG/icon.png" >/dev/null 2>&1
if sips -s format icns "$TMPIMG/icon.png" --out "$APP/Contents/Resources/odysseus.icns" >/dev/null 2>&1; then
echo " icon: odysseus.icns"
@@ -42,7 +42,7 @@ if [ -f "$REPO_DIR/docs/odysseus.jpg" ] && command -v sips >/dev/null 2>&1; then
fi
rm -rf "$TMPIMG"
else
echo " icon: (skipped — no docs/odysseus.jpg)"
echo " icon: (skipped — no assets/branding/odysseus.jpg)"
fi
# ── Info.plist ──
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Encode a source screen-recording (.mkv) into web-optimized preview clips for
# the landing page: docs/<name>.webm (VP9) + docs/<name>.mp4 (H.264).
# the landing page: website/<name>.webm (VP9) + website/<name>.mp4 (H.264).
#
# ./encode_previews.sh <input> <name> [max_secs]
#
@@ -13,7 +13,7 @@ set -euo pipefail
IN="${1:?input file}"
NAME="${2:?output basename}"
MAX="${3:-30}"
OUT_DIR="$(cd "$(dirname "$0")/../docs" && pwd)"
OUT_DIR="$(cd "$(dirname "$0")/../website" && pwd)"
dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$IN" | cut -d. -f1)
dur=${dur:-0}
+137 -19
View File
@@ -1,30 +1,39 @@
"""Regression guard for issue #1335 — PR review screenshots were committed into
docs/ (docs/a11y/*.png from #738, docs/gallery-314-*.png from #644) where they
served no purpose: nothing in the repo referenced them, so they just showed up
as "random images" in the doc folder.
"""Repository asset ownership guards for issues #1335 and #6175.
This test fails if any image under docs/ is orphaned — present in the tree but
referenced by no tracked text file. The intended doc assets (the README hero
image and the feature preview clips) are referenced, so they pass; a stray
screenshot dropped in by a future PR would not.
Public Markdown and landing-page media belong in website/, while shared
README/packaging imagery belongs in assets/branding/. Images in either managed
root must be referenced by tracked text, and every tracked website video must
be referenced by the site's entry point.
"""
import re
import subprocess
from pathlib import Path
from urllib.parse import urlsplit
import pytest
REPO = Path(__file__).resolve().parent.parent
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
VIDEO_EXTS = {".webm", ".mp4", ".mov", ".m4v"}
PUBLIC_GUIDES = {
"agent-migration.md",
"attachments.md",
"backup-restore.md",
"email-outlook.md",
"pr-blocker-audit.md",
"security-ci.md",
"setup.md",
}
# Files a referenced image name could legitimately appear in.
TEXT_EXTS = {".md", ".html", ".htm", ".js", ".ts", ".css", ".py", ".sh",
".json", ".yml", ".yaml", ".txt"}
def _tracked(paths_under):
"""Git-tracked files under a path, or None if git isn't available."""
def _tracked(*paths_under):
"""Git-tracked files under paths, or None if git isn't available."""
try:
out = subprocess.run(
["git", "ls-files", paths_under],
["git", "ls-files", "--", *paths_under],
cwd=REPO, capture_output=True, text=True, timeout=30,
)
except (OSError, subprocess.SubprocessError):
@@ -34,12 +43,14 @@ def _tracked(paths_under):
return [REPO / line for line in out.stdout.splitlines() if line.strip()]
def test_no_orphan_images_in_docs():
docs_images = _tracked("docs")
if docs_images is None:
def test_no_orphan_documentation_or_branding_images():
managed_files = _tracked("website", "assets/branding")
if managed_files is None:
pytest.skip("not a git checkout")
docs_images = [p for p in docs_images if p.suffix.lower() in IMAGE_EXTS]
assert docs_images, "expected docs/ to still contain referenced doc assets"
managed_images = [p for p in managed_files if p.suffix.lower() in IMAGE_EXTS]
assert any("assets/branding" in p.as_posix() for p in managed_images), (
"expected assets/branding/ to contain the shared project imagery"
)
# All tracked text we might reference an image from.
all_tracked = _tracked(".") or []
@@ -55,10 +66,117 @@ def test_no_orphan_images_in_docs():
orphans = [
str(img.relative_to(REPO))
for img in docs_images
for img in managed_images
if img.name not in blob
]
assert not orphans, (
"unreferenced image(s) committed under docs/ — likely PR screenshots "
f"added by accident (see #1335): {orphans}"
"unreferenced image(s) committed under website/ or assets/branding/ "
f"(see #1335 and #6175): {orphans}"
)
def test_pages_site_owns_its_entrypoint_and_media():
docs_files = _tracked("docs")
website_files = _tracked("website")
if docs_files is None or website_files is None:
pytest.skip("not a git checkout")
assert REPO / "website/index.html" in website_files
assert REPO / "docs/index.html" not in docs_files
assert not [p for p in docs_files if p.suffix.lower() in VIDEO_EXTS | {".md"}]
website_paths = {p.relative_to(REPO / "website").as_posix() for p in website_files}
assert PUBLIC_GUIDES <= website_paths
for guide in PUBLIC_GUIDES:
text = (REPO / "website" / guide).read_text(encoding="utf-8")
assert text.startswith("---\nlayout: default\n---\n"), guide
website_videos = [p for p in website_files if p.suffix.lower() in VIDEO_EXTS]
assert website_videos, "expected website/ to contain the landing-page videos"
entrypoint = (REPO / "website/index.html").read_text(encoding="utf-8")
unreferenced = [
str(video.relative_to(REPO))
for video in website_videos
if video.name not in entrypoint
]
assert not unreferenced, f"unreferenced website video(s): {unreferenced}"
workflow = (REPO / ".github/workflows/deploy-pages.yml").read_text(encoding="utf-8")
assert "actions/jekyll-build-pages@" in workflow
assert "source: website" in workflow
assert "destination: _site" in workflow
assert "path: _site" in workflow
assert "cancel-in-progress: false" in workflow
def test_pages_guides_keep_relative_links_inside_site():
site_root = (REPO / "website").resolve()
for guide in sorted(PUBLIC_GUIDES):
guide_path = REPO / "website" / guide
text = guide_path.read_text(encoding="utf-8")
for target in re.findall(r"\]\(([^)]+)\)", text):
parsed = urlsplit(target)
if parsed.scheme or parsed.netloc or not parsed.path:
continue
resolved = (guide_path.parent / parsed.path).resolve()
assert resolved.is_relative_to(site_root), (
f"{guide} links outside the Pages source: {target}"
)
assert resolved.exists(), f"{guide} has a missing local link: {target}"
def test_setup_preserves_docker_go_template_literal():
setup = (REPO / "website/setup.md").read_text(encoding="utf-8")
guarded_command = """<!-- {% raw %} -->
```bash
docker info --format '{{.DockerRootDir}}'
```
<!-- {% endraw %} -->"""
assert guarded_command in setup
def test_preview_encoder_targets_pages_source():
encoder = (REPO / "scripts/encode_previews.sh").read_text(encoding="utf-8")
assert "landing page: website/<name>.webm" in encoder
assert 'OUT_DIR="$(cd "$(dirname "$0")/../website" && pwd)"' in encoder
def test_ci_runs_asset_ownership_guards_for_managed_roots():
workflow = (REPO / ".github/workflows/ci.yml").read_text(encoding="utf-8")
match = re.search(r"grep -Ev '([^']+)'", workflow)
assert match, "expected the docs-only path classifier in CI"
docs_only = re.compile(match.group(1))
assert docs_only.match("README.md")
assert docs_only.match("docs/example.md")
assert not docs_only.match("website/setup.md")
assert not docs_only.match("website/new-preview.webm")
assert not docs_only.match("assets/branding/new-logo.png")
@pytest.mark.parametrize(
"path",
[
"website/favicon.png",
"website/media/social-card.jpg",
"website/guides/reference.pdf",
"assets/branding/new-logo.gif",
"assets/branding/print/logo.tiff",
],
)
def test_managed_site_media_is_not_ignored(path):
result = subprocess.run(
["git", "check-ignore", "--no-index", "--quiet", path],
cwd=REPO,
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 128:
pytest.skip("not a git checkout")
assert result.returncode == 1, f"{path} is unexpectedly ignored"
+1 -1
View File
@@ -30,7 +30,7 @@ def test_kokoro_feature_markers_match_supported_python_range(python_version, sel
def test_setup_documents_container_constraint_and_install_command():
setup = (ROOT / "docs" / "setup.md").read_text(encoding="utf-8")
setup = (ROOT / "website" / "setup.md").read_text(encoding="utf-8")
assert "pip install -r requirements-optional.txt" in setup
assert "default Docker image currently uses Python 3.14" in setup
+2 -2
View File
@@ -123,10 +123,10 @@ def test_docker_compose_binds_web_ui_to_loopback_by_default():
def test_readme_native_quickstart_uses_loopback():
# The README refresh (#4306) moved the native quickstart into docs/setup.md,
# The Pages source split (#6175) moved the native quickstart into website/setup.md,
# so accept the loopback guidance from either the README or the setup guide.
docs = Path("README.md").read_text(encoding="utf-8")
docs += "\n" + Path("docs/setup.md").read_text(encoding="utf-8")
docs += "\n" + Path("website/setup.md").read_text(encoding="utf-8")
assert "python -m uvicorn app:app --host 127.0.0.1 --port 7000" in docs
assert "0.0.0.0` only when you intentionally want" in docs
+1 -1
View File
@@ -29,7 +29,7 @@ def test_create_default_admin_normalizes_env_username(tmp_path, monkeypatch):
def test_main_loads_admin_password_from_env_file(tmp_path, monkeypatch):
"""Regression: setup.py must honor an admin password pre-seeded in .env on
native installs, even when the var is not exported into the shell
(docs/setup.md documents this). Previously setup.py never called
(website/setup.md documents this). Previously setup.py never called
load_dotenv(), so os.getenv() saw nothing and a random password was
generated instead."""
import bcrypt
+10
View File
@@ -0,0 +1,10 @@
title: Odysseus
description: A self-hosted AI workspace for chat, agents, tools, and local models.
theme: jekyll-theme-primer
plugins:
- jekyll-relative-links
relative_links:
enabled: true
collections: true
@@ -1,3 +1,7 @@
---
layout: default
---
# Agent migration manifests
Odysseus should be able to learn from another agent without blindly trusting
@@ -1,3 +1,7 @@
---
layout: default
---
# Attachment References and Upload Storage
Odysseus stores uploaded bytes once under the configured upload directory and
@@ -1,3 +1,7 @@
---
layout: default
---
# Backup & Restore
Odysseus keeps all of your state in the `data/` directory — the SQLite database
View File
@@ -1,3 +1,7 @@
---
layout: default
---
# Outlook / Office 365 email accounts
Odysseus email accounts currently use IMAP and SMTP with username/password
@@ -1,3 +1,7 @@
---
layout: default
---
# PR Blocker Audit
`scripts/pr_blocker_audit.py` is a small, read-only triage helper for maintainers who need to inspect open pull request overlap before reviewing or starting related work.
@@ -1,3 +1,7 @@
---
layout: default
---
# Security CI guide
This project runs a set of automated security checks on pull requests and
+9 -4
View File
@@ -1,3 +1,7 @@
---
layout: default
---
# Odysseus Setup Guide
This page keeps the detailed install, deployment, troubleshooting, and configuration notes out of the front README.
@@ -15,8 +19,7 @@ On first setup, Odysseus creates an admin account (`admin` unless
For Docker installs, the same line is in `docker compose logs odysseus`.
Use that for the first login, then change it in **Settings**.
Contributing? See [CONTRIBUTING.md](../CONTRIBUTING.md) for setup, testing, and
pull request guidelines.
Contributing? See [CONTRIBUTING.md](https://github.com/odysseus-dev/odysseus/blob/dev/CONTRIBUTING.md) for setup, testing, and pull request guidelines.
### Docker (recommended)
```bash
@@ -205,9 +208,11 @@ failed to fulfil mount request: open /usr/lib/wsl/lib/libdxcore.so: no such file
Check with `snap list docker` or:
<!-- {% raw %} -->
```bash
docker info --format '{{.DockerRootDir}}'
```
<!-- {% endraw %} -->
A Docker root under `/var/snap/docker/` means snap confinement can prevent
Docker from seeing WSL2's `/usr/lib/wsl/lib` GPU libraries even when the files
@@ -476,7 +481,7 @@ uv pip sync requirements.lock # reproduce it exactly la
### Outlook / Office 365 email
Odysseus email accounts currently use IMAP/SMTP username-password auth. Outlook
and Microsoft 365 generally require OAuth instead, so normal Microsoft mailbox
passwords will fail. See [docs/email-outlook.md](docs/email-outlook.md) for the
passwords will fail. See [the Outlook email guide](email-outlook.md) for the
current limitation and the planned integration direction.
## Security Notes
@@ -733,7 +738,7 @@ src/ llm_core, agent_loop, agent_tools, chat_processor, search/
routes/ chat, session, document, memory, model … endpoints
services/ docs, memory, search, hwfit (Cookbook) …
static/ index.html + app.js + style.css + js/ (modular front-end)
docs/ landing page (index.html) + preview clips
website/ landing page (index.html) + preview clips
```
## Data