Repository navigation
225 lines (220 loc) · 11.6 KB
/
Copy pathdocs.yml
File metadata and controls
225 lines (220 loc) · 11.6 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
216
217
218
219
220
221
222
223
224
225
# This workflow enforces the documentation and spec contracts on pull requests/pushes that touch
# markdown, or that move a top-level test name a spec's Verify line points at. ci.yml deliberately
# ignores "docs/**" and "**.md", and GitHub's path filters are per-workflow rather than per-job, so
# the check needs its own.
name: Docs
permissions:
contents: read
defaults:
run:
shell: bash
on:
workflow_dispatch: { }
push:
branches:
- "main"
- "v*-dev"
paths:
- "**.md"
- "docs/**"
- "site/**"
- "Makefile"
- "hack/check/site-links.py"
- "hack/check/agent-index.py"
- "hack/site/**"
- ".github/workflows/site.yml"
- ".github/workflows/chart.yml"
- "hack/site.sh"
- "hack/site-serve.sh"
- "hack/lib/hugo.sh"
# README.md links ./LICENSE, and the script resolves non-markdown link targets too.
- "LICENSE"
# A renamed test breaks a spec's `go test -run` line without touching any markdown, so the
# check that catches it has to run when the test names move, not only when prose moves.
- "**/*_test.go"
- ".claude/skills/gpustack-operator-docs/scripts/*.sh"
# The skill contract rides on "make lint docs". A SKILL.md is markdown and is already covered
# by "**.md" above, but the Codex invocation policy beside it is YAML and the exclusion lists
# it compares are JSON and prose, so each needs its own trigger or the gate misses the edit.
- ".agents/skills/**"
- ".opencodereview/rule.json"
- "hack/check/skills.sh"
- "hack/check/skills-selftest.sh"
- "hack/lint.sh"
- ".github/workflows/docs.yml"
pull_request:
branches:
- "main"
- "v*-dev"
paths:
- "**.md"
- "docs/**"
- "site/**"
- "Makefile"
- "hack/check/site-links.py"
- "hack/check/agent-index.py"
- "hack/site/**"
- ".github/workflows/site.yml"
- ".github/workflows/chart.yml"
- "hack/site.sh"
- "hack/site-serve.sh"
- "hack/lib/hugo.sh"
# README.md links ./LICENSE, and the script resolves non-markdown link targets too.
- "LICENSE"
# A renamed test breaks a spec's `go test -run` line without touching any markdown, so the
# check that catches it has to run when the test names move, not only when prose moves.
- "**/*_test.go"
- ".claude/skills/gpustack-operator-docs/scripts/*.sh"
# The skill contract rides on "make lint docs". A SKILL.md is markdown and is already covered
# by "**.md" above, but the Codex invocation policy beside it is YAML and the exclusion lists
# it compares are JSON and prose, so each needs its own trigger or the gate misses the edit.
- ".agents/skills/**"
- ".opencodereview/rule.json"
- "hack/check/skills.sh"
- "hack/check/skills-selftest.sh"
- "hack/lint.sh"
- ".github/workflows/docs.yml"
jobs:
check:
timeout-minutes: 20
runs-on: ubuntu-24.04
env:
# The repo-file shortcut in "Check links under .github" must recognize the branch a
# "blob" link pins, and that branch is whatever this event's repository calls its
# default, not a literal "main": a fork or a renamed default branch would otherwise
# send every default-branch link back through the flaky HTTP path. Structured
# context rather than a shell default, so it follows each event's repository.
default_branch: ${{ github.event.repository.default_branch }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: true
persist-credentials: false
# Links and anchors, the "## Contents" list, each page's footer, the docs/README.md
# index, the prose paragraph cap, the page line and "##" caps, every index label against its page's H1 -- and, over specs/,
# the Status word, every `go test -run` that must select something, and
# cross-references. The two self-tests run first: they are what establishes
# that the rules can fail at all.
# The script's portability floor is onetrueawk, which is what macOS ships; ubuntu-24.04's default
# awk is gawk and would silently accept a gawk-only extension no macOS contributor could run.
# This pins the implementation, not the version: noble's original-awk is newer (2023-11-27) than
# the 20200816 on a current macOS, so a feature added between the two still passes here.
- name: Setup onetrueawk
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends original-awk
- name: Setup Go for Hugo
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Check
run: |
sudo update-alternatives --set awk /usr/bin/original-awk
awk --version 2>&1 | head -1
make lint docs
- name: Check versioned build
env:
SITE_BASE_URL: https://docs.gpustack.ai/gpustack-operator/main/
HUGO_PARAMS_SITEROOT: https://docs.gpustack.ai/gpustack-operator/
HUGO_PARAMS_VERSION: main
HUGO_PARAMS_SOURCEREF: ${{ github.sha }}
run: make site
# The local check above never resolves an external URL — it only follows relative paths and
# in-file anchors — which is how a 404 in PULL_REQUEST_TEMPLATE.md survived unnoticed. This step
# fetches them, and it lives here rather than in "make lint docs" so the local check keeps its
# "a second or two, no cluster" character instead of gaining network flake.
#
# Scoped to .github on purpose. The whole markdown corpus is 53 unique URLs, ~15 of which cannot
# pass from a GitHub runner: two 127.0.0.1 examples that must always fail, ~10 CN vendor hosts
# unreliable from US egress, and three rate-limited img.shields.io. The ignore list that would
# make a corpus-wide gate green is worse to own than no gate. Under .github every URL is github.com.
#
# That last sentence bounds which hosts are reached, NOT how reliably they answer. github.com
# serves a runner 503 under load: four of five URLs here came back 503 on three attempts running
# while all five answered 200 from a developer machine minutes later, and two runs started eight
# seconds apart made it worse by asking for the same URLs at once. A status that reports only the
# server's own state is therefore recorded as unverified instead of counted as a broken link.
#
# Deliberately awk-free: the awk pinned above is onetrueawk to match check-docs.sh's portability
# floor, and a step leaning on any awk behaviour would silently inherit that constraint too.
- name: Check links under .github
run: |
urls=$(grep -rhoE 'https?://[^][ )>"`]+' .github --include='*.md' | sed 's/[.,;:]*$//' | sort -u || true)
if [[ -z "${urls}" ]]; then
echo "No external URL found under .github/**/*.md."
exit 0
fi
echo "Checking $(printf '%s\n' "${urls}" | wc -l | tr -d ' ') unique URL(s) under .github/**/*.md:"
failed=""
unverified=""
repository_files="https://github.com/${GITHUB_REPOSITORY}/blob/${default_branch}/"
while IFS= read -r url; do
[[ -n "${url}" ]] || continue
# A PR may move its own docs before the default branch contains them. Check those
# paths against this checkout; links to other repositories and pinned revisions
# still use HTTP, because the current checkout cannot prove a file existed at the
# revision the link pins.
if [[ "${url}" == "${repository_files}"* ]]; then
file="${url#"${repository_files}"}"
# Query and fragment first: a path's own "?" or "#" only exists once decoded.
file="${file%%\?*}"
file="${file%%\#*}"
# Blob URLs percent-encode what the checkout stores raw; decode via stdlib
# unquote, passing the path as an argument, never inside the program text.
if [[ "${file}" == *%* ]]; then
file=$(python3 -c 'import sys, urllib.parse; sys.stdout.write(urllib.parse.unquote(sys.argv[1]))' "${file}")
fi
if [[ -f "${file}" ]]; then
printf ' ok file %s\n' "${url}"
else
printf ' FAIL file %s\n' "${url}"
failed="${failed}missing file ${url}"$'\n'
fi
continue
fi
# Retry a transient failure rather than turning the build red on one dropped connection.
# A 4xx other than 408/429 is a genuinely broken link, so it fails on the first answer.
#
# No `|| echo 000` here. curl already writes 000 through -w on a connection failure or a
# --max-time timeout AND exits non-zero, so the fallback appended a second one: the code
# read 000000, matched neither the retry set nor 2xx/3xx, and turned the build red on the
# first dropped connection — the exact case this retry exists to absorb. The default
# covers the one path where curl exits before writing anything at all.
for attempt in 1 2 3; do
# `|| true` keeps curl's non-zero exit from tripping errexit: without it the
# first dropped connection aborts the step before this loop can retry.
code=$(curl -sSL -o /dev/null -w '%{http_code}' --max-time 20 "${url}") || true
code=${code:-000}
case "${code}" in
000|408|429|5??) [[ "${attempt}" == 3 ]] || sleep 5 ;;
*) break ;;
esac
done
# The verdict, and it turns on what the status code is about. A 4xx other than 408/429 is
# the server saying the link is wrong, which is the whole point of this gate. A 408, a 429
# or a 5xx is the server saying something about ITSELF: it answered, so the host resolves
# and the route works, and the code carries nothing about whether the link is good.
# Exhausting the retries means only that this run never got to an answer, so failing the
# build on it would be a verdict reached on evidence that does not support one. Those are
# listed as unverified instead, loudly, so a run that checked nothing is visible rather
# than quietly green.
#
# 000 stays a failure. There curl got no answer at all, which is also what a domain that
# no longer exists produces, so the code does not clear the link the way a 5xx does.
case "${code}" in
2??|3??) printf ' ok %s %s\n' "${code}" "${url}" ;;
408|429|5??) printf ' unverified %s %s\n' "${code}" "${url}"; unverified="${unverified}${code} ${url}"$'\n' ;;
*) printf ' FAIL %s %s\n' "${code}" "${url}"; failed="${failed}${code} ${url}"$'\n' ;;
esac
done <<< "${urls}"
# Say how much of the gate did not run. A count of zero broken links means less when some
# links were never reached, and the difference is invisible unless it is printed.
if [[ -n "${unverified}" ]]; then
echo "Unverified this run -- $(printf '%s' "${unverified}" | wc -l | tr -d ' ') URL(s) whose server reported on itself, not on the link:"
printf '%s' "${unverified}"
fi
# Report every broken URL, not just the first: a gate that names one of three costs three CI rounds.
if [[ -n "${failed}" ]]; then
echo "Broken link(s) under .github/**/*.md:"
printf '%s' "${failed}"
exit 1
fi