ebman 0.37.0

k9s-style TUI for AWS Elastic Beanstalk
name: CI

on:
  push:
    branches: [main]
  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: [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
      # `--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: 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
        # Plain text. Nothing below parses cargo's output any more (see
        # the note in the run block), but the human-readable failure
        # output is easier to read in a log without escape codes.
        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."