rust.yml
In .github/workflows/
Preview
Code · 23.7 KiBname: "CI: Rust"
# Scope note: the Linux Rust gates (fmt/clippy/build/nextest/doctests, msrv,
# codegen-freshness, supply-chain, docs, geiger, coverage) run on Google Cloud
# Build — see cloudbuild/README.md. This workflow keeps the legs Cloud
# Build's Linux containers CAN'T run: the macOS build/test leg and the
# cross-platform keystore-backends matrix (macOS native keystore). Windows is
# not a supported target (MKIT-6; see docs/INVARIANTS.md) and has no leg here.
# Server feature-combination clippy on main and patch API checks on PRs also
# run here, feeding the stable Rust CI gate below.
#
# Cost note: macOS runners bill at 10x Linux minutes. `build-and-test` (macOS)
# therefore runs automatically only on `push` to `main` (bounding the 10x
# cost to post-merge, not every PR push) plus `workflow_dispatch` for
# on-demand runs. `keystore-backends` (2-OS matrix) stays the heaviest job
# and remains MANUAL ONLY — gated to `workflow_dispatch` and nothing else; it
# does not run automatically on any PR push, review approval, or push to
# main. Trigger it explicitly (Actions tab, or
# `gh workflow run rust.yml --ref <branch>`) when you specifically need
# keystore coverage — see docs/RELEASE.md's pre-release checklist, which
# calls this out explicitly. Fast per-push feedback for everything else
# comes from the Linux gate on Cloud Build (cloudbuild/ci.yaml) plus the job
# above.
on:
# `push` to main runs build-and-test (macOS) automatically — see the Cost
# note above. No `paths:` filter on either trigger: the workflow ALWAYS
# triggers on PRs (and pushes to main) so its required check (ci-gate) is
# never absent. build-and-test runs on `push` (and workflow_dispatch);
# keystore-backends stays dispatch-only. Mirrors the same pattern already
# used by web.yml/mcp.yml.
push:
branches:
- main
pull_request:
branches:
- main
# The only way to actually run keystore-backends: manual dispatch, on
# demand, against any branch/ref. Also usable to force-run build-and-test
# against an arbitrary ref.
workflow_dispatch:
permissions:
contents: read
# One run per PR (or per branch on manual dispatch); newer PR events cancel
# older ones. Manual dispatch runs are never auto-cancelled.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
CARGO_TERM_COLOR: always
RUST_VERSION: "1.95.0"
# Match Swatinem/rust-cache's expectations.
CARGO_INCREMENTAL: 0
jobs:
server-feature-clippy:
name: Server feature combinations (${{ matrix.crate }})
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 30
env:
CARGO_PROFILE_DEV_DEBUG: '0'
strategy:
fail-fast: false
matrix:
crate: [mkit-server, mkit-server-worker, mkit-rpc, mkit-client, mkit-sync]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./.github/actions/setup-rust
with:
toolchain: ${{ env.RUST_VERSION }}
components: clippy
cache-prefix: server-feature-clippy-${{ matrix.crate }}
protoc: "false"
- uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2
with:
tool: cargo-hack
# Library-only avoids dev-dependency feature unification. Depth two
# and the per-crate timeout bound the combinatorial cost. SQL is
# unconditional in the Workers adapter, outside the core feature graph.
- name: Clippy feature powerset (depth two)
working-directory: rust
run: cargo hack clippy --locked -p ${{ matrix.crate }} --lib --feature-powerset --depth 2 -- -D warnings
- name: Clippy default features (including tests)
working-directory: rust
run: cargo clippy --locked -p ${{ matrix.crate }} --all-targets -- -D warnings
- name: Clippy all features (library)
working-directory: rust
run: cargo clippy --locked -p ${{ matrix.crate }} --lib --all-features -- -D warnings
server-semver:
name: Server patch API compatibility
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./.github/actions/setup-rust
with:
toolchain: ${{ env.RUST_VERSION }}
cache-prefix: server-semver
protoc: "false"
- uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2
with:
tool: cargo-semver-checks
# main retains the released version until release preparation, and is
# accumulating 0.6 changes with no 0.5.x patch release planned. A 0.x
# crate's next minor release (0.6.0) may break its public API, which
# cargo-semver-checks calls the "major" release type for 0.x versions;
# "minor" would still reject a breaking change.
# TODO(0.6.0 release prep): restore `--release-type patch` against the
# 0.6.0 baseline once 0.6.0 is tagged (see docs/RELEASE.md).
- name: Check server 0.6 API against 0.5.0
working-directory: rust
run: cargo semver-checks -p mkit-server --all-features --baseline-version 0.5.0 --release-type major
# mkit-client is the runtime-independent protocol client. Its wasm32 build is
# the product requirement, so it is built (not only checked) for the real
# target, and its dependency graph is held free of async runtimes and C
# compression (scripts/check-wasm-dep-graph.sh).
client-wasm:
name: mkit-client wasm32 build
if: github.event_name == 'pull_request' || github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 20
env:
CARGO_PROFILE_DEV_DEBUG: '0'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./.github/actions/setup-rust
with:
toolchain: ${{ env.RUST_VERSION }}
targets: wasm32-unknown-unknown
cache-prefix: client-wasm
- name: Build mkit-client for wasm32
working-directory: rust
run: cargo build --locked -p mkit-client --target wasm32-unknown-unknown
- name: Build mkit-client without a zstd decoder for wasm32
working-directory: rust
run: cargo build --locked -p mkit-client --no-default-features --target wasm32-unknown-unknown
- name: Dependency graph has no runtime or C compression
run: bash scripts/check-wasm-dep-graph.sh
# mkit-sync's `connect-native` feature is a small native HTTP channel: gzip
# only, no zstd-sys, no second C crypto library, no HTTP/2 or Connect runtime.
# The gate reads the resolved dependency graph for the embedding addon's build.
sync-native-deps:
name: mkit-sync connect-native dependency gate
if: github.event_name == 'pull_request' || github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: ./.github/actions/setup-rust
with:
toolchain: ${{ env.RUST_VERSION }}
cache-prefix: sync-native-deps
protoc: "false"
- name: Dependency graph has no zstd and no heavy runtime
run: bash scripts/check-sync-native-deps.sh
build-and-test:
name: Build & Test (${{ matrix.os }})
# macOS-only here: the Linux leg moved to Cloud Build (cloudbuild/ci.yaml).
#
# Runs on `push` to main (bounding the 10x macOS cost to post-merge, not
# every PR push) and on `workflow_dispatch` for on-demand runs. Does NOT
# run automatically on a pull_request push or a review approval — a
# job-level `if: false` would short-circuit before the matrix expands, so
# GitHub would show one synthetic entry named literally "Build & Test
# (${{ matrix.os }})" (unexpanded) instead of the real per-leg name —
# confusing in the PR checks list even though nothing is misconfigured.
# Gating per-step below (via env.SHOULD_RUN) keeps the matrix expanding
# normally (real name, real "skipped" steps) at effectively the same cost
# on PRs: the trivial steps below.
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-latest]
env:
SHOULD_RUN: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
steps:
- name: Checkout
if: env.SHOULD_RUN == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# workflow_dispatch has no github.event.pull_request, so this
# always falls through to github.sha — the tip of whatever ref the
# dispatch was run against.
ref: ${{ github.event.pull_request.head.sha || github.sha }}
# Toolchain (rustfmt + clippy) + lockfile-keyed cargo cache. No protoc:
# mkit-rpc's build.rs stages pre-committed generated/ sources by default
# (protoc is only invoked in codegen mode, via MKIT_RPC_CODEGEN=1, which
# only scripts/regen-rpc-proto.sh sets) — the codegen-freshness check
# that needs that now lives on Cloud Build (cloudbuild/codegen.yaml),
# which gets protoc from its own Docker image, not from here.
# cache-prefix is partitioned per-workflow + per-runner so a malicious PR
# cannot poison a key a privileged workflow (release.yml) later reads —
# see .github/actions/setup-rust and docs/RELEASE.md.
- name: Set up Rust and cache
if: env.SHOULD_RUN == 'true'
uses: ./.github/actions/setup-rust
with:
toolchain: ${{ env.RUST_VERSION }}
components: rustfmt, clippy
cache-prefix: "rust-ci-${{ matrix.os }}"
protoc: "false"
# cargo-nextest: faster + structured test runner. Replaces
# `cargo test`. Pinned via taiki-e/install-action which fetches a
# cosign-signed prebuilt binary from the cargo-nextest release.
- name: Install cargo-nextest
if: env.SHOULD_RUN == 'true'
uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2
with:
tool: cargo-nextest
# macOS gates the native signer deps (libtss2/libudev/pcsclite) out via
# target_os, so no apt step is needed here — unlike the Linux leg (now on
# Cloud Build), where cloudbuild/Dockerfile.ci bakes them in.
- name: Check formatting
if: env.SHOULD_RUN == 'true'
working-directory: rust
run: cargo fmt --check
- name: Clippy
if: env.SHOULD_RUN == 'true'
working-directory: rust
run: cargo clippy --all-targets --all-features --workspace -- -D warnings
- name: Build
if: env.SHOULD_RUN == 'true'
working-directory: rust
run: cargo build --locked --workspace
# The reference external signers are a SEPARATE workspace
# (contrib/signers/) so they don't break release-plz's crates.io publish
# (#225). `--workspace` above no longer covers them, so build/test/lint
# them here. This step must run BEFORE the workspace nextest step:
# mkit-cli's attest_roundtrip tests drive the mkit-sign-file binary from
# contrib/signers/target/debug/ and only fall back to building it
# on-the-fly when it's missing.
# No doctest pass here: the signers are bin-only crates and
# `cargo test --doc` hard-errors with "no library targets found"
# when a workspace has no lib targets (doctests only exist in libs).
- name: Contrib signers (separate workspace)
if: env.SHOULD_RUN == 'true'
working-directory: contrib/signers
run: |
cargo fmt --check
cargo clippy --locked --all-targets --all-features -- -D warnings
cargo build --locked --all-features
cargo nextest run --locked --all-features
# #505 PR 2/5: macOS runners always have `git` on PATH, so tests that
# would otherwise silently `return` green when `git` is missing must
# fail loudly here instead. TPM (`tpm2` feature) and the real-ssh e2e
# suite are NOT provisioned on macOS runners, so MKIT_TEST_STRICT is
# deliberately left unset for the contrib/signers step below — those
# stay ordinary (quiet, `#[ignore]`d) skips here.
- name: Test (nextest)
if: env.SHOULD_RUN == 'true'
working-directory: rust
env:
MKIT_TEST_STRICT: "1"
run: cargo nextest run --locked --workspace --all-features
# The indexed verification suite again with one Extract step per slice
# (the default chains selection steps): every outcome assertion must
# hold in both modes.
- name: Test (nextest, indexed verification unchained)
if: env.SHOULD_RUN == 'true'
working-directory: rust
env:
MKIT_TEST_EXTRACT_CHAIN: "1"
run: >-
cargo nextest run --locked -p mkit-server --all-features
-E 'test(~indexed::)'
# #505 PR 5/5: a handful of irreducibly-real tests (subprocess
# sleeps, real localhost TCP, the ~5s worktree-lock wait, a real
# `ssh` connect attempt to 127.0.0.1:22) were quarantined behind
# `#[ignore]` instead of determinized. The `ignored-lane` nextest
# profile (rust/.config/nextest.toml) is the single source of
# truth for which quarantined tests this serial lane runs and why
# — see that profile's comment for the full rationale (both this
# workflow and cloudbuild/ci.yaml invoke the same profile instead
# of duplicating the test-name filter inline).
- name: Test (nextest, ignored lane)
if: env.SHOULD_RUN == 'true'
working-directory: rust
run: >-
cargo nextest run --locked --workspace --all-features
--profile ignored-lane --run-ignored ignored-only
- name: Fuzz unit tests
if: env.SHOULD_RUN == 'true'
working-directory: rust
run: cargo test --manifest-path fuzz/Cargo.toml
# Doctests are not run by `cargo nextest`. Run them separately so
# docstring examples still get exercised.
- name: Doctests
if: env.SHOULD_RUN == 'true'
working-directory: rust
run: cargo test --locked --doc --workspace
- name: Version contract
if: env.SHOULD_RUN == 'true'
working-directory: rust
shell: bash
run: |
cargo build --release --locked -p mkit-cli
out=$(./target/release/mkit version)
expected="mkit $(awk -F\" '/^version/ {print $2; exit}' Cargo.toml)"
echo "stdout: [$out]"
echo "expected: [$expected]"
if [ "$out" != "$expected" ]; then
echo "::error::mkit version contract violated — stdout is not exactly 'mkit <X.Y.Z>\\n'"
exit 1
fi
# Issue #156: build the CLI with the dedicated
# `enc-transport` cargo feature and run the encrypted-transport
# e2e test. The above `--all-features` test pass already covers
# this in aggregate; the dedicated invocation pins the feature
# gate so a future refactor can't silently break the
# opt-in-only build path.
- name: Encrypted-transport feature build
if: env.SHOULD_RUN == 'true'
working-directory: rust
run: |
cargo build --locked -p mkit-cli --features enc-transport
cargo test --locked -p mkit-transport-enc --features tcp --no-fail-fast
keystore-backends:
name: Keystore backends (${{ matrix.name }})
# Heaviest job in this workflow (2-OS matrix): stays manual-only, unlike
# build-and-test above. Runs ONLY on `workflow_dispatch` —
# NOT on any pull_request, review approval, or push. See
# docs/RELEASE.md's pre-release checklist for the reminder to dispatch it
# before a release.
#
# The gate lives on each STEP below (via env.SHOULD_RUN), not on the job
# itself. A job-level `if: false` short-circuits before the matrix is ever
# expanded, so GitHub shows one synthetic entry named literally
# "Keystore backends (${{ matrix.name }})" (unexpanded) instead of the
# real per-leg names — confusing in the PR checks list even though
# nothing is actually misconfigured. Gating per-step still expands the
# matrix normally (real names, real "skipped" steps) at effectively the
# same cost: the trivial steps below.
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- name: macos-keychain-yubikey
os: macos-latest
features: backend-macos-keychain backend-yubikey
- name: linux-secret-systemd-yubikey
os: ubuntu-latest
features: backend-linux-secret-service backend-systemd-creds backend-yubikey
env:
SHOULD_RUN: ${{ github.event_name == 'workflow_dispatch' }}
steps:
- name: Checkout
if: env.SHOULD_RUN == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# workflow_dispatch has no github.event.pull_request, so this
# always falls through to github.sha — the tip of whatever ref the
# dispatch was run against.
ref: ${{ github.event.pull_request.head.sha || github.sha }}
# Keystore backends never compile mkit-rpc, so protoc is skipped here.
- name: Set up Rust and cache
if: env.SHOULD_RUN == 'true'
uses: ./.github/actions/setup-rust
with:
toolchain: ${{ env.RUST_VERSION }}
components: rustfmt, clippy
cache-prefix: "rust-keystore-${{ matrix.name }}"
protoc: "false"
- name: Install Linux keystore native deps
if: env.SHOULD_RUN == 'true' && runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y dbus-x11 gnome-keyring libpcsclite-dev pkg-config
- name: Test keystore backend feature set
if: env.SHOULD_RUN == 'true'
working-directory: rust
shell: bash
env:
MKIT_RUN_NATIVE_KEYSTORE_TESTS: "1"
run: |
set -euo pipefail
if [ "${{ runner.os }}" = "Linux" ]; then
dbus-run-session -- bash -lc '
set -euo pipefail
eval "$(printf "mkit-ci\n" | gnome-keyring-daemon --unlock --components=secrets)"
cargo test --locked -p mkit-keystore --features "${{ matrix.features }}"
cargo test --locked -p mkit-keystore --features "${{ matrix.features }}" \
backend_linux_secret_service::tests::live_backend_create_open_list_export_delete_roundtrip -- --exact --ignored
'
sudo env \
"PATH=$PATH" \
"CARGO_HOME=$CARGO_HOME" \
"RUSTUP_HOME=${RUSTUP_HOME:-$HOME/.rustup}" \
"CARGO_TARGET_DIR=$RUNNER_TEMP/mkit-systemd-creds-target" \
"MKIT_RUN_NATIVE_KEYSTORE_TESTS=1" \
"MKIT_RUN_SYSTEMD_CREDS_TESTS=1" \
cargo test --locked -p mkit-keystore --features "backend-systemd-creds" \
backend_systemd_creds::tests::live_backend_create_open_list_export_delete_roundtrip -- --exact --ignored
else
cargo test --locked -p mkit-keystore --features "${{ matrix.features }}"
cargo test --locked -p mkit-keystore --features "${{ matrix.features }}" -- --ignored
fi
- name: Clippy keystore backend feature set
if: env.SHOULD_RUN == 'true'
working-directory: rust
shell: bash
run: cargo clippy --locked -p mkit-keystore --features "${{ matrix.features }}" --all-targets -- -D warnings
# Aggregator gate — the single stable check to mark "required" in branch
# protection for THIS workflow's legs. It `needs` all three jobs above and
# runs with `if: always()`, so it reports a real success/failure even when
# a job is skipped, cancelled, or fails.
#
# Critically, 'skipped' is accepted ONLY on the trigger types where the job
# is genuinely not expected to do any work on this event — never as a
# blanket pass. A job-level `if:` isn't used on either of these two jobs (to
# keep the matrix leg expanded with real names — see its job comment), so
# in practice a 'skipped' *result* only shows up if that changes later; the
# per-event branching below exists so that regression is still caught
# instead of silently accepted, which is the exact bug this gate used to
# have (a hardcoded `workflow_dispatch`-only SHOULD_RUN plus a blanket
# `success|skipped` case arm meant the required check was green even though
# zero macOS coverage ever ran):
# - build-and-test: expected to actually run (SHOULD_RUN=true) on `push`
# and `workflow_dispatch`; on those triggers only 'success' is
# accepted. On `pull_request` it's a no-op by design, so 'skipped' is
# also accepted there.
# - keystore-backends: manual-only on every trigger (2-OS matrix cost) —
# 'skipped' is accepted on any event, but it must not have failed or
# been cancelled when it did run. See docs/RELEASE.md's pre-release
# checklist for the reminder to dispatch it before a release.
# The Linux Rust gate lives on Cloud Build (cloudbuild/ci.yaml) and posts its
# own commit status — mark that "required" alongside this one.
ci-gate:
name: Rust CI gate
if: always()
needs: [build-and-test, keystore-backends, server-feature-clippy, server-semver, client-wasm, sync-native-deps]
runs-on: ubuntu-latest
steps:
- name: Verify no critical job failed
shell: bash
env:
BUILD_AND_TEST: ${{ needs.build-and-test.result }}
KEYSTORE_BACKENDS: ${{ needs.keystore-backends.result }}
SERVER_FEATURE_CLIPPY: ${{ needs.server-feature-clippy.result }}
SERVER_SEMVER: ${{ needs.server-semver.result }}
CLIENT_WASM: ${{ needs.client-wasm.result }}
SYNC_NATIVE_DEPS: ${{ needs.sync-native-deps.result }}
EVENT_NAME: ${{ github.event_name }}
run: |
echo "event: ${EVENT_NAME}"
echo "build-and-test: ${BUILD_AND_TEST}"
echo "keystore-backends: ${KEYSTORE_BACKENDS}"
fail=0
for result in "${SERVER_FEATURE_CLIPPY}" "${SERVER_SEMVER}" "${CLIENT_WASM}" "${SYNC_NATIVE_DEPS}"; do
case "${result}" in
success|skipped) ;;
*)
echo "::error::Server compatibility job did not succeed (result=${result})"
fail=1
;;
esac
done
# Expected to run on push/workflow_dispatch — only 'success' is
# acceptable there. On pull_request it's a deliberate no-op, so
# 'skipped' is also accepted.
case "${EVENT_NAME}" in
push|workflow_dispatch)
case "${BUILD_AND_TEST}" in
success) ;;
*)
echo "::error::build-and-test did not succeed on ${EVENT_NAME} (result=${BUILD_AND_TEST})"
fail=1
;;
esac
;;
*)
case "${BUILD_AND_TEST}" in
success|skipped) ;;
*)
echo "::error::build-and-test did not succeed (result=${BUILD_AND_TEST})"
fail=1
;;
esac
;;
esac
# Manual-only on every trigger: may be skipped on any event, but
# must not have failed or been cancelled when it did run.
case "${KEYSTORE_BACKENDS}" in
success|skipped) ;;
*)
echo "::error::keystore-backends did not succeed (result=${KEYSTORE_BACKENDS})"
fail=1
;;
esac
if [ "${fail}" -ne 0 ]; then
echo "::error::Rust CI gate failed — see job results above."
exit 1
fi
echo "All critical Rust jobs satisfied the gate."