rust.yml

In .github/workflows/ · At 619cbad

619cbadHistory

Preview

Code · 23.7 KiB
name: "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."

Files