My personal web garden & blog.

blog personal website

https://cleberg.net

Commit fc693369d1

fc693369d1fbecb51675b042d82ae86cba246b62

parent: ab2f101694

Verified · cmc ci/build: success ci/lint: success

cmc <hello@cleberg.net> · 2026-08-27T04:00:36Z

Move CI from GitHub Actions to gitbay

Three jobs in .gitbay/ci.yml: lint and build on every push, link-check on
its weekly cron. The runner has no containers and a read-only /usr, so the
pinned ruff, orgo and lychee install into $HOME and are version-checked per
run instead of restored from a cache action.

Dropped with no gitbay equivalent: PR-only triggers, the paths filter on the
link-check workflow, permissions, and artifact upload — the lychee reports
go to the build log.
.gitbay/ci.yml added +173
@@ -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
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 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"
.github/workflows/build.yml deleted −99
@@ -1,99 +0,0 @@
1name: Build
2
3on:
4 pull_request:
5 push:
6 branches: [main]
7
8permissions:
9 contents: read
10
11env:
12 # Pinned so a new ruff release adds rules on your schedule, not mid-PR.
13 RUFF_VERSION: "0.16.4"
14 # orgo is installed from crates.io, so nothing bumps this automatically —
15 # dependabot does not see `cargo install` in a workflow. Raise it by hand when
16 # a new orgo lands, which is also how you find out orgo broke the site.
17 ORGO_VERSION: "0.22.0"
18
19jobs:
20 # Separate from the build so a lint failure and a broken page are distinct
21 # signals. build.py runs `ruff check --fix` and `ruff format` on development
22 # builds; these are the verifying forms of the same two commands, reading
23 # the same ruff.toml, so CI cannot disagree with a local dev build.
24 lint:
25 runs-on: ubuntu-latest
26 steps:
27 - uses: actions/checkout@v7
28
29 - name: Install ruff
30 run: pipx install "ruff==$RUFF_VERSION"
31
32 - name: Lint
33 run: |
34 ruff --version
35 ruff check --no-fix
36
37 - name: Format
38 run: ruff format --check --diff
39
40 build:
41 runs-on: ubuntu-latest
42 steps:
43 - uses: actions/checkout@v7
44
45 # Compiling orgo takes minutes; the binary only changes when the pinned
46 # version does, so key the cache on it.
47 - name: Cache orgo
48 id: cache-orgo
49 uses: actions/cache@v6
50 with:
51 path: ~/.cargo/bin/orgo
52 key: orgo-${{ env.ORGO_VERSION }}-${{ runner.os }}
53
54 - name: Install orgo
55 if: steps.cache-orgo.outputs.cache-hit != 'true'
56 run: |
57 cargo --version
58 cargo install orgo --version "$ORGO_VERSION" --locked
59
60 - name: Show versions
61 run: |
62 orgo --version
63 python3 --version
64
65 # build.py deploys with rsync when DEPLOY=true and ENV=prod. CI passes
66 # neither, but "we did not set the variable" is an assumption, not a
67 # guarantee. Shadowing rsync with a failing stub turns it into one: if the
68 # deploy path is ever reached from CI, the job fails loudly instead of
69 # quietly reaching for a production host.
70 - name: Block the deploy path
71 run: |
72 mkdir -p "$RUNNER_TEMP/bin"
73 cat > "$RUNNER_TEMP/bin/rsync" <<'EOF'
74 #!/usr/bin/env bash
75 echo "::error::rsync was invoked from CI — the deploy path must never run here" >&2
76 exit 1
77 EOF
78 chmod +x "$RUNNER_TEMP/bin/rsync"
79 echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
80
81 # ENV=prod is deliberate. The development path in build.py runs
82 # `ruff check --fix` and `ruff format`, which rewrite files in place —
83 # CI should verify the tree, not edit it. Production is also the build
84 # that actually ships, including the onion image-URL rewrite. DEPLOY is
85 # left unset, so main() builds and stops.
86 - name: Build the site
87 env:
88 ENV: prod
89 BUILD: "true"
90 run: python3 build.py
91
92 - name: Check the build produced pages
93 run: |
94 pages=$(find .build -name '*.html' | wc -l)
95 echo "built $pages html pages"
96 if [ "$pages" -lt 100 ]; then
97 echo "::error::only $pages pages built; expected the full site"
98 exit 1
99 fi
.github/workflows/link-check.yml deleted −100
@@ -1,100 +0,0 @@
1name: Link check
2
3# Weekly rather than per-PR: most of these links are external, and the web is
4# flaky enough that a per-PR run would fail for reasons no author controls.
5# The paths filter still runs it on any PR that edits this workflow, so a change
6# here is validated by the thing it changes.
7on:
8 schedule:
9 - cron: "0 7 * * 1"
10 workflow_dispatch:
11 pull_request:
12 paths: [".github/workflows/link-check.yml"]
13
14permissions:
15 contents: read
16
17env:
18 ORGO_VERSION: "0.22.0"
19
20jobs:
21 links:
22 runs-on: ubuntu-latest
23 steps:
24 - uses: actions/checkout@v7
25
26 - name: Cache orgo
27 id: cache-orgo
28 uses: actions/cache@v6
29 with:
30 path: ~/.cargo/bin/orgo
31 key: orgo-${{ env.ORGO_VERSION }}-${{ runner.os }}
32
33 - name: Install orgo
34 if: steps.cache-orgo.outputs.cache-hit != 'true'
35 run: cargo install orgo --version "$ORGO_VERSION" --locked
36
37 # orgo directly, not build.py. A production build rewrites
38 # img.cleberg.net URLs to /img/, which is served from a different docroot
39 # and would look like 198 broken links here. The development output keeps
40 # them absolute, so the image host gets checked for real.
41 - name: Build the site
42 run: orgo build content -o .build-dev --strict
43
44 # Internal links are the half worth gating on: they are entirely within
45 # this repo, so a failure is always a real defect and always fixable here.
46 - name: Internal links
47 uses: lycheeverse/lychee-action@v2.9.0
48 with:
49 args: >-
50 --offline
51 --root-dir ${{ github.workspace }}/.build-dev
52 --include-verbatim
53 '.build-dev/**/*.html'
54 fail: true
55 output: internal.md
56
57 # External links are reported, not gated. A dead third-party link is worth
58 # knowing about, but it is not a reason to block the site.
59 - name: External links
60 uses: lycheeverse/lychee-action@v2.9.0
61 with:
62 args: >-
63 --root-dir ${{ github.workspace }}/.build-dev
64 --exclude '\.onion'
65 --exclude '^https?://(localhost|127\.0\.0\.1|0\.0\.0\.0)'
66 --exclude '(\{|%7B|client_id=$)'
67 --max-concurrency 8
68 --max-retries 2
69 --timeout 20
70 --accept 200,206,301,302,303,307,308,401,403,429
71 --include-verbatim
72 '.build-dev/**/*.html'
73 fail: false
74 output: external.md
75
76 # `fail: false` stops a dead third-party link failing the job — but it also
77 # swallows lychee refusing to start at all, which reports success while
78 # checking nothing. Assert the report has a summary table in it.
79 - name: External step actually ran
80 run: |
81 if ! grep -q "Total" external.md; then
82 echo "::error::lychee produced no summary — it did not run"
83 cat external.md
84 exit 1
85 fi
86
87 - name: Summary
88 if: always()
89 run: |
90 { echo "## Internal"; cat internal.md 2>/dev/null || echo "no report";
91 echo; echo "## External"; cat external.md 2>/dev/null || echo "no report";
92 } >> "$GITHUB_STEP_SUMMARY"
93
94 - uses: actions/upload-artifact@v7
95 if: always()
96 with:
97 name: link-reports
98 path: |
99 internal.md
100 external.md