| @@ -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" |