-
-
Notifications
You must be signed in to change notification settings - Fork 8
370 lines (361 loc) · 17.9 KB
/
Copy pathdocs.yaml
File metadata and controls
370 lines (361 loc) · 17.9 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
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
---
name: 📚 Docs
"on":
workflow_call:
# Only the Cloudflare deploy target reads these, and only a repository that
# selected it will have them set: an unset optional secret arrives empty and
# the job that would use it never runs. Declared rather than inherited, so a
# caller forwards these two by name instead of handing over everything it
# holds.
secrets:
CLOUDFLARE_API_TOKEN:
required: false
CLOUDFLARE_ACCOUNT_ID:
required: false
workflow_dispatch:
push:
branches:
- main
# Only rebuild docs when something the docs actually depend on changes:
# Sphinx sources, Python (autodoc), pyproject (project metadata + docs
# group deps), `uv.lock` (resolved env), the docs workflow itself, and
# the top-level Markdown / data files that the docs include or link to.
# `readme.*.md` catches the translated readmes kept beside the English
# one. `check-broken-links` crawls every file `metadata` reports under
# `doc_files`, translations included, so listing `readme.md` alone left a
# translation-only push with no link check at all: on an awesome list,
# where the translation carries the same few hundred links, that is most
# of what the crawl exists to cover.
paths:
- .github/workflows/docs.yaml
- changelog.md
- citation.cff
- docs/**
- pyproject.toml
- readme.*.md
- readme.md
- uv.lock
- "**/*.py"
# A monthly run, mirrored into every downstream caller. It is the heartbeat
# for two things a push-only trigger cannot surface on a quiet repository:
# a Cloudflare API token that lapsed (Cloudflare warns about neither an
# approaching expiry nor a passed one, so the first symptom must be a red
# run and its email rather than a deploy that never happens), and link rot
# accumulating between pushes, which check-broken-links only sees when it
# runs. Offset from the monthly tests.yaml run on the 1st.
schedule:
- cron: "43 6 15 * *"
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ !startsWith(github.event.head_commit.message, '[changelog] Release') }}
# Supply-chain cooldown: no package published within the window can be resolved by
# any command in this workflow. Set here, not per command, so it also covers the
# `metadata` bootstrap and any step added later; a workflow-level `env:` cannot
# reference `needs`, so the window is a literal kept equal to `[tool.repomatic]
# minimum-release-age`. repomatic's own test suite enforces that upstream; a
# synced copy is kept in step by hand. Deliberate bypasses are per-package CLI
# flags (`--exclude-newer-package`, `--min-release-age-exclude`).
# See claude.md for the rationale.
env:
NPM_CONFIG_MIN_RELEASE_AGE: 7
UV_EXCLUDE_NEWER: "1 week"
jobs:
metadata:
name: 🧬 Project metadata
runs-on: ubuntu-26.04
timeout-minutes: 15
outputs:
metadata: ${{ steps.metadata.outputs.metadata }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: "0.12.3"
- name: Run repomatic metadata
id: metadata
run: >
uv --no-progress run --frozen -- repomatic metadata
--format github-json --output "$GITHUB_OUTPUT"
is_python_project is_sphinx doc_files sphinx_builder
site_deploy site_cloudflare_project
deploy-docs:
name: 📖 Deploy Sphinx doc
needs:
- metadata
# Gated on the deploy target, which the Cloudflare job below answers for
# instead. Two jobs rather than one with conditional steps: each host wants
# different permissions, and GitHub Pages needs a job-level `environment:`
# that would be a lie on the other path. Only ever one of them runs, so the
# build is never done twice.
if: >-
fromJSON(needs.metadata.outputs.metadata).is_python_project
&& fromJSON(needs.metadata.outputs.metadata).is_sphinx
&& fromJSON(needs.metadata.outputs.metadata).site_deploy == 'github-pages'
runs-on: ubuntu-26.04
# Wider than the default: this job provisions a toolchain, so its runtime tracks how much it has to install.
timeout-minutes: 30
permissions:
id-token: write
pages: write
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: "0.12.3"
- name: Install Graphviz and mandoc
# Graphviz: backs the sphinx.ext.graphviz directive.
# See: https://www.sphinx-doc.org/en/master/usage/extensions/graphviz.html
# mandoc: renders the roff `.1` files written by the
# click_extra.sphinx.manpages hook into the browser-viewable
# `.html` siblings the docs site links to. Skipped silently when
# the hook is not opted in.
# fonts-liberation is graphviz's only Recommends (via the transitional
# fonts-liberation2), and an image that ships no fonts of its own would
# otherwise lose it: naming it here keeps diagram labels rendering
# whatever the base image carries, while --no-install-recommends stops
# apt pulling anything else unasked.
run: |
sudo apt-get update
sudo apt-get install --yes --no-install-recommends \
fonts-liberation graphviz mandoc
- name: Build documentation
# Install --all-extras so documentation can cover all features of the project, including the optional ones.
# The builder is `[tool.repomatic] sphinx.builder`, defaulting to `html`:
# a project serving extension-less URLs sets it to `dirhtml`.
# It travels through the environment rather than being interpolated
# into the command, like DOC_FILES below: the value is whatever the
# repository's own `pyproject.toml` holds, so expanding it inline would
# let a `pull_request`-triggered caller run a fork's string as shell
# code.
# `-d` keeps Sphinx's pickled parse cache out of the published tree. It
# otherwise defaults to `<outdir>/.doctrees`, which the upload below then
# ships: on a project with autodoc-heavy pages that was 118 MB of a 182 MB
# artifact, two thirds of every deploy. GitHub Pages never serves it (it
# refuses dot-prefixed paths) so nothing was exposed, but nothing needed
# sending either. The runner's temp directory keeps it out of the working
# tree as well, so no downstream `.gitignore` or `exclude_patterns` entry
# has to be added for it.
env:
SPHINX_BUILDER: ${{ fromJSON(needs.metadata.outputs.metadata).sphinx_builder }}
run: >
uv --no-progress run --frozen --all-extras --group docs --
sphinx-build -b "$SPHINX_BUILDER"
-d "${{ runner.temp }}/sphinx-doctrees"
./docs ./docs/_build
- name: Upload artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: ./docs/_build
- name: Deploy to GitHub Pages
id: deploy
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
deploy-docs-cloudflare:
name: 📖 Deploy Sphinx doc to Cloudflare Pages
needs:
- metadata
if: >-
fromJSON(needs.metadata.outputs.metadata).is_python_project
&& fromJSON(needs.metadata.outputs.metadata).is_sphinx
&& fromJSON(needs.metadata.outputs.metadata).site_deploy == 'cloudflare-pages'
runs-on: ubuntu-26.04
# Same toolchain as the job above, so the same wider cap.
timeout-minutes: 30
# No `id-token`, no `pages`, and no environment: this path authenticates to
# Cloudflare with a stored token and never touches the repository's own
# deployment surface.
permissions: {}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: "0.12.3"
- name: Install Graphviz and mandoc
# Same reasons as the GitHub Pages job above.
run: |
sudo apt-get update
sudo apt-get install --yes --no-install-recommends \
fonts-liberation graphviz mandoc
- name: Build documentation
# Same invocation as the GitHub Pages job above, comments included.
env:
SPHINX_BUILDER: ${{ fromJSON(needs.metadata.outputs.metadata).sphinx_builder }}
run: >
uv --no-progress run --frozen --all-extras --group docs --
sphinx-build -b "$SPHINX_BUILDER"
-d "${{ runner.temp }}/sphinx-doctrees"
./docs ./docs/_build
- name: Drop files over the Pages upload limit
# Direct Upload rejects any file over 25 MiB, and `wrangler` fails the
# whole deploy on the first one it meets. Dropping the stragglers
# publishes everything else instead of nothing, and `-print` names
# each dropped file in the log so the trim is never silent.
# See: https://developers.cloudflare.com/pages/get-started/direct-upload/#limits
run: >
find ./docs/_build -type f -size +25M -print -delete
- name: Deploy to Cloudflare Pages
# Direct Upload: the tree built above is what reaches the edge, so
# Cloudflare never builds anything and needs no access to this
# repository. The project defaults to the repository's name, overridden
# by `[tool.repomatic] site.cloudflare-project` for a project that
# predates repomatic, and must already exist either way.
# `wrangler` is pinned as an npm literal, which `sync-workflow-pins`
# keeps current and the workflow-level cooldown holds to a release old
# enough to have been withdrawn if it were broken.
# Every value reaches the command through `env:` rather than inline
# expansion: a branch name is attacker-controllable, and `${{ }}` in a
# `run:` body is substituted into the script before the shell sees it.
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
PROJECT_NAME: >-
${{ fromJSON(needs.metadata.outputs.metadata).site_cloudflare_project
|| github.event.repository.name }}
REF_NAME: ${{ github.ref_name }}
COMMIT_SHA: ${{ github.sha }}
run: >
npx --yes wrangler@4.120.0 pages deploy ./docs/_build
--project-name "$PROJECT_NAME"
--branch "$REF_NAME"
--commit-hash "$COMMIT_SHA"
cloudflare-config-drift:
name: 🌩️ Check Cloudflare config drift
needs:
- metadata
# Cloudflare holds settings no committed file can express: the
# compatibility date, Smart Placement, the build image floor, whether a
# git source got attached behind the deploy's back. All are invisible
# until they misbehave, so this job fails loudly when the live project
# stops matching what `[tool.repomatic] site.*` declares. Deliberately a
# job of its own rather than a step of the deploy: drifted settings should
# be loud, but they must never hold up publishing. Runs for every
# Cloudflare-targeting repository, Sphinx or not, since the drift exists
# wherever the project does.
if: >-
fromJSON(needs.metadata.outputs.metadata).site_deploy == 'cloudflare-pages'
runs-on: ubuntu-26.04
# A couple of API calls and a TOML parse. Ten minutes covers an API that
# is answering slowly rather than not at all.
timeout-minutes: 10
permissions: {}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: "0.12.3"
- name: Compare live project against declared state
# Same project resolution as the deploy job above, spelled out so the
# two jobs visibly address one project.
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
SITE_PROJECT: >-
${{ fromJSON(needs.metadata.outputs.metadata).site_cloudflare_project
|| github.event.repository.name }}
run: >
uv --no-progress run --frozen -- repomatic cloudflare-pages
--project "$SITE_PROJECT" --check
check-broken-links:
name: 💔 Check broken links
needs:
- metadata
permissions:
issues: write
# Skip all PRs as we won't have permissions to create issues.
# Skip the prepare-release branch as it contains URLs pointing to tags that don't exist yet.
# Skip any push containing a post-release bump commit as a precautionary measure.
if: >
github.event_name != 'pull_request'
&& github.ref != 'refs/heads/prepare-release'
&& (! contains(toJSON(github.event.commits.*.message), '[changelog] Post-release bump'))
&& (fromJSON(needs.metadata.outputs.metadata).doc_files
|| (fromJSON(needs.metadata.outputs.metadata).is_python_project
&& fromJSON(needs.metadata.outputs.metadata).is_sphinx))
# ARM rather than the x86 axis: this crawl's runtime is dominated by remote
# servers' throttling and grows with every release (each adds a dozen URLs
# to the binaries page), so it wants the fastest Linux image available. It
# once ran on a lean image with a 15-minute job cap, which killed the whole
# crawl outright rather than failing a step.
runs-on: ubuntu-26.04-arm
# Widest cap in the repository, for the reason just above: this crawl is the
# one job whose runtime is set by other people's servers. Triple the 15
# minutes that already killed it once, against a measured 10.4-minute worst
# case, so the growth has somewhere to go.
timeout-minutes: 45
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
version: "0.12.3"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/repomatic/bin
key: >-
repomatic-${{ runner.os }}-${{ runner.arch }}-lychee-${{
github.job_workflow_sha || hashFiles('repomatic/tool_registry.py') }}
restore-keys: |
repomatic-${{ runner.os }}-${{ runner.arch }}-lychee-
- name: Install Graphviz
if: >
fromJSON(needs.metadata.outputs.metadata).is_python_project
&& fromJSON(needs.metadata.outputs.metadata).is_sphinx
# So we can use the sphinx.ext.graphviz plugin.
# See: https://www.sphinx-doc.org/en/master/usage/extensions/graphviz.html
# fonts-liberation named explicitly: see the deploy-docs job above.
run: |
sudo apt-get update
sudo apt-get install --yes --no-install-recommends fonts-liberation graphviz
- name: Run Sphinx linkcheck
if: >
fromJSON(needs.metadata.outputs.metadata).is_python_project
&& fromJSON(needs.metadata.outputs.metadata).is_sphinx
# Do not use -W: we parse output.json directly instead of relying on exit codes.
# GitHub throttles anonymous crawls to ~1 request/minute, enough to blow a
# capped runner's job budget on link-heavy repos (the runner kills the whole
# job, bypassing continue-on-error). Exposing the token lets a repo's conf.py
# authenticate its github.com checks via linkcheck_request_headers.
env:
GITHUB_TOKEN: ${{ github.token }}
run: >
uv --no-progress run --frozen --all-extras --group docs --
sphinx-build -b linkcheck ./docs ./docs/_linkcheck
continue-on-error: true
- name: Run lychee
if: fromJSON(needs.metadata.outputs.metadata).doc_files
id: lychee_run
env:
GITHUB_TOKEN: ${{ github.token }}
DOC_FILES: ${{ fromJSON(needs.metadata.outputs.metadata).doc_files }}
# xargs splits the list and does nothing else. It must not be what launches
# lychee: xargs answers any child status in 1..125 with 123 of its own, so
# lychee's 2 ("broken links found") reaches `repomatic broken-links` looking
# exactly like a crash, and a real report gets filed as a tool failure and
# dropped. Batching is the second casualty, since each batch writes the same
# --output path and only the last one survives. Handing the split to `printf
# '%s\0'` keeps xargs on an operation that cannot fail, and the single
# invocation below then carries back both the status and the whole report.
#
# The split cannot be `eval "set -- ${DOC_FILES}"`: metadata wraps each path
# in bare double quotes with no escaping, so a filename containing $(...)
# would execute. xargs expands neither that nor backticks.
run: |
exit_code=0
files=()
while IFS= read -r -d '' f; do files+=("$f"); done \
< <(echo "${DOC_FILES}" | xargs printf '%s\0')
uv --no-progress run --frozen -- repomatic run lychee -- \
--format markdown --output ./lychee/out.md \
--hidden --suggest --no-progress --include-fragments --exclude-all-private \
"${files[@]}" \
|| exit_code=$?
echo "exit_code=$exit_code" >> "$GITHUB_OUTPUT"
- name: Manage broken links issue
env:
GH_TOKEN: ${{ github.token }}
LYCHEE_EXIT_CODE: ${{ steps.lychee_run.outputs.exit_code }}
run: |
uv --no-progress run --frozen -- repomatic broken-links \
${LYCHEE_EXIT_CODE:+--lychee-exit-code "${LYCHEE_EXIT_CODE}"}