Render and deploy Quarto docs #299
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Render and deploy Quarto docs | |
| on: | |
| push: | |
| branches: [main] | |
| paths: | |
| - "docs/quarto/**" | |
| - ".github/workflows/docs.yml" | |
| pull_request: | |
| branches: [main] | |
| paths: | |
| - "docs/quarto/**" | |
| - ".github/workflows/docs.yml" | |
| schedule: | |
| - cron: "*/5 * * * *" | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| pages: write | |
| id-token: write | |
| # Only one deployment at a time; keep in-progress runs to avoid partial churn. | |
| concurrency: | |
| group: "pages-multi-monitor" | |
| cancel-in-progress: false | |
| jobs: | |
| pr_render: | |
| name: Render docs for PR | |
| if: github.event_name == 'pull_request' | |
| runs-on: ubuntu-latest | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Install Quarto | |
| uses: quarto-dev/quarto-actions/setup@v2 | |
| with: | |
| version: "1.6.39" | |
| - name: Render site | |
| working-directory: docs/quarto | |
| run: quarto render | |
| prepare_monitor_matrix: | |
| name: Prepare monitor matrix | |
| if: github.event_name != 'pull_request' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| matrix: ${{ steps.prepare.outputs.matrix }} | |
| enabled_slugs: ${{ steps.prepare.outputs.enabled_slugs }} | |
| all_slugs: ${{ steps.prepare.outputs.all_slugs }} | |
| env: | |
| H17_ENABLED: ${{ vars.MONITOR_H17_ENABLED }} | |
| steps: | |
| - name: Build enabled monitor matrix | |
| id: prepare | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| python - <<'PY' | |
| import json | |
| import os | |
| from pathlib import Path | |
| def as_bool(value: str) -> bool: | |
| return str(value).strip().lower() == "true" | |
| monitors = [ | |
| {"monitor_slug": "dsacamera", "runner_label": "dsacamera", "source_root": "/data/incoming"}, | |
| ] | |
| if as_bool(os.getenv("H17_ENABLED", "false")): | |
| monitors.append({"monitor_slug": "h17", "runner_label": "h17", "source_root": "/data/incoming"}) | |
| matrix = {"include": monitors} | |
| enabled_slugs = ",".join(m["monitor_slug"] for m in monitors) | |
| all_slugs = "dsacamera,h17" | |
| out = Path(os.environ["GITHUB_OUTPUT"]) | |
| with out.open("a", encoding="utf-8") as f: | |
| f.write(f"matrix={json.dumps(matrix)}\n") | |
| f.write(f"enabled_slugs={enabled_slugs}\n") | |
| f.write(f"all_slugs={all_slugs}\n") | |
| PY | |
| build_monitors: | |
| name: Build ${{ matrix.monitor_slug }} monitor | |
| runs-on: [self-hosted, linux, "${{ matrix.runner_label }}"] | |
| needs: prepare_monitor_matrix | |
| if: github.event_name != 'pull_request' | |
| strategy: | |
| fail-fast: false | |
| matrix: ${{ fromJson(needs.prepare_monitor_matrix.outputs.matrix) }} | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| MONITOR_OUT_DIR: .monitor_site | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.11" | |
| - name: Install dsacamera-monitor scanner | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| python -m pip install ./tools/dsacamera-monitor | |
| - name: Build monitor output | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| rm -rf "${MONITOR_OUT_DIR}" | |
| mkdir -p "${MONITOR_OUT_DIR}" | |
| dsacamera-incoming-scan --root "${{ matrix.source_root }}" --out "${MONITOR_OUT_DIR}" --pointing-timeseries --pointing-timeseries-max-files 5000 | |
| - name: Validate monitor artifact contract | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| [[ -f "${MONITOR_OUT_DIR}/index.html" ]] || { echo "Missing index.html"; exit 1; } | |
| [[ -f "${MONITOR_OUT_DIR}/manifest.json" ]] || { echo "Missing manifest.json"; exit 1; } | |
| [[ -d "${MONITOR_OUT_DIR}/css" ]] || { echo "Missing css/"; exit 1; } | |
| [[ -d "${MONITOR_OUT_DIR}/js" ]] || { echo "Missing js/"; exit 1; } | |
| python - <<'PY' | |
| import json | |
| from pathlib import Path | |
| manifest_path = Path(".monitor_site/manifest.json") | |
| manifest = json.loads(manifest_path.read_text(encoding="utf-8")) | |
| required = { | |
| "schema_version", | |
| "generated_at", | |
| "source_root", | |
| "totals", | |
| "by_day", | |
| "by_beam", | |
| "gaps", | |
| "freshness", | |
| "options", | |
| } | |
| missing = sorted(required - set(manifest.keys())) | |
| if missing: | |
| raise SystemExit(f"manifest missing keys: {missing}") | |
| if not isinstance(manifest.get("schema_version"), int): | |
| raise SystemExit("manifest schema_version must be int") | |
| if not isinstance(manifest.get("by_day"), list): | |
| raise SystemExit("manifest by_day must be list") | |
| if manifest.get("options", {}).get("no_stat", True): | |
| raise SystemExit("manifest options.no_stat must be false for KPI size/mtime support") | |
| PY | |
| - name: Upload monitor artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: monitor-${{ matrix.monitor_slug }} | |
| path: ${{ env.MONITOR_OUT_DIR }} | |
| # MONITOR_OUT_DIR is a dot-prefixed path (.monitor_site); upload-artifact@v4 | |
| # treats dot-prefixed paths as hidden and skips them by default, yielding an | |
| # empty artifact and a silent "No files were found" warning. Include hidden | |
| # files so the whole output tree ships. | |
| include-hidden-files: true | |
| aggregate_site: | |
| name: Render docs and aggregate monitors | |
| needs: [prepare_monitor_matrix, build_monitors] | |
| # Run whenever the matrix prepared successfully, even if some per-host legs | |
| # failed. Failed legs render a placeholder so the rest of the site still | |
| # publishes -- one bad host should not take down the whole inventory page. | |
| if: always() && needs.prepare_monitor_matrix.result == 'success' && github.event_name != 'pull_request' | |
| runs-on: ubuntu-latest | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| SITE_OUT_DIR: docs/quarto/_site | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Install Quarto | |
| uses: quarto-dev/quarto-actions/setup@v2 | |
| with: | |
| version: "1.6.39" | |
| - name: Render site | |
| working-directory: docs/quarto | |
| run: quarto render | |
| - name: Download monitor artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| pattern: monitor-* | |
| path: .monitor_artifacts | |
| merge-multiple: false | |
| - name: Mount monitor artifacts under _site | |
| env: | |
| ENABLED_SLUGS: ${{ needs.prepare_monitor_matrix.outputs.enabled_slugs }} | |
| ALL_SLUGS: ${{ needs.prepare_monitor_matrix.outputs.all_slugs }} | |
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| for slug in ${ALL_SLUGS//,/ }; do | |
| src=".monitor_artifacts/monitor-${slug}" | |
| mkdir -p "${SITE_OUT_DIR}/${slug}-live-monitor" | |
| if [[ -f "${src}/index.html" && -f "${src}/manifest.json" ]]; then | |
| cp -r "${src}/." "${SITE_OUT_DIR}/${slug}-live-monitor/" | |
| elif [[ ",${ENABLED_SLUGS}," == *",${slug},"* ]]; then | |
| # Enabled slug but no artifact: the per-host build leg failed. | |
| # Emit a placeholder instead of hard-failing so the rest of the | |
| # site still deploys; flag the failure via a workflow warning so | |
| # it surfaces in the run summary. | |
| echo "::warning title=Monitor leg failed::No artifact for enabled slug '${slug}'; rendering placeholder. See build_monitors job for details." | |
| cat > "${SITE_OUT_DIR}/${slug}-live-monitor/index.html" <<EOF | |
| <!doctype html> | |
| <html lang="en"> | |
| <head><meta charset="utf-8"><title>${slug} live monitor unavailable (build failed)</title></head> | |
| <body style="font-family: sans-serif; padding: 2rem;"> | |
| <h1>${slug} live monitor unavailable</h1> | |
| <p>The ${slug} monitor build failed for this run; no fresh artifact is available.</p> | |
| <p>See the <a href="${RUN_URL}">Actions run</a> (job "Build ${slug} monitor") for the specific failure.</p> | |
| <p>This page will self-heal on the next successful run (scheduled every 5 minutes).</p> | |
| </body> | |
| </html> | |
| EOF | |
| else | |
| cat > "${SITE_OUT_DIR}/${slug}-live-monitor/index.html" <<EOF | |
| <!doctype html> | |
| <html lang="en"> | |
| <head><meta charset="utf-8"><title>${slug} live monitor unavailable</title></head> | |
| <body style="font-family: sans-serif; padding: 2rem;"> | |
| <h1>${slug} live monitor unavailable</h1> | |
| <p>This monitor is not enabled yet in CI. Set repository variable MONITOR_${slug^^}_ENABLED=true and ensure a matching self-hosted runner label exists.</p> | |
| </body> | |
| </html> | |
| EOF | |
| fi | |
| done | |
| - name: Validate mounted monitor output | |
| env: | |
| ENABLED_SLUGS: ${{ needs.prepare_monitor_matrix.outputs.enabled_slugs }} | |
| ALL_SLUGS: ${{ needs.prepare_monitor_matrix.outputs.all_slugs }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| # Every slug must have an index.html (real artifact or placeholder). | |
| # manifest.json presence is no longer required for enabled slugs: | |
| # enabled+mounted legs already passed the per-leg contract check | |
| # upstream; enabled+placeholder legs legitimately have no manifest. | |
| for slug in ${ALL_SLUGS//,/ }; do | |
| dest="${SITE_OUT_DIR}/${slug}-live-monitor" | |
| [[ -f "${dest}/index.html" ]] || { echo "Mounted site missing ${slug} index.html"; exit 1; } | |
| if [[ -f "${dest}/manifest.json" ]]; then | |
| echo " ${slug}: real artifact mounted" | |
| elif [[ ",${ENABLED_SLUGS}," == *",${slug},"* ]]; then | |
| echo " ${slug}: placeholder (enabled but build leg failed)" | |
| else | |
| echo " ${slug}: placeholder (not enabled)" | |
| fi | |
| done | |
| - name: Upload Pages artifact | |
| # Only upload on main branch (never on PRs or non-main dispatches) | |
| if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' | |
| uses: actions/upload-pages-artifact@v3 | |
| with: | |
| path: ${{ env.SITE_OUT_DIR }} | |
| deploy: | |
| name: Deploy to GitHub Pages | |
| needs: aggregate_site | |
| # Lead with always() so the implicit transitive success() check does not cascade | |
| # from a failed build_monitors leg and skip us; then explicitly require | |
| # aggregate_site to have succeeded (it runs under always() too, so this is the | |
| # real gate). Without always() here, GitHub still skips deploy whenever any | |
| # upstream job -- including transitive build_monitors legs -- failed. | |
| if: always() && needs.aggregate_site.result == 'success' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main' | |
| runs-on: ubuntu-latest | |
| env: | |
| FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true | |
| environment: | |
| name: github-pages | |
| url: ${{ steps.deployment.outputs.page_url }} | |
| steps: | |
| - name: Deploy to GitHub Pages | |
| id: deployment | |
| uses: actions/deploy-pages@v4 |