ebman 0.34.1

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: 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