-
Notifications
You must be signed in to change notification settings - Fork 5
215 lines (199 loc) · 9.78 KB
/
check-links.yml
File metadata and controls
215 lines (199 loc) · 9.78 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
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'