Pages (landing + nightly dashboard) #168
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: Pages (landing + nightly dashboard) | |
| # Single owner of the GitHub Pages deployment (repo Pages source must be set to | |
| # "GitHub Actions"). A repo has exactly one Pages site, so this one build serves | |
| # BOTH the nightly CI dashboard (at the site root) AND the project landing page | |
| # (Jekyll render of README.md, under /docs/ alongside the rest of the rendered | |
| # docs). It runs on main pushes, after each "Nightly Evaluation" completes (pick | |
| # up fresh history from the ci-results branch), after each "Sanitizers Nightly" | |
| # completes (pick up fresh history from the sanitizer-results branch), after each | |
| # "Latest ROCm canary" completes (also ci-results, under results/canary/), and on | |
| # demand. | |
| # | |
| # Every workflow that WRITES a data branch this page renders has to be listed | |
| # below, or its rows sit on the branch unpublished until some unrelated later | |
| # deploy happens to pick them up. The canary is the newest such writer. | |
| on: | |
| push: | |
| branches: [main] | |
| workflow_run: | |
| workflows: ["Nightly Evaluation", "Sanitizers Nightly", "Latest ROCm canary"] | |
| types: [completed] | |
| workflow_dispatch: | |
| permissions: | |
| contents: read | |
| pages: write | |
| id-token: write | |
| # Never cancel an in-progress deploy; queue them so a landing-page push and a | |
| # nightly refresh don't race to publish. | |
| concurrency: | |
| group: pages | |
| cancel-in-progress: false | |
| jobs: | |
| build-deploy: | |
| name: build + deploy pages | |
| # Only ever deploy trusted content: a push to main, a manual dispatch, or a | |
| # data-branch refresh triggered by a source workflow that itself ran on main. | |
| # A workflow_run fired by a feature-branch run (e.g. a workflow_dispatch of | |
| # Sanitizers Nightly off a PR branch) must NOT trigger an official Pages | |
| # deploy -- that would let unreviewed branch output reach the public site. | |
| if: >- | |
| github.event_name != 'workflow_run' || | |
| github.event.workflow_run.head_branch == 'main' | |
| runs-on: ubuntu-latest | |
| environment: | |
| name: github-pages | |
| url: ${{ steps.deploy.outputs.page_url }} | |
| steps: | |
| # Always deploy the landing page + dashboard generator from main, even for a | |
| # manual workflow_dispatch off another ref, so Pages content is deterministic. | |
| - name: Check out main | |
| uses: actions/checkout@v5 | |
| with: | |
| ref: main | |
| - name: Configure Pages | |
| id: pages | |
| uses: actions/configure-pages@v5 | |
| # Renders README.md (+ any repo docs) to ./_site with the same github-pages | |
| # gem the legacy Pages build used, so the rendered docs are unchanged. | |
| - name: Build landing page (Jekyll) | |
| uses: actions/jekyll-build-pages@v1 | |
| with: | |
| source: ./ | |
| destination: ./_site | |
| # jekyll-build-pages is a container action running as root, so ./_site comes | |
| # back root-owned and the later steps (which run as the unprivileged runner | |
| # user) cannot write inside it. | |
| - name: Take ownership of the Jekyll output | |
| run: sudo chown -R "$(id -u):$(id -g)" _site | |
| # Jekyll renders README.md to _site/index.html and docs/*.md to _site/docs/*. | |
| # Move that landing page under /docs/ (which had no index of its own) so the | |
| # CI dashboard can own the site root. Two rewrites are needed: | |
| # | |
| # 1. The rendered README mixes root-absolute links with relative ones; | |
| # going one directory deeper breaks only the relative ones, so they are | |
| # re-pointed with a ../ prefix. Fragments (#x), root-absolute (/x) and | |
| # absolute (https://x, which all contain a colon) are left alone. | |
| # 2. Jekyll's SEO tags hard-code the page's own URL, which was the site | |
| # root. Left alone they would tell crawlers and link unfurlers that this | |
| # page lives at the dashboard's URL, so they are re-pointed at /docs/. | |
| - name: Move the landing page to /docs/ | |
| env: | |
| DOCS_URL: ${{ steps.pages.outputs.base_url }}/docs/ | |
| run: | | |
| set -euo pipefail | |
| test -f _site/index.html | |
| mkdir -p _site/docs | |
| sed -E \ | |
| -e 's%(href|src)="([^":#/][^":]*)"%\1="../\2"%g' \ | |
| -e "s%(<link rel=\"canonical\" href=\")[^\"]*\"%\1${DOCS_URL}\"%" \ | |
| -e "s%(<meta property=\"og:url\" content=\")[^\"]*\"%\1${DOCS_URL}\"%" \ | |
| -e "s%(\"url\":\")[^\"]*\"%\1${DOCS_URL}\"%" \ | |
| _site/index.html > _site/docs/index.html | |
| rm _site/index.html | |
| echo "landing page -> /docs/ ($(wc -c < _site/docs/index.html) bytes)" | |
| # History lives on the ci-results data branch (written by nightly-eval's | |
| # publish job); it may not exist until the first nightly runs. Fail closed: | |
| # a genuinely-absent branch (ls-remote exit 2) deploys the docs only, but a | |
| # transport/auth error must NOT be swallowed into "no dashboard" -- that | |
| # would silently drop the dashboard while history exists. | |
| - name: Generate the nightly dashboard at the site root | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| repo_url="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" | |
| set +e | |
| git ls-remote --exit-code --heads "$repo_url" ci-results >/dev/null 2>&1 | |
| lsr=$? | |
| set -e | |
| # Both result directories are OPTIONAL inputs, resolved independently. | |
| # An absent one falls back to an empty directory, which gen_dashboard | |
| # renders as its documented "no runs yet" state. | |
| # | |
| # Two lanes write to ci-results from two different workflows, so | |
| # neither may gate the other: the canary publishes on its own schedule | |
| # and can legitimately land first, and previously the whole render was | |
| # conditional on a top-level gated results/*.json existing, so canary | |
| # history stayed invisible until the first gated nightly (and the | |
| # empty-state fallback dropped --canary-results-dir entirely). Same | |
| # class as the "/sanitizers/ 404s before the first nightly" rule. | |
| empty="${RUNNER_TEMP}/empty-results" | |
| mkdir -p "$empty" | |
| results_dir="$empty" | |
| canary_dir="$empty/absent" # deliberately non-existent; tolerated | |
| if [ "$lsr" -eq 2 ]; then | |
| echo "ci-results branch absent; publishing the empty state" | |
| elif [ "$lsr" -ne 0 ]; then | |
| echo "::error::cannot reach ci-results (git ls-remote exit ${lsr}); failing closed" | |
| exit 1 | |
| else | |
| tmp="$(mktemp -d)" | |
| git clone --depth 1 --branch ci-results "$repo_url" "$tmp" | |
| if [ -d "$tmp/results" ]; then | |
| results_dir="$tmp/results" | |
| fi | |
| if [ -d "$tmp/results/canary" ]; then | |
| canary_dir="$tmp/results/canary" | |
| fi | |
| fi | |
| # Rendered unconditionally, so / never 404s. The canary cannot affect | |
| # the gated page: results/*.json is globbed non-recursively and the | |
| # canary rows take a separate render path that feeds neither | |
| # status.json nor data.json. | |
| python3 scripts/ci/gen_dashboard.py \ | |
| --results-dir "$results_dir" --out-dir _site --max-builds 180 \ | |
| --canary-results-dir "$canary_dir" | |
| # Counted with a directory guard, not `2>/dev/null`: redirecting | |
| # stderr hides find's message but not its exit code, and under | |
| # `set -euo pipefail` a failing command substitution in an assignment | |
| # aborts the step. `canary_dir` is deliberately non-existent on the | |
| # normal pre-first-run path, so that would have failed the Pages job | |
| # right after the dashboard was generated -- never deploying the empty | |
| # state this block exists to publish. | |
| count_json() { | |
| if [ -d "$1" ]; then | |
| find "$1" -maxdepth 1 -name '*.json' | wc -l | |
| else | |
| echo 0 | |
| fi | |
| } | |
| echo "dashboard at / ($(count_json "$results_dir") gated build(s), $(count_json "$canary_dir") canary run(s))" | |
| if [ ! -f _site/index.html ]; then | |
| echo "::error::dashboard generation produced no index.html" | |
| exit 1 | |
| fi | |
| # History lives on the sanitizer-results data branch (written by | |
| # sanitizers-nightly's publish job); it may not exist until the first | |
| # sanitizer nightly runs. Same fail-closed ls-remote contract as ci-results. | |
| # The /sanitizers/ route must NEVER 404: the root nav and docs link to it | |
| # unconditionally, so when there is no (trusted) snapshot yet we publish the | |
| # generator's own "no runs yet" empty state instead of leaving it absent. | |
| - name: Publish the sanitizer dashboard at /sanitizers/ | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| baselines="recipes/sanitizers/fixtures/expected/verdict_baselines.json" | |
| mkdir -p _site/sanitizers | |
| publish_placeholder() { | |
| empty="$(mktemp -d)" | |
| python3 scripts/sanitizers/gen_sanitizer_dashboard.py \ | |
| --history-root "$empty" \ | |
| --baselines "$baselines" \ | |
| --out-dir _site/sanitizers | |
| } | |
| repo_url="https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" | |
| set +e | |
| git ls-remote --exit-code --heads "$repo_url" sanitizer-results >/dev/null 2>&1 | |
| lsr=$? | |
| set -e | |
| if [ "$lsr" -eq 2 ]; then | |
| echo "sanitizer-results branch absent; publishing the empty-state page" | |
| publish_placeholder | |
| elif [ "$lsr" -ne 0 ]; then | |
| echo "::error::cannot reach sanitizer-results (git ls-remote exit ${lsr}); failing closed" | |
| exit 1 | |
| else | |
| tmp="$(mktemp -d)" | |
| git clone --depth 1 --branch sanitizer-results "$repo_url" "$tmp" | |
| src_ref="" | |
| if [ -f "$tmp/dashboard/status.json" ]; then | |
| src_ref="$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("ref",""))' \ | |
| "$tmp/dashboard/status.json" 2>/dev/null || echo "")" | |
| fi | |
| if [ -f "$tmp/dashboard/status.json" ] && [ "$src_ref" != "refs/heads/main" ]; then | |
| # Defence in depth for the main-only publish guard: never serve | |
| # sanitizer data whose recorded source ref is not main. | |
| echo "::warning::sanitizer-results recorded ref '${src_ref}' is not main; publishing empty state instead" | |
| publish_placeholder | |
| elif [ -d "$tmp/dashboard" ] && [ -f "$tmp/dashboard/index.html" ]; then | |
| cp -r "$tmp/dashboard/"* _site/sanitizers/ | |
| echo "sanitizer dashboard at /sanitizers/ (source ref ${src_ref:-unknown})" | |
| else | |
| echo "sanitizer-results exists but has no dashboard yet; publishing empty state" | |
| publish_placeholder | |
| fi | |
| fi | |
| test -s _site/sanitizers/index.html | |
| # The dashboard used to live at /ci/, the docs advertised that URL, and | |
| # /ci/data.json is a machine-readable endpoint something may already poll. | |
| # A Pages deploy replaces the whole site, so moving the dashboard to the | |
| # root would 404 both the moment it ships. Keep the data endpoint where it | |
| # was and redirect the page to its new home. | |
| - name: Keep the old /ci/ routes working | |
| env: | |
| SITE_URL: ${{ steps.pages.outputs.base_url }} | |
| run: | | |
| set -euo pipefail | |
| site="${SITE_URL%/}" | |
| mkdir -p _site/ci | |
| cp _site/data.json _site/ci/data.json | |
| printf '%s\n' \ | |
| '<!doctype html>' \ | |
| '<html lang="en"><head><meta charset="utf-8">' \ | |
| '<title>Moved — aorta nightly CI</title>' \ | |
| '<meta http-equiv="refresh" content="0; url=../">' \ | |
| "<link rel=\"canonical\" href=\"${site}/\">" \ | |
| '</head><body>' \ | |
| '<p>The nightly CI dashboard moved to <a href="../">the site root</a>.' \ | |
| 'The history feed is unchanged at <a href="data.json">/ci/data.json</a>.</p>' \ | |
| '</body></html>' \ | |
| > _site/ci/index.html | |
| # Fail before deploying rather than publishing a site that 404s a route we | |
| # promised. Every path below is one an existing bookmark or consumer uses. | |
| - name: Verify the published routes | |
| env: | |
| SITE_URL: ${{ steps.pages.outputs.base_url }} | |
| run: | | |
| set -euo pipefail | |
| # /sanitizers/ is always published (a real snapshot or the empty state), | |
| # so the route is required unconditionally -- it must never 404. | |
| routes=( | |
| _site/index.html | |
| _site/docs/index.html | |
| _site/ci/index.html | |
| _site/data.json | |
| _site/ci/data.json | |
| _site/status.json | |
| _site/sanitizers/index.html | |
| ) | |
| for f in "${routes[@]}"; do | |
| if [ ! -s "$f" ]; then | |
| echo "::error::$f is missing or empty; refusing to deploy" | |
| exit 1 | |
| fi | |
| done | |
| cmp _site/data.json _site/ci/data.json | |
| # The moved docs page must not still identify itself as the site root. | |
| site="${SITE_URL%/}" | |
| if grep -E 'rel="canonical"|property="og:url"|"url":"' \ | |
| _site/docs/index.html | grep -qF "${site}/\""; then | |
| echo "::error::/docs/ still claims the dashboard's URL as its own" | |
| exit 1 | |
| fi | |
| echo "routes ok: / /docs/ /ci/ /data.json /ci/data.json /status.json /sanitizers/" | |
| - name: Upload Pages artifact | |
| uses: actions/upload-pages-artifact@v4 | |
| with: | |
| path: _site | |
| - name: Deploy to GitHub Pages | |
| id: deploy | |
| uses: actions/deploy-pages@v4 |