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
name: CI
on:
push:
branches:
pull_request:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: short
# Least privilege for every job below. Without an explicit block a job
# inherits the repository default, which can be write-all — CodeQL flags
# each one (`actions/missing-workflow-permissions`). Nothing in CI needs
# more than reading the checkout.
permissions:
contents: read
jobs:
build-and-test:
name: build, test
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os:
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Build
run: cargo build --locked --all-targets
- name: Test
run: cargo test --locked --all-targets
# `--all-targets` silently SKIPS doctests. Both current ones are
# `ignore`d so nothing is lost today, but the gate did not exist —
# a doctest added tomorrow would never run.
- name: Doctests
run: cargo test --locked --doc
fmt:
name: rustfmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# PINNED. `@stable` floats, and these two jobs gate on lint/format
# output — so a new stable shipping a new clippy lint or a rustfmt
# tweak turns main red for reasons unrelated to any change. That is
# worse here than on a human-driven project: an agent mid-task will
# fix the unrelated lint inside whatever it was doing, and the diff
# arrives mixed. Every other required gate in this repo is pinned
# (cargo-machete to a commit, candor-scan to a version,
# mcp-publisher to a sha256) for exactly this reason; these two
# were the exception. Bump deliberately.
- uses: dtolnay/rust-toolchain@1.96.0
with:
components: rustfmt
- run: cargo fmt --all -- --check
clippy:
name: clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# PINNED. `@stable` floats, and these two jobs gate on lint/format
# output — so a new stable shipping a new clippy lint or a rustfmt
# tweak turns main red for reasons unrelated to any change. That is
# worse here than on a human-driven project: an agent mid-task will
# fix the unrelated lint inside whatever it was doing, and the diff
# arrives mixed. Every other required gate in this repo is pinned
# (cargo-machete to a commit, candor-scan to a version,
# mcp-publisher to a sha256) for exactly this reason; these two
# were the exception. Bump deliberately.
- uses: dtolnay/rust-toolchain@1.96.0
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- run: cargo clippy --locked --all-targets -- -D warnings
msrv:
name: msrv (rust 1.94.1)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.94.1
- uses: Swatinem/rust-cache@v2
- run: cargo build --locked
# Build alone let a test-only API that doesn't exist on the MSRV
# ship green — the gate claimed "builds on 1.94.1" while the suite
# it gates was never compiled against it.
- run: cargo test --locked
machete:
name: cargo-machete (unused deps)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# Found `tokio-stream` and `aws-types` on its first run — both
# were direct dependencies with zero references in src/.
#
# Pinned to a commit, not `@main`. This is a REQUIRED gate: on a
# moving branch it can break, or be subverted, underfoot — and a
# required gate that changes without review is the shape this
# repo already fixed once for the candor lint. Bump deliberately.
- uses: bnjbvr/cargo-machete@5a6550345dd59f4450caa4cf7779438e875e8914
deny:
name: cargo-deny (advisories, licences, bans)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# 61 direct dependencies including the whole AWS SDK, and until
# now nothing checked them against RUSTSEC at all.
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check advisories licenses bans sources
semver:
name: cargo-semver-checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
# This job used to be PR-only, on the reasoning that diffing the
# public API against the last published release is noise on a
# main push between tags. That half was right — between tags the
# manifest version EQUALS the published one, so any API change
# reads as "same version, different API" and main stays red until
# someone bumps. But it also meant the gate never ran on the one
# commit whose version claim actually ships: the release commit.
# 0.31.0 shipped a breaking change (a new pub field on a
# constructible struct) that nothing caught — it was found by
# running this tool by hand after the tag.
#
# So: gate on whether there is an unpublished version to validate.
# Between tags manifest == published and there is nothing to say;
# on the release commit they differ and the claim gets checked
# before the tag rather than after it.
- id: gate
name: Is there an unpublished version to validate?
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "run=true" >> "$GITHUB_OUTPUT"
echo "pull request — always check."
exit 0
fi
manifest=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
published=$(curl -sS --max-time 20 -H 'User-Agent: ebman-ci' \
https://crates.io/api/v1/crates/ebman | jq -r '.crate.max_version' || true)
echo "manifest=$manifest published=$published"
if [ -z "$manifest" ]; then
echo "::error::could not read the package version out of Cargo.toml"
exit 1
fi
# Fail toward running the check, never toward skipping it: a
# crates.io blip must not silently turn the gate off.
if [ -z "$published" ] || [ "$published" = "null" ]; then
echo "run=true" >> "$GITHUB_OUTPUT"
echo "crates.io unreachable — checking anyway."
elif [ "$manifest" = "$published" ]; then
echo "run=false" >> "$GITHUB_OUTPUT"
echo "$manifest is already published — no new claim to validate."
else
echo "run=true" >> "$GITHUB_OUTPUT"
echo "$manifest is not published yet — validating the bump."
fi
# ebman is lib + bin on crates.io, so a signature change in the
# lib is a semver event. 0.30.2 shipped one that a human review
# caught (`ui::series_anomaly_label` gained a parameter) — the
# kind of thing that decides whether the next tag is 0.30.3 or
# 0.31.0, and not something to notice by reading.
- uses: obi1kenobi/cargo-semver-checks-action@v2
if: steps.gate.outputs.run == 'true'
with:
feature-group: default-features
candor:
name: candor (effect/layer policy)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install dylint
run: cargo install cargo-dylint dylint-link --locked
- name: Build the candor lint
run: |
# Pinned, not HEAD. This gate can fail the release, so it must
# not change underfoot: an unrelated push to candor-rust would
# otherwise land in the next ebman CI run with no review here.
# Bump deliberately.
git clone --filter=blob:none https://github.com/tombaldwin/candor-rust ~/candor
(cd ~/candor && git checkout --quiet a7f01138a1ceea6b2e05dc92995ee47ee9219201 && cargo build)
# The DYLINT backend, not `candor-scan`. The syntactic scanner
# reports `ui::draw` with no Exec/Ipc at all, while the
# type-resolved lint finds both through the embedded shell pane —
# so gating on a scan report would miss exactly the class this
# policy exists to catch.
- name: Enforce .candor/policy
# Plain text, overriding the workflow-level `always`. The three
# greps below parse this output; with colour on, cargo writes
# "Checking\e[0m ebman" and the "did it actually run" guard
# fails a clean tree. It did exactly that once.
env:
CARGO_TERM_COLOR: never
run: |
LIB=$(ls ~/candor/target/debug/libcandor@*.so)
# Force a re-analysis. `rust-cache` restores the check
# artefacts, and a cached `cargo dylint` prints "Finished"
# without re-running the lint — so the gate would pass on
# stale results from before the change under review.
touch src/lib.rs src/main.rs
# No `|| true` on the lint. Swallowing its exit code means a
# crash, a missing lib or a compile error produces no AS-EFF
# lines, the grep below finds nothing, and the gate passes —
# a gate that reports success when it did not run.
if ! out=$(CANDOR_POLICY=.candor/policy cargo dylint --lib-path "$LIB" 2>&1); then
echo "$out"
echo "::error::candor lint failed to RUN (not a policy violation)"
exit 1
fi
echo "$out"
# And prove it actually analysed this crate, for the same
# reason: a lint that silently no-ops emits no findings.
if ! echo "$out" | grep -qE 'Checking.*ebman'; then
echo "::error::candor lint produced no analysis of ebman — gate did not run"
exit 1
fi
# Fail on ANY effect/layer finding, then allowlist by exact id.
#
# This used to grep for `AS-EFF-00[689]` — three hardcoded ids
# out of a numbering scheme we do not control. A fourth
# violation class, an upstream renumber at the next candor
# bump, or a policy rule emitting a different id, and the gate
# goes green on a real violation. That is the same shape as the
# `starts_with("pub ")` guard that would have gone blind on a
# visibility edit, and the `split("//")` stripper that let a
# clipboard call hide behind a URL literal. Both were live.
#
# Inverted: anything matching AS-EFF fails unless its id is in
# ACCEPTED. Empty today — the policy is fully enforced — so a
# new id fails loudly instead of passing silently.
ACCEPTED=""
# Prove the check can fail, on every run, before trusting it to
# pass. A gate with no demonstration that it rejects anything is
# a gate you are trusting on assertion — and this repo has found
# three guards that were silently passing. Same discipline as
# `scripts/mutate.sh`: a test that cannot fail is not evidence.
canary=$(echo "warning: AS-EFF-999 canary" | grep -oE "AS-EFF-[0-9]+" | sort -u)
if [ "$canary" != "AS-EFF-999" ]; then
echo "::error::the candor finding-extractor does not extract findings"
exit 1
fi
findings=$(echo "$out" | grep -oE "AS-EFF-[0-9]+" | sort -u || true)
for id in $findings; do
case " $ACCEPTED " in
*" $id "*) echo "note: $id is an accepted exception" ;;
*)
echo "::error::effect/layer policy violation $id — see .candor/policy"
exit 1 ;;
esac
done