Add Validation docs to the site #16
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: Check Links | |
| # Trigger only when the docs site, the link checker config, or this workflow | |
| # itself changes — unrelated PRs do not need to pay the build+check cost. | |
| on: | |
| pull_request: | |
| paths: | |
| - 'docs/**' | |
| - 'site/**' | |
| - 'lychee.toml' | |
| - '.github/workflows/check-links.yml' | |
| workflow_dispatch: | |
| env: | |
| HUGO_VERSION: 0.161.1 | |
| LYCHEE_RELEASE: "lychee-x86_64-unknown-linux-gnu.tar.gz" | |
| LYCHEE_VERSION_TAG: "lychee-v0.24.2" | |
| # SHA256 of the above tarball, pinned at download time. Update alongside | |
| # LYCHEE_VERSION_TAG whenever the binary is upgraded. | |
| LYCHEE_SHA256: "1f4e0ef7f6554a6ed33dd7ac144fb2e1bbed98598e7af973042fc5cd43951c9a" | |
| # Force Hugo to write its module cache where the cache step actually | |
| # restores from. Hugo's default on Linux is `~/.cache/hugo_cache` | |
| # (or `$TMPDIR/hugo_cache_$USER`), neither of which matches the | |
| # `path: /tmp/hugo_cache` cache step below — without this env var, | |
| # the cache would silently never hit. | |
| HUGO_CACHEDIR: /tmp/hugo_cache | |
| jobs: | |
| check-links: | |
| runs-on: ubuntu-latest | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| # Detect the Hugo site root (`docs/` or `site/`) by looking for a Hugo | |
| # config file. Hugo config may live directly in the site root or in a | |
| # `config/` or `config/_default/` subdirectory (both layouts are valid). | |
| # Outputs `present=true|false`, `site_dir=docs|site`, and `work_dir` | |
| # (the directory where `npm ci` / `hugo` commands should run — either | |
| # `$site_dir/_preview` for repos that use a separate preview sub-tree, | |
| # or `$site_dir` for repos whose Node/Hugo setup lives at the root). | |
| # When neither directory has a Hugo config, the job short-circuits to a | |
| # success so that this shared workflow stays green on repos that do not | |
| # host a Hugo site at all. | |
| - name: Detect docs site | |
| id: docs | |
| run: | | |
| for dir in docs site; do | |
| for cfg in hugo.toml hugo.yaml config/hugo.toml config/_default/hugo.toml; do | |
| if [ -f "$dir/$cfg" ]; then | |
| echo "site_dir=$dir" >> "$GITHUB_OUTPUT" | |
| if [ -f "$dir/_preview/package-lock.json" ]; then | |
| echo "work_dir=$dir/_preview" >> "$GITHUB_OUTPUT" | |
| echo "present=true" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Hugo site found under $dir/ (work_dir: $dir/_preview)" | |
| elif [ -f "$dir/package-lock.json" ]; then | |
| echo "work_dir=$dir" >> "$GITHUB_OUTPUT" | |
| echo "present=true" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Hugo site found under $dir/ (work_dir: $dir)" | |
| else | |
| echo "present=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::Hugo config found in $dir/ but no package-lock.json found — skipping link check." | |
| fi | |
| exit 0 | |
| fi | |
| done | |
| done | |
| echo "present=false" >> "$GITHUB_OUTPUT" | |
| echo "::notice::No Hugo site found under docs/ or site/ — skipping link check." | |
| - name: Setup Hugo | |
| if: steps.docs.outputs.present == 'true' | |
| uses: peaceiris/actions-hugo@v3 | |
| with: | |
| hugo-version: ${{ env.HUGO_VERSION }} | |
| extended: true | |
| # `actions/setup-node@v4` ships with built-in npm caching that hashes | |
| # the lockfile and restores `~/.npm`. We use that instead of a | |
| # standalone `actions/cache@v4` block so there is only one source of | |
| # truth for the cache key (no drift between two layers). | |
| - name: Setup Node | |
| if: steps.docs.outputs.present == 'true' | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '26' | |
| cache: 'npm' | |
| cache-dependency-path: ${{ steps.docs.outputs.work_dir }}/package-lock.json | |
| # `HUGO_CACHEDIR=/tmp/hugo_cache` (set in `env:` above) makes Hugo | |
| # actually write to the path this step restores from. The key hashes | |
| # both possible go.sum locations so adding/removing a Hugo module | |
| # invalidates the cache deterministically regardless of site root. | |
| - name: Cache Hugo Modules | |
| if: steps.docs.outputs.present == 'true' | |
| uses: actions/cache@v4 | |
| with: | |
| path: /tmp/hugo_cache | |
| key: ${{ runner.os }}-hugomod-${{ hashFiles('docs/**/go.sum', 'site/**/go.sum') }} | |
| restore-keys: | | |
| ${{ runner.os }}-hugomod- | |
| - name: Install Dependencies | |
| if: steps.docs.outputs.present == 'true' | |
| working-directory: ${{ steps.docs.outputs.work_dir }} | |
| run: npm ci | |
| - name: Build docs preview site | |
| if: steps.docs.outputs.present == 'true' | |
| working-directory: ${{ steps.docs.outputs.work_dir }} | |
| run: hugo -e development | |
| # Cache Lychee results to avoid hitting rate limits. | |
| # Key on the lychee.toml hash so that exclude-list edits (e.g. removing | |
| # an exclude pattern) invalidate the cache deterministically; otherwise | |
| # stale `200 OK` entries for the now-checked URLs would be trusted until | |
| # `max_cache_age` expires. | |
| - name: Cache Lychee results | |
| if: steps.docs.outputs.present == 'true' | |
| uses: actions/cache@v4 | |
| with: | |
| path: .lycheecache | |
| key: cache-lychee-${{ runner.os }}-${{ hashFiles('lychee.toml') }} | |
| restore-keys: | | |
| cache-lychee-${{ runner.os }}- | |
| # The cache key includes LYCHEE_VERSION_TAG so a version bump | |
| # automatically pulls a fresh binary instead of reusing the old one. | |
| # The restore-keys fallback lets a release-filename tweak (rare) reuse | |
| # the existing cached binary for the same version-tag instead of paying | |
| # for a fresh download. | |
| - name: Cache Lychee executable | |
| if: steps.docs.outputs.present == 'true' | |
| id: cache-lychee | |
| uses: actions/cache@v4 | |
| with: | |
| path: lychee | |
| key: ${{ runner.os }}-${{ env.LYCHEE_VERSION_TAG }}-${{ env.LYCHEE_RELEASE }} | |
| restore-keys: | | |
| ${{ runner.os }}-${{ env.LYCHEE_VERSION_TAG }}- | |
| # We use Lychee directly instead of a GitHub Action because it | |
| # must have access to the local Hugo server, which is not visible | |
| # from the Docker-based action. | |
| # | |
| # `if:` gating uses `hashFiles('lychee/lychee')` rather than | |
| # `steps.cache-lychee.outputs.cache-hit != 'true'`. Per `actions/cache` | |
| # docs, `cache-hit` is only `'true'` on an EXACT key match — a restore | |
| # via `restore-keys` reports `cache-hit == 'false'`, even though the | |
| # binary is present in the workspace. Re-downloading in that case | |
| # would defeat the point of the fallback. `hashFiles` returns an empty | |
| # string when the file is absent, so this guard runs the download iff | |
| # neither the exact key nor any restore-key restored the binary. | |
| - name: Download Lychee executable | |
| uses: robinraju/release-downloader@v1.7 | |
| if: steps.docs.outputs.present == 'true' && hashFiles('lychee/lychee') == '' | |
| with: | |
| repository: "lycheeverse/lychee" | |
| tag: ${{ env.LYCHEE_VERSION_TAG }} | |
| fileName: ${{ env.LYCHEE_RELEASE }} | |
| - name: Verify Lychee checksum | |
| if: steps.docs.outputs.present == 'true' && hashFiles('lychee/lychee') == '' | |
| run: | | |
| echo "${{ env.LYCHEE_SHA256 }} ${{ env.LYCHEE_RELEASE }}" | sha256sum --check --strict | |
| # The v0.24.2 tarball contains a top-level directory | |
| # (e.g. `lychee-x86_64-unknown-linux-gnu/lychee`), so `--strip-components=1` | |
| # flattens it to `lychee/lychee` — matching what the companion | |
| # `check-links` skill does locally and what the next step expects. | |
| - name: Extract Lychee executable | |
| if: steps.docs.outputs.present == 'true' && hashFiles('lychee/lychee') == '' | |
| run: | | |
| mkdir -p lychee && | |
| tar -xzf ${{ env.LYCHEE_RELEASE }} --strip-components=1 -C lychee | |
| # 1. In the generated HTML, some inner links will have absolute URLs and | |
| # the link checker will attempt to fetch them. That's why we need | |
| # a server. Sadly, link checkers have no settings to address this. | |
| # 2. Output redirection is necessary for nohup in GitHub Actions. | |
| # 3. Sleep + `curl` readiness check make sure the server is actually | |
| # serving HTTP before the next step runs Lychee. Without the curl | |
| # probe a silent startup failure (port already bound, missing | |
| # Hugo module, build error surfacing after `nohup` returns 0) | |
| # would manifest 60 s later as "every URL unreachable" Lychee | |
| # errors instead of pointing at the real cause. Mirrors the | |
| # `pgrep -F` guard in the companion `check-links` skill. | |
| # 4. `--port 1313` is set explicitly (not relying on Hugo's default) so | |
| # the coupling with `--base-url http://localhost:1313/` in the next | |
| # Lychee step is visible — change one, change the other. | |
| - name: Start Hugo server | |
| if: steps.docs.outputs.present == 'true' | |
| working-directory: ${{ steps.docs.outputs.work_dir }} | |
| run: | | |
| nohup hugo server \ | |
| --environment development \ | |
| --port 1313 \ | |
| > nohup.out 2> nohup.err < /dev/null & | |
| sleep 5 | |
| if ! curl -sf http://localhost:1313/ > /dev/null; then | |
| echo "ERROR: Hugo server did not respond on port 1313." >&2 | |
| echo "--- stdout ---" >&2; cat nohup.out >&2 || true | |
| echo "--- stderr ---" >&2; cat nohup.err >&2 || true | |
| exit 1 | |
| fi | |
| - name: Check links | |
| if: steps.docs.outputs.present == 'true' | |
| run: | | |
| ./lychee/lychee --config lychee.toml --timeout 60 \ | |
| --base-url http://localhost:1313/ \ | |
| '${{ steps.docs.outputs.work_dir }}/public/**/*.html' |