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