1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
name: CI
# Build / test / lint / audit gate. Runs on GitHub-hosted `ubuntu-latest` — free
# and unlimited for this public repo, with the 7 jobs running in parallel. (They
# used to be pinned to the single self-hosted ci-VM runner to save private-repo
# minutes; that rationale went away when the repo went public.)
# CodeQL, dependency-review, and OSSF Scorecard live in their own workflows
# (they are designed for GitHub-hosted runners — see those files + the README).
# The upgrade-boot gate is upgrade-boot.yml (and, on a tag, release-image.yml).
on:
push:
branches:
pull_request:
branches:
workflow_dispatch: # allow manual runs from the Actions tab / gh CLI
concurrency:
# One in-flight CI run per ref: a new push to a PR cancels that PR's own
# in-progress run rather than letting a superseded 7-job build finish. Keyed on
# the PR number (falling back to the ref for push/dispatch). On GitHub-hosted
# runners the jobs also run in parallel — so this is purely about not wasting a
# run on outdated commits.
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
# Least privilege: every job only checks out the tree and hits package registries
# — none push, tag, comment, or upload — so trim the default GITHUB_TOKEN to read.
permissions:
contents: read
jobs:
rust:
name: Rust
# FORK-PR GUARD (applied to every job): same-repo PRs + push / dispatch only;
# fork PRs get NO CI. Originally to keep untrusted fork code (a slipped-in
# build.rs / proc-macro / npm lifecycle script) off the self-hosted homelab
# runner. Now that CI is GitHub-hosted (sandboxed; secrets aren't exposed to
# fork-PR workflows) that RCE risk is gone — so this is now a POLICY choice:
# drop this `if:` to give contributor PRs CI.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ubuntu-latest # GitHub-hosted: free + parallel now the repo is public
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Install stable toolchain (clippy + rustfmt)
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
with:
toolchain: stable # required — SHA-pinned, so the @ref no longer selects the toolchain
components: clippy, rustfmt
- name: Cache cargo
uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
- name: Build
run: cargo build --all-targets --locked
- name: Test
run: cargo test --locked
- name: Clippy
run: cargo clippy --all-targets -- -D warnings
- name: Rustfmt
run: cargo fmt --all --check
# This codebase routes a reader between doc comments by intra-doc link, so
# a dangling link is not cosmetic — it renders as plain text and the
# reference silently stops being one. #189 deleted a constant that four
# doc comments pointed at and nothing noticed, because nothing ran this.
- name: Rustdoc (intra-doc links)
run: cargo doc --no-deps --locked
env:
RUSTDOCFLAGS: -D warnings
cargo-deny:
name: cargo deny (licenses + bans + sources)
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} # fork-PR guard (see rust job)
runs-on: ubuntu-latest # GitHub-hosted: free + parallel now the repo is public
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
# cargo-deny shells out to `cargo metadata` to build the crate graph, so
# this job needs a Rust toolchain (cargo on PATH) even though it only runs
# the license/ban/source gates.
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
with:
toolchain: stable # required — SHA-pinned ref no longer selects the toolchain
# Install a pinned cargo-deny binary directly (rather than
# EmbarkStudios/cargo-deny-action@v2, whose bundled RustSec advisory-DB
# fetch broke the gate on the old self-hosted runner every run).
# Config is deny.toml at the repo root; feature evaluation is set there
# (`[graph] all-features = true`).
- name: Install cargo-deny
uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2
with:
tool: cargo-deny@0.20.2
# We deliberately DO NOT run the `advisories` check here: it needs the
# RustSec advisory DB, which the dedicated `cargo audit` job below already
# covers (same DB, same fail-on-vuln semantics). This job enforces the
# AGPL-compatible license allowlist, duplicate/ban hygiene, and the
# crates.io-only source policy. (Not `--offline`: `cargo metadata` needs
# the registry index to resolve the dependency graph.)
- name: cargo-deny (licenses + bans + sources)
run: cargo-deny check bans licenses sources
cargo-audit:
name: cargo audit (RustSec)
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} # fork-PR guard (see rust job)
runs-on: ubuntu-latest # GitHub-hosted: free + parallel now the repo is public
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Install stable toolchain
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
with:
toolchain: stable # required — SHA-pinned ref no longer selects the toolchain
- name: Cache cargo
uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
# Dedicated RustSec scan of Cargo.lock. Fails the build on any known
# vulnerability. Un-actionable transitive advisories (no fixed release, or
# a fix that needs a semver-major bump) are whitelisted in deny.toml's
# `[advisories] ignore = [...]`; mirror any such id here as an explicit
# `--ignore RUSTSEC-YYYY-NNNN` flag. Keep the list SHORT and documented.
# Install the pinned cargo-audit BINARY (like the cargo-deny job) rather than
# `cargo install ... || true`, which masked an install failure and let the
# next step die command-not-found — a green job that skipped the RustSec scan.
- name: Install cargo-audit
uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2
with:
tool: cargo-audit@0.22.2
- name: cargo audit
# `-D warnings` promotes unmaintained/unsound advisories to failures too.
run: cargo audit -D warnings
sidecar:
name: OAuth sidecar
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} # fork-PR guard (see rust job)
runs-on: ubuntu-latest # GitHub-hosted: free + parallel now the repo is public
defaults:
run:
working-directory: oauth-sidecar
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Setup Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
with:
node-version: 24
cache: npm
cache-dependency-path: oauth-sidecar/package-lock.json
- name: Install
run: npm ci
- name: Build
run: npm run build
- name: Typecheck
run: npm run typecheck
- name: Test
# The sidecar's 71 tests gated NOTHING until this step existed. Every
# other job here runs its suite (`rust`, `bot`); this one went install →
# build → typecheck → lint → format → audit, so a `npm test` failure
# could not fail a PR. The tests cover the parts of the sidecar with no
# other safety net: session-store encryption and the plaintext migrate-
# on-read path, the reaper's absolute/idle TTLs, `StoreError` tagging,
# and `purgeDid`.
#
# `npm test` runs a `test:guard` step first because `node --test` EXITS 0
# when its argument matches no files — a glob pointing at a moved output
# directory would have made this step green while running nothing, which
# is the exact failure this job exists to prevent. Demonstrated, not
# hypothetical: the previous `dist-test/test/*.test.js` also silently
# skipped any test file in a subdirectory.
run: npm test
- name: Lint (oxlint, --max-warnings 0)
run: npm run lint
- name: Format check (Prettier)
# Scoped to the tooling/config files the CI pipeline owns. The hand-
# authored src/ + test/ predate Prettier and are enforced for
# CORRECTNESS by oxlint above; a repo-wide `prettier --write` is left as
# a deliberate one-time follow-up (`npm run format:write`) so this gate
# never fights the code-fix workflows over whitespace. See
# .github/workflows/README.md.
run: npm run format:check
- name: npm audit (prod deps, high+)
# Sidecar runtime deps only (--omit=dev); dev tooling churn shouldn't
# break CI. Fails on high/critical advisories.
#
# **A green result here is not an all-clear, and that is not a
# hypothetical.** `npm audit` queries the npm registry's advisory feed
# only. This step once passed — at EVERY severity, not just high — while
# fastify 5.12.1 was in the lockfile and four PUBLISHED high-severity
# advisories applied to it, one an authentication bypass
# (GHSA-p68q-wchp-6fh7, `>= 4.0.0, < 5.12.2`).
#
# That particular exposure is CLOSED: #125 (b74c4bf) took fastify to
# 5.12.3, past the 5.12.2 patch line for all four. The lesson is what
# remains — the scanner said nothing, before and after.
#
# They were visible on GitHub's REPO-level advisory database
# (`gh api repos/fastify/fastify/security-advisories`) and nowhere a
# scanner looks: `gh api advisories/<id>` 404s for all four, and OSV
# holds them only under CVE aliases with a GIT range and no npm package
# mapping — so an OSV query for `fastify@5.12.1` also returns nothing.
# npm audit, OSV-Scanner and Dependabot would all have said the same.
#
# There is no CI step that closes this today. What closes it is reading
# the upstream release notes when a dependabot PR bumps a
# network-facing dependency, and checking
# `repos/<owner>/<repo>/security-advisories` directly.
run: npm audit --omit=dev --audit-level=high
bot:
name: Invite bot (build + test + lint + deny + audit)
# The follow→invite bot is a STANDALONE workspace (bot/Cargo.toml has its own
# `[workspace]`), so the root Rust / cargo-deny / cargo-audit jobs above NEVER
# touch it. This job gives the bot the SAME gates as the app, scoped to `bot/`.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} # fork-PR guard (see rust job)
runs-on: ubuntu-latest # GitHub-hosted: free + parallel now the repo is public
defaults:
run:
working-directory: bot
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Install stable toolchain (clippy + rustfmt)
uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable
with:
toolchain: stable # required — SHA-pinned, so the @ref no longer selects the toolchain
components: clippy, rustfmt
- name: Cache cargo
uses: Swatinem/rust-cache@f0d9c3887740aee45f6153b24b3a6b815192ec16 # v2
with:
workspaces: bot # cache the bot workspace's target/, not the repo root
- name: Build
run: cargo build --all-targets --locked
- name: Test
run: cargo test --locked
- name: Clippy
run: cargo clippy --all-targets -- -D warnings
- name: Rustfmt
run: cargo fmt --all --check
# The bot's supply-chain gates live here (mirroring the app's jobs). The
# bot's own bot/deny.toml carries the AGPL-compatible license allowlist,
# including CDLA-Permissive-2.0 (webpki-root-certs) and MIT (zmij); r-efi's
# `MIT OR Apache-2.0 OR LGPL-2.1-or-later` OR-expression resolves to a
# permissive branch automatically.
- name: Install cargo-deny
uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2
with:
tool: cargo-deny@0.20.2
- name: cargo-deny (licenses + bans + sources)
run: cargo-deny check bans licenses sources
# Install the pinned cargo-audit BINARY (mirroring the cargo-deny step above)
# rather than `cargo install ... || true`: the `|| true` masked an install
# FAILURE, so the next step would die "cargo-audit: command not found" — a
# green-looking job that never actually ran the RustSec scan. install-action
# fails the job loudly if the tool can't be fetched. (Cheap nit.)
- name: Install cargo-audit
uses: taiki-e/install-action@83ac0ad63c0167e6f06796fab0fce28db1bf3db0 # v2
with:
tool: cargo-audit@0.22.2
- name: cargo audit
# `-D warnings` promotes unmaintained/unsound advisories to failures too.
run: cargo audit -D warnings
secrets:
name: Secret scan (gitleaks)
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} # fork-PR guard (see rust job)
runs-on: ubuntu-latest # GitHub-hosted: free + parallel now the repo is public
env:
GITLEAKS_VERSION: 8.28.0
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0 # full history so gitleaks can scan past commits too
# Run the gitleaks BINARY directly rather than gitleaks/gitleaks-action@v2.
# The action gates on a GITLEAKS_LICENSE (required for any repo owned by a
# GitHub *org*/account it deems non-free) and does GITHUB_TOKEN-scoped PR
# bookkeeping — both of which trip on a personal-account private repo and
# failed the job regardless of findings. The binary is licence-free for
# this use and has none of that PR/org coupling.
- name: Install gitleaks
run: |
set -euo pipefail
url="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
curl -sSfL "$url" -o /tmp/gitleaks.tar.gz
tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks
install -m 0755 /tmp/gitleaks "$RUNNER_TEMP/gitleaks"
"$RUNNER_TEMP/gitleaks" version
# Scan the full git history (checkout above sets fetch-depth: 0). Config is
# .gitleaks.toml at the repo root (extends the default ruleset + allowlists
# the documented test fixtures/placeholders). --redact keeps any match out
# of the logs; a non-zero exit (leak found) fails the job.
- name: gitleaks (history + tree)
run: |
"$RUNNER_TEMP/gitleaks" git --config .gitleaks.toml --redact --exit-code 1
caddyfile:
name: Caddyfile (validate both OAuth routings)
# Nothing validated deploy/Caddyfile. It is baked into the image and parsed
# at container start by a Caddy running under the two-phase entrypoint — and
# that entrypoint brings the WHOLE box down if any child dies, so a syntax
# error here is not a degraded edge, it is a crash-loop on a single machine
# with no on-call. The only prior check was whoever edited it running
# `caddy validate` by hand, if they thought to.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ubuntu-latest # GitHub-hosted: free + parallel now the repo is public
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
# **The digest comes from the Dockerfile, not from a second pin here.**
# Validating with a different Caddy than the one that ships is how a config
# passes CI and crash-loops in production: directives and log filters are
# version-sensitive (`request>uri query` needs a recent 2.x). Reading the
# pin keeps the two in lockstep by construction rather than by discipline,
# and fails loudly if the Dockerfile stops pinning by digest.
- name: Read the pinned Caddy image from the Dockerfile
id: caddy
run: |
set -euo pipefail
image=$(grep -oE 'caddy:[0-9a-z.-]+@sha256:[0-9a-f]{64}' Dockerfile | head -1)
if [ -z "$image" ]; then
echo "::error file=Dockerfile::no digest-pinned caddy image found — this job validates against whatever the Dockerfile ships, so it cannot run without one"
exit 1
fi
echo "image=$image" >> "$GITHUB_OUTPUT"
echo "validating against $image"
# **Both routings, because the entrypoint installs ONE of them at runtime.**
# `container-entrypoint.sh` picks deploy/caddy-oauth-{rust,sidecar}.conf by
# FEATHERREADER_REPO_BACKEND and writes it to /etc/caddy/oauth-routes.conf,
# which Caddyfile imports. Validating only the live one would let the other
# rot until a rollback tried to boot it — and a rollback is exactly when
# nobody wants to discover a parse error.
#
# **What this catches, measured rather than assumed.** Imported files ARE
# covered: a bogus directive added to caddy-oauth-rust.conf fails with
# `oauth-routes.conf:30: unrecognized directive`, naming the imported file
# and line. What it does NOT catch is anything syntactically legal — a bare
# `reverse_proxy` with no upstream validates clean, and an unterminated
# block near the end of an imported file can be absorbed by the enclosing
# site block's brace. So this is a parse gate, not a behaviour gate: it
# stops the crash-loop, it does not prove the routing is right.
- name: caddy validate — both OAuth routings
run: |
set -euo pipefail
for variant in rust sidecar; do
conf="deploy/caddy-oauth-${variant}.conf"
[ -f "$conf" ] || { echo "::error::missing $conf"; exit 1; }
echo "::group::${variant}"
docker run --rm \
-v "$PWD/deploy/Caddyfile:/etc/caddy/Caddyfile:ro" \
-v "$PWD/${conf}:/etc/caddy/oauth-routes.conf:ro" \
--entrypoint caddy \
"${{ steps.caddy.outputs.image }}" \
validate --adapter caddyfile --config /etc/caddy/Caddyfile
echo "::endgroup::"
done
teardown:
name: Teardown script (revoke order + refusals)
# deploy/teardown.sh is the one script that deletes every user's data, and
# nothing exercised it: on the rust backend it revoked no sessions at all
# (#257) and no check noticed. This runs it against throwaway SQLite files
# with stubbed revoke/stop commands and pins the order and the refusals.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ubuntu-latest # GitHub-hosted: free + parallel now the repo is public
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Ensure sqlite3
run: command -v sqlite3 || (sudo apt-get update && sudo apt-get install -y sqlite3)
- name: Test deploy/teardown.sh
run: bash scripts/test-teardown.sh