name: CI
on:
push:
branches: [main]
pull_request:
env:
CARGO_TERM_COLOR: always
RUST_BACKTRACE: short
permissions:
contents: read
jobs:
build-and-test:
name: build, test
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
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
- name: Doctests
run: cargo test --locked --doc
fmt:
name: rustfmt
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- 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
- 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
- run: cargo test --locked
machete:
name: cargo-machete (unused deps)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: bnjbvr/cargo-machete@5a6550345dd59f4450caa4cf7779438e875e8914
deny:
name: cargo-deny (advisories, licences, bans)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- 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
- 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
- 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: Install candor (pinned)
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 && ./install.sh)
# `install.sh` DEGRADES rather than fails when the nightly
# build doesn't work: it builds only the stable crates, and
# `cargo candor` keeps working via the syntactic scanner. That
# scanner reports `ui::draw` with no Exec/Ipc at all, while the
# type-resolved lint finds both through the embedded shell pane
# — i.e. it misses exactly the class this policy exists to
# catch.
#
# `cargo candor policy` is dylint-only and would fail closed
# (exit 2) rather than quietly scan, so this is belt-and-braces
# — but it names the actual cause instead of leaving a bare
# "policy NOT evaluated" to be diagnosed from scratch.
ls ~/.candor/lib/libcandor@*.so >/dev/null 2>&1 || {
echo "::error::candor installed without the nightly lint dylib — the deep engine is unavailable, and the stable scanner cannot see the embedded shell pane's Exec/Ipc"
exit 1
}
- name: Enforce .candor/policy
env:
CARGO_TERM_COLOR: never
run: |
# `cargo candor policy`, not a raw `cargo dylint`.
#
# This job used to invoke the dylint backend directly and then
# reconstruct, in bash, three things candor already guarantees:
# whether the lint actually ran, whether the run was cached,
# and which findings it produced. Each was inferred by grepping
# cargo's human-facing progress output, and it broke twice on
# cosmetic changes to that output — first when colour codes
# split "Checking ebman", then when adding a build.rs in 0.35.0
# made cargo say "Compiling ebman" instead. Neither was a
# policy violation; both were red builds.
#
# The wrapper answers all three from machine signals:
# * exit codes are pinned (candor-spec §3.3) — 0 evaluated
# clean, 1 evaluated with violations, 2 NOT evaluated, and
# a 2 is fail-closed by design;
# * the verdict rides on CANDOR_VIOLATIONS, a sentinel file
# the engine appends to, so a message reword or a dylint
# output-stream change cannot turn the gate green;
# * a cache hit that emits no diagnostics is detected and
# re-run by the wrapper rather than mistaken for a pass.
#
# It is also a STRONGER gate than what it replaces: `policy`
# snapshots every workspace crate first and enforces with the
# siblings loaded, so an effect or host that physically lives
# in another crate is caught at the boundary that forbids it.
# The raw invocation saw one crate at a time.
rc=0
cargo candor policy .candor/policy --gate-json verdict.json || rc=$?
case "$rc" in
0) ;;
1)
echo "::error::effect/layer policy violation — see .candor/policy"
[ -f verdict.json ] && cat verdict.json
exit 1 ;;
2)
# NOT a pass. Unreadable policy, a build failure, a missing
# engine — anything that means the gate could not actually
# check. Fail-closed is the whole point.
echo "::error::candor policy NOT evaluated (exit 2) — failing closed, this is not a clean run"
exit 1 ;;
*)
echo "::error::candor exited $rc, which is outside the documented 0/1/2 contract"
exit 1 ;;
esac
# A clean exit has to be corroborated by the document, not
# taken on trust. Exit 0 with no verdict would mean the two
# signals disagree, and the safe reading of that is failure.
[ -f verdict.json ] || {
echo "::error::candor exited 0 but wrote no verdict document"
exit 1
}
cat verdict.json
# `jq '.ok // "missing"'` would be wrong here: `//` treats
# `false` as empty, so a document honestly saying `"ok": false`
# would be reported as `ok=missing`. The failure is the same
# either way, but the message would send the next reader
# looking for a malformed document instead of a real refusal.
ok=$(jq -r 'if has("ok") then (.ok|tostring) else "missing" end' verdict.json)
# `refused: true` carries no `violations` key by design, so a
# naive read of a refusal document is already the fail-closed
# one. Check it explicitly anyway.
refused=$(jq -r 'if has("refused") then (.refused|tostring) else "false" end' verdict.json)
if [ "$ok" != "true" ] || [ "$refused" = "true" ]; then
echo "::error::candor exited 0 but its verdict says ok=$ok refused=$refused"
exit 1
fi
# Prove the reader can fail, on every run, before trusting it
# to pass. A gate with no demonstration that it rejects
# anything is one you are trusting on assertion — this repo has
# found three guards that were silently passing. Same
# discipline as `scripts/mutate.sh`.
canary='{"ok":false,"refused":true,"reason":"canary"}'
if [ "$(echo "$canary" | jq -r '.ok')" = "true" ]; then
echo "::error::the verdict reader reports a refusal document as clean"
exit 1
fi
echo "candor: evaluated, clean."