.gitbay/ci.yml
181 lines · 7459 bytes
1# CI for cleberg.net.
2#
3# Every job runs on a plain host under the ci-runner account: no containers,
4# no per-job image, and /usr is read-only. Only $HOME (/var/lib/gitbay-runner)
5# survives between builds, so each pinned tool is installed there once and
6# version-checked on every run. The clone itself is fresh and discarded.
7#
8# lint and build run on every push. link-check runs on its cron.
9
10jobs:
11 # Separate from the build so a lint failure and a broken page are distinct
12 # signals. build.py runs `ruff check --fix` and `ruff format` on development
13 # builds; these are the verifying forms of the same two commands, reading the
14 # same ruff.toml, so CI cannot disagree with a local dev build.
15 lint:
16 steps:
17 # Pinned so a new ruff release adds rules on your schedule, not mid-push.
18 # Changing the version here reinstalls it on the next build.
19 - |
20 set -eu
21 v=0.16.4
22 d=$HOME/tools/ruff
23 if ! "$d/bin/ruff" --version 2>/dev/null | grep -qx "ruff $v"; then
24 rm -rf "$d"
25 python3 -m venv "$d"
26 "$d/bin/pip" install --quiet --disable-pip-version-check "ruff==$v"
27 fi
28 "$d/bin/ruff" --version
29
30 - $HOME/tools/ruff/bin/ruff check --no-fix
31
32 - $HOME/tools/ruff/bin/ruff format --check --diff
33
34 build:
35 steps:
36 # rustup is here only to build orgo; nothing else on the runner uses it.
37 - |
38 set -eu
39 if [ ! -x "$HOME/.cargo/bin/cargo" ]; then
40 curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
41 | sh -s -- -y --no-modify-path --profile minimal
42 fi
43 "$HOME/.cargo/bin/cargo" --version
44
45 # orgo is installed from crates.io and takes minutes to compile, so it is
46 # rebuilt only when this version changes. Nothing bumps it automatically:
47 # raise it by hand, which is also how you find out orgo broke the site.
48 #
49 # --root pins where it lands, like ruff and lychee below. Without it cargo
50 # installs under whatever cargo home the runner sets, which is not $HOME:
51 # the binary goes somewhere this file does not name, and the next run's
52 # `cargo install` declines to repeat itself because its metadata already
53 # records the version as installed.
54 - |
55 set -eu
56 v=0.24.0
57 if ! "$HOME/tools/orgo/bin/orgo" --version 2>/dev/null | grep -qx "orgo $v"; then
58 "$HOME/.cargo/bin/cargo" install orgo --version "$v" --locked \
59 --root "$HOME/tools/orgo"
60 fi
61 "$HOME/tools/orgo/bin/orgo" --version
62 python3 --version
63
64 # build.py deploys with rsync when DEPLOY=true and ENV=prod. This build
65 # passes neither, but "we did not set the variable" is an assumption, not
66 # a guarantee. Shadowing rsync with a failing stub turns it into one: if
67 # the deploy path is ever reached from CI, the step fails loudly instead
68 # of quietly reaching for a production host.
69 #
70 # ENV=prod is deliberate. The development path in build.py runs
71 # `ruff check --fix` and `ruff format`, which rewrite files in place — CI
72 # should verify the tree, not edit it. Production is also the build that
73 # actually ships, including the onion image-URL rewrite. DEPLOY is left
74 # unset, so main() builds and stops.
75 - |
76 set -eu
77 mkdir -p .ci-bin
78 cat > .ci-bin/rsync <<'EOF'
79 #!/bin/sh
80 echo "rsync was invoked from CI — the deploy path must never run here" >&2
81 exit 1
82 EOF
83 chmod +x .ci-bin/rsync
84 PATH="$PWD/.ci-bin:$HOME/tools/orgo/bin:$PATH" ENV=prod BUILD=true python3 build.py
85
86 - |
87 set -eu
88 pages=$(find .build -name '*.html' | wc -l)
89 echo "built $pages html pages"
90 if [ "$pages" -lt 100 ]; then
91 echo "only $pages pages built; expected the full site" >&2
92 exit 1
93 fi
94
95 # Weekly rather than per-push: most of these links are external, and the web
96 # is flaky enough that a per-push run would fail for reasons no author
97 # controls. A scheduled job does not run on push, so edits here are only
98 # exercised by the next scheduled run.
99 link-check:
100 schedule: "0 7 * * 1"
101 steps:
102 - |
103 set -eu
104 if [ ! -x "$HOME/.cargo/bin/cargo" ]; then
105 curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \
106 | sh -s -- -y --no-modify-path --profile minimal
107 fi
108 v=0.24.0
109 if ! "$HOME/tools/orgo/bin/orgo" --version 2>/dev/null | grep -qx "orgo $v"; then
110 "$HOME/.cargo/bin/cargo" install orgo --version "$v" --locked \
111 --root "$HOME/tools/orgo"
112 fi
113 "$HOME/tools/orgo/bin/orgo" --version
114
115 # Prebuilt release binary, checksum-verified, kept in $HOME between runs.
116 - |
117 set -eu
118 v=0.24.2
119 d=$HOME/tools/lychee
120 if ! "$d/lychee" --version 2>/dev/null | grep -qx "lychee $v"; then
121 rm -rf "$d"
122 mkdir -p "$d"
123 t=lychee-x86_64-unknown-linux-gnu.tar.gz
124 base=https://github.com/lycheeverse/lychee/releases/download/lychee-v$v
125 curl -sSfL -o "$d/$t" "$base/$t"
126 curl -sSfL -o "$d/$t.sha256" "$base/$t.sha256"
127 (cd "$d" && sha256sum -c "$t.sha256")
128 tar -xzf "$d/$t" -C "$d" --strip-components=1
129 fi
130 "$d/lychee" --version
131
132 # orgo directly, not build.py. A production build rewrites img.cleberg.net
133 # URLs to /img/, which is served from a different docroot and would look
134 # like 198 broken links here. The development output keeps them absolute,
135 # so the image host gets checked for real.
136 - $HOME/tools/orgo/bin/orgo build content -o .build-dev --strict
137
138 # Internal links are the half worth gating on: they are entirely within
139 # this repo, so a failure is always a real defect and always fixable here.
140 - |
141 set -eu
142 st=0
143 "$HOME/tools/lychee/lychee" \
144 --offline \
145 --root-dir "$PWD/.build-dev" \
146 --include-verbatim \
147 --no-progress \
148 --format markdown \
149 --output internal.md \
150 '.build-dev/**/*.html' || st=$?
151 cat internal.md 2>/dev/null || echo "no report"
152 exit $st
153
154 # External links are reported, not gated. A dead third-party link is worth
155 # knowing about, but it is not a reason to block the site. Swallowing the
156 # exit status also swallows lychee refusing to start at all, which would
157 # report success while checking nothing, so assert the report has a
158 # summary table in it.
159 - |
160 set -eu
161 st=0
162 "$HOME/tools/lychee/lychee" \
163 --root-dir "$PWD/.build-dev" \
164 --exclude '\.onion' \
165 --exclude '^https?://(localhost|127\.0\.0\.1|0\.0\.0\.0)' \
166 --exclude '(\{|%7B|client_id=$)' \
167 --max-concurrency 8 \
168 --max-retries 2 \
169 --timeout 20 \
170 --accept 200,206,301,302,303,307,308,401,403,429 \
171 --include-verbatim \
172 --no-progress \
173 --format markdown \
174 --output external.md \
175 '.build-dev/**/*.html' || st=$?
176 cat external.md 2>/dev/null || echo "no report"
177 if ! grep -q "Total" external.md 2>/dev/null; then
178 echo "lychee produced no summary — it did not run" >&2
179 exit 1
180 fi
181 echo "external check exited $st; not gating"