| @@ -0,0 +1,173 @@ |
| |
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 | |
| |
10 | jobs: |
| |
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 | set -eu |
| |
50 | v=0.22.0 |
| |
51 | if ! "$HOME/.cargo/bin/orgo" --version 2>/dev/null | grep -qx "orgo $v"; then |
| |
52 | "$HOME/.cargo/bin/cargo" install orgo --version "$v" --locked |
| |
53 | fi |
| |
54 | "$HOME/.cargo/bin/orgo" --version |
| |
55 | python3 --version |
| |
56 | |
| |
57 | # build.py deploys with rsync when DEPLOY=true and ENV=prod. This build |
| |
58 | # passes neither, but "we did not set the variable" is an assumption, not |
| |
59 | # a guarantee. Shadowing rsync with a failing stub turns it into one: if |
| |
60 | # the deploy path is ever reached from CI, the step fails loudly instead |
| |
61 | # of quietly reaching for a production host. |
| |
62 | # |
| |
63 | # ENV=prod is deliberate. The development path in build.py runs |
| |
64 | # `ruff check --fix` and `ruff format`, which rewrite files in place — CI |
| |
65 | # should verify the tree, not edit it. Production is also the build that |
| |
66 | # actually ships, including the onion image-URL rewrite. DEPLOY is left |
| |
67 | # unset, so main() builds and stops. |
| |
68 | - | |
| |
69 | set -eu |
| |
70 | mkdir -p .ci-bin |
| |
71 | cat > .ci-bin/rsync <<'EOF' |
| |
72 | #!/bin/sh |
| |
73 | echo "rsync was invoked from CI — the deploy path must never run here" >&2 |
| |
74 | exit 1 |
| |
75 | EOF |
| |
76 | chmod +x .ci-bin/rsync |
| |
77 | PATH="$PWD/.ci-bin:$HOME/.cargo/bin:$PATH" ENV=prod BUILD=true python3 build.py |
| |
78 | |
| |
79 | - | |
| |
80 | set -eu |
| |
81 | pages=$(find .build -name '*.html' | wc -l) |
| |
82 | echo "built $pages html pages" |
| |
83 | if [ "$pages" -lt 100 ]; then |
| |
84 | echo "only $pages pages built; expected the full site" >&2 |
| |
85 | exit 1 |
| |
86 | fi |
| |
87 | |
| |
88 | # Weekly rather than per-push: most of these links are external, and the web |
| |
89 | # is flaky enough that a per-push run would fail for reasons no author |
| |
90 | # controls. A scheduled job does not run on push, so edits here are only |
| |
91 | # exercised by the next scheduled run. |
| |
92 | link-check: |
| |
93 | schedule: "0 7 * * 1" |
| |
94 | steps: |
| |
95 | - | |
| |
96 | set -eu |
| |
97 | if [ ! -x "$HOME/.cargo/bin/cargo" ]; then |
| |
98 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ |
| |
99 | | sh -s -- -y --no-modify-path --profile minimal |
| |
100 | fi |
| |
101 | v=0.22.0 |
| |
102 | if ! "$HOME/.cargo/bin/orgo" --version 2>/dev/null | grep -qx "orgo $v"; then |
| |
103 | "$HOME/.cargo/bin/cargo" install orgo --version "$v" --locked |
| |
104 | fi |
| |
105 | "$HOME/.cargo/bin/orgo" --version |
| |
106 | |
| |
107 | # Prebuilt release binary, checksum-verified, kept in $HOME between runs. |
| |
108 | - | |
| |
109 | set -eu |
| |
110 | v=0.24.2 |
| |
111 | d=$HOME/tools/lychee |
| |
112 | if ! "$d/lychee" --version 2>/dev/null | grep -qx "lychee $v"; then |
| |
113 | rm -rf "$d" |
| |
114 | mkdir -p "$d" |
| |
115 | t=lychee-x86_64-unknown-linux-gnu.tar.gz |
| |
116 | base=https://github.com/lycheeverse/lychee/releases/download/lychee-v$v |
| |
117 | curl -sSfL -o "$d/$t" "$base/$t" |
| |
118 | curl -sSfL -o "$d/$t.sha256" "$base/$t.sha256" |
| |
119 | (cd "$d" && sha256sum -c "$t.sha256") |
| |
120 | tar -xzf "$d/$t" -C "$d" --strip-components=1 |
| |
121 | fi |
| |
122 | "$d/lychee" --version |
| |
123 | |
| |
124 | # orgo directly, not build.py. A production build rewrites img.cleberg.net |
| |
125 | # URLs to /img/, which is served from a different docroot and would look |
| |
126 | # like 198 broken links here. The development output keeps them absolute, |
| |
127 | # so the image host gets checked for real. |
| |
128 | - $HOME/.cargo/bin/orgo build content -o .build-dev --strict |
| |
129 | |
| |
130 | # Internal links are the half worth gating on: they are entirely within |
| |
131 | # this repo, so a failure is always a real defect and always fixable here. |
| |
132 | - | |
| |
133 | set -eu |
| |
134 | st=0 |
| |
135 | "$HOME/tools/lychee/lychee" \ |
| |
136 | --offline \ |
| |
137 | --root-dir "$PWD/.build-dev" \ |
| |
138 | --include-verbatim \ |
| |
139 | --no-progress \ |
| |
140 | --format markdown \ |
| |
141 | --output internal.md \ |
| |
142 | '.build-dev/**/*.html' || st=$? |
| |
143 | cat internal.md 2>/dev/null || echo "no report" |
| |
144 | exit $st |
| |
145 | |
| |
146 | # External links are reported, not gated. A dead third-party link is worth |
| |
147 | # knowing about, but it is not a reason to block the site. Swallowing the |
| |
148 | # exit status also swallows lychee refusing to start at all, which would |
| |
149 | # report success while checking nothing, so assert the report has a |
| |
150 | # summary table in it. |
| |
151 | - | |
| |
152 | set -eu |
| |
153 | st=0 |
| |
154 | "$HOME/tools/lychee/lychee" \ |
| |
155 | --root-dir "$PWD/.build-dev" \ |
| |
156 | --exclude '\.onion' \ |
| |
157 | --exclude '^https?://(localhost|127\.0\.0\.1|0\.0\.0\.0)' \ |
| |
158 | --exclude '(\{|%7B|client_id=$)' \ |
| |
159 | --max-concurrency 8 \ |
| |
160 | --max-retries 2 \ |
| |
161 | --timeout 20 \ |
| |
162 | --accept 200,206,301,302,303,307,308,401,403,429 \ |
| |
163 | --include-verbatim \ |
| |
164 | --no-progress \ |
| |
165 | --format markdown \ |
| |
166 | --output external.md \ |
| |
167 | '.build-dev/**/*.html' || st=$? |
| |
168 | cat external.md 2>/dev/null || echo "no report" |
| |
169 | if ! grep -q "Total" external.md 2>/dev/null; then |
| |
170 | echo "lychee produced no summary — it did not run" >&2 |
| |
171 | exit 1 |
| |
172 | fi |
| |
173 | echo "external check exited $st; not gating" |