name: Deploy Site on: release: types: [published] push: branches: [main] paths: - 'website/**' - 'skills/**' - 'optional-skills/**' - '.github/workflows/deploy-site.yml' workflow_dispatch: inputs: skills_index_run_id: description: 'Optional Build Skills Index run ID whose skills-index artifact should be deployed' required: false type: string rebuild_skills_index: description: 'Force a fresh multi-source crawl instead of reusing the latest healthy index' required: false default: false type: boolean permissions: contents: read actions: read pages: write id-token: write concurrency: group: pages cancel-in-progress: false jobs: deploy-vercel: # Triggered automatically on release publish (production cuts) and # manually via `gh workflow run deploy-site.yml` when an out-of-band # main commit needs to ship live before the next release tag — e.g. # a skills-index PR that doesn't touch website/** paths and so # doesn't auto-deploy via the deploy-docs path. if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: Trigger Vercel Deploy run: curl -fsS --retry 3 --retry-delay 10 -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}" deploy-docs: if: github.repository == 'NousResearch/hermes-agent' runs-on: ubuntu-latest timeout-minutes: 30 environment: name: github-pages url: ${{ steps.deploy.outputs.page_url }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Get GitHub App token id: app-token uses: ./.github/actions/get-app-token with: client-id: ${{ vars.APP_CLIENT_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 26 cache: npm cache-dependency-path: website/package-lock.json - name: grab npm 12 run: | npm i -g npm@12 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.11' - name: Install PyYAML for skill extraction uses: ./.github/actions/retry with: command: pip install pyyaml==6.0.2 httpx==0.28.1 - name: Prepare skills index (unified multi-source catalog) env: GH_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }} REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }} run: | # The unified external catalog is expensive to crawl and can burn # through the repository installation's GitHub API quota when several # docs deploys land close together. Normal docs deploys therefore # reuse the latest healthy catalog: first the artifact from a # scheduled skills-index run, then the currently live index. Only a # manual force rebuild does a fresh crawl here. # # If we do crawl, the build remains fatal. build_skills_index.py runs # the health check BEFORE writing and exits non-zero on source # collapse, keeping the last good Pages deployment live instead of # publishing a degenerate catalog. set -euo pipefail INDEX_PATH="website/static/api/skills-index.json" mkdir -p "$(dirname "$INDEX_PATH")" validate_index() { python3 - "$INDEX_PATH" <<'PY' import json import sys from pathlib import Path path = Path(sys.argv[1]) try: data = json.loads(path.read_text(encoding="utf-8")) except Exception as exc: print(f"invalid skills index JSON: {exc}", file=sys.stderr) sys.exit(1) skills = data.get("skills") if not isinstance(skills, list) or len(skills) < 1500: count = len(skills) if isinstance(skills, list) else "missing" print(f"skills index too small: {count}", file=sys.stderr) sys.exit(1) print(f"skills index ready: {len(skills)} skills") PY } if [ "$REBUILD_SKILLS_INDEX" = "true" ]; then python3 scripts/build_skills_index.py validate_index exit 0 fi if [ -n "$SKILLS_INDEX_RUN_ID" ]; then tmpdir="$(mktemp -d)" echo "Downloading skills-index artifact from run $SKILLS_INDEX_RUN_ID" if gh run download "$SKILLS_INDEX_RUN_ID" --name skills-index --dir "$tmpdir"; then candidate="$(find "$tmpdir" -name skills-index.json -type f | head -n 1 || true)" if [ -n "$candidate" ]; then cp "$candidate" "$INDEX_PATH" if validate_index; then exit 0 fi fi fi echo "::warning::Could not use skills-index artifact from run $SKILLS_INDEX_RUN_ID; trying live index" fi echo "Downloading currently live skills index" if curl -fsSL --retry 3 --retry-delay 5 \ "https://hermes-agent.nousresearch.com/docs/api/skills-index.json" \ -o "$INDEX_PATH" && validate_index; then exit 0 fi echo "::warning::Live skills index unavailable or unhealthy; falling back to a fresh crawl" rm -f "$INDEX_PATH" python3 scripts/build_skills_index.py validate_index - name: Extract skill metadata for dashboard run: python3 website/scripts/extract-skills.py - name: Regenerate per-skill docs pages + catalogs run: python3 website/scripts/generate-skill-docs.py - name: Install dependencies uses: ./.github/actions/retry with: command: npm ci working-directory: website - name: Build Docusaurus run: npm run build working-directory: website - name: Stage deployment run: | mkdir -p _site/docs cp -r website/build/* _site/docs/ # llms.txt / llms-full.txt are also published at the site root # (https://hermes-agent.nousresearch.com/llms.txt) because some # agents and IDE plugins probe the classic root-level path rather # than /docs/llms.txt. Same file, two URLs, one source of truth. if [ -f website/build/llms.txt ]; then cp website/build/llms.txt _site/llms.txt fi if [ -f website/build/llms-full.txt ]; then cp website/build/llms-full.txt _site/llms-full.txt fi # Pages serves exactly the newest artifact, so each deploy used to delete # the previous build's content-hashed JS/CSS while edge caches (Vercel → # Fastly, max-age=300 + stale-while-revalidate=3600) kept serving HTML # that referenced it — every asset request 404'd for up to ~65 minutes # after each deploy. With push-triggered deploys landing every ~15-30 # minutes, the docs were in that broken window most of the day (search, # being pure client JS, died first). Fix: keep a rolling pool of prior # builds' hashed assets and union-merge it into every artifact so stale # HTML keeps resolving. Hashed filenames are content-addressed, so a # collision is by definition the identical file — the merge never # overwrites current-build output (cp --update=none). - name: Restore asset retention pool uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: _asset_retention key: docs-asset-retention-${{ github.run_id }} restore-keys: | docs-asset-retention- - name: Merge retained assets from previous deploys run: | set -euo pipefail ASSET_DIRS="assets zh-Hans/assets" mkdir -p _asset_retention # 1) Add this build's hashed assets to the pool (fresh mtimes, so # assets still shipped by current builds never age out). for d in $ASSET_DIRS; do if [ -d "_site/docs/$d" ]; then mkdir -p "_asset_retention/$d" cp -a "_site/docs/$d/." "_asset_retention/$d/" fi done # 2) Drop pool entries no build has produced for 14 days — far # beyond any edge-cache or open-tab horizon. find _asset_retention -type f -mtime +14 -delete find _asset_retention -type d -empty -delete # 3) Union-merge the pool into the artifact; --update=none keeps the # current build authoritative for any path it produced. for d in $ASSET_DIRS; do if [ -d "_asset_retention/$d" ]; then mkdir -p "_site/docs/$d" cp -R --update=none "_asset_retention/$d/." "_site/docs/$d/" fi done echo "retention pool:" && du -sh _asset_retention echo "deployed assets:" && du -sh _site/docs/assets - name: Save asset retention pool # Always save under a fresh key (caches are immutable); restore-keys # prefix matching picks the newest on the next run. uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: _asset_retention key: docs-asset-retention-${{ github.run_id }} - name: Upload artifact uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 with: path: _site - name: Deploy to GitHub Pages id: deploy uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4