nsip 0.7.4

NSIP Search API client for nsipsearch.nsip.org/api
Documentation
---
name: CI

"on":
  push:
    branches: [develop, main]
  pull_request:
    branches: [develop, main]
  workflow_dispatch:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

permissions:
  contents: read

env:
  CARGO_TERM_COLOR: always
  CARGO_INCREMENTAL: 0
  RUSTFLAGS: "-D warnings"

jobs:
  fmt:
    name: Format
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Checkout repository
        # yamllint disable-line rule:line-length
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1

      - name: Setup Rust with caching
        uses: ./.github/actions/setup-rust-cached
        with:
          toolchain: stable
          components: rustfmt
          cache-key: fmt

      - name: Check formatting
        run: cargo fmt --all -- --check

  clippy:
    name: Clippy
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - name: Checkout repository
        # yamllint disable-line rule:line-length
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1

      - name: Setup Rust with caching
        uses: ./.github/actions/setup-rust-cached
        with:
          toolchain: stable
          components: clippy
          cache-key: clippy

      - name: Run clippy
        run: >-
          cargo clippy --all-targets --all-features
          -- -D warnings

  test:
    name: Test (${{ matrix.os }})
    runs-on: ${{ matrix.os }}
    timeout-minutes: 30
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    steps:
      - name: Checkout repository
        # yamllint disable-line rule:line-length
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1

      - name: Setup Rust with caching
        uses: ./.github/actions/setup-rust-cached
        with:
          toolchain: stable
          cache-key: test

      - name: Run tests
        run: cargo test --all-features --verbose

  doc:
    name: Documentation
    runs-on: ubuntu-latest
    timeout-minutes: 15
    env:
      RUSTDOCFLAGS: "-D warnings"
    steps:
      - name: Checkout repository
        # yamllint disable-line rule:line-length
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1

      - name: Setup Rust with caching
        uses: ./.github/actions/setup-rust-cached
        with:
          toolchain: stable
          cache-key: doc

      - name: Check documentation
        run: cargo doc --no-deps --all-features

  deny:
    name: Cargo Deny
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - name: Checkout repository
        # yamllint disable-line rule:line-length
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1

      - name: Install cargo-deny
        # yamllint disable-line rule:line-length
        uses: ./.github/actions/install-cargo-tool
        with:
          tool: cargo-deny

      - name: Run cargo-deny
        run: cargo deny check

  slug-docs:
    name: Error Slug Docs
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Checkout repository
        # yamllint disable-line rule:line-length
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1

      - name: Assert every error slug has a catalog page
        run: |
          set -euo pipefail
          # Source of truth: the default slug literals in crates/problem.rs
          # (slug_path / validation_slug). Each must resolve to a Markdown
          # page under docs/reference/errors/.
          missing=0
          # Match any "domain/slug" literal (not just the current cli/api/mcp
          # domains) so a new error domain is caught. Strip the #[cfg(test)]
          # module first (test fixtures contain non-slug strings like
          # "errors/custom"), and drop RFC media types like
          # application/problem+json that share the shape.
          slugs=$(sed '/#\[cfg(test)\]/,$d' crates/problem.rs \
            | grep -oE '"[a-z][a-z0-9]*/[a-z0-9][a-z0-9-]*"' \
            | tr -d '"' \
            | grep -Ev '^(application|text|image|multipart|audio|video|font|model|message|example)/' \
            | sort -u)
          if [ -z "$slugs" ]; then
            echo "ERROR: no slug literals found in crates/problem.rs" >&2
            exit 1
          fi
          for slug in $slugs; do
            page="docs/reference/errors/${slug}.md"
            if [ -f "$page" ]; then
              echo "ok: $slug -> $page"
            else
              echo "MISSING: $slug has no catalog page at $page" >&2
              missing=1
            fi
          done
          if [ "$missing" -ne 0 ]; then
            echo "Error slug catalog is incomplete." >&2
            exit 1
          fi

  dependabot-lock-exclusions:
    name: Dependabot Lock File Exclusions
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - name: Checkout repository
        # yamllint disable-line rule:line-length
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1

      - name: Assert gh-aw generated lock files are excluded from dependabot
        run: |
          set -euo pipefail
          # gh-aw regenerates *.lock.yml from its own compiler (gh aw
          # compile); dependabot must never hand-edit them or the next
          # compile silently reverts the bump with no warning (nsip#363).
          # Every gh-aw-generated lock file must match an exclude-paths
          # glob in dependabot.yml's github-actions ecosystem block.
          generated_files=()
          for f in .github/workflows/*.lock.yml; do
            [ -e "$f" ] || continue
            if grep -q '^# This file was automatically generated by gh-aw' "$f"; then
              generated_files+=("$f")
            fi
          done

          if [ "${#generated_files[@]}" -eq 0 ]; then
            echo "No gh-aw generated lock files found; nothing to check."
            exit 0
          fi

          # Read the github-actions ecosystem block via a real YAML parser (so
          # this check does not depend on item ordering or indentation) and
          # match with File::FNM_PATHNAME, which stops `*` from crossing a `/`
          # the way dependabot path globs do. Bash `[[ $f == $pattern ]]` lets
          # `*` cross `/`, so a pattern like `*.lock.yml` -- which dependabot
          # would NOT apply to a nested file -- reported a false pass.
          # Exit codes: 2 = unreadable config, 3 = no exclude-paths, 4 = a
          # generated lock file is not covered.
          rc=0
          # shellcheck disable=SC2016  # `#{}` below is Ruby interpolation.
          ruby -ryaml -e '
            cfg_path = ARGV.shift
            begin
              cfg = YAML.load_file(cfg_path)
            rescue StandardError => e
              warn "ERROR: could not parse #{cfg_path}: #{e.message}"
              exit 2
            end
            updates = cfg.is_a?(Hash) ? cfg["updates"] : nil
            gha = Array(updates).find do |u|
              u.is_a?(Hash) && u["package-ecosystem"] == "github-actions"
            end
            exclude = gha.is_a?(Hash) ? gha["exclude-paths"] : nil
            exit 3 unless exclude.is_a?(Array)
            # `directory: /` invites a leading slash; dependabot treats these
            # patterns as repo-relative, so normalize before matching rather
            # than failing a correctly-configured repo.
            patterns = exclude.map do |p|
              p.to_s.sub(%r{\A\./}, "").sub(%r{\A/}, "")
            end
            patterns.reject!(&:empty?)
            exit 3 if patterns.empty?
            missing = false
            ARGV.each do |path|
              covered = patterns.any? do |pat|
                File.fnmatch?(pat, path, File::FNM_PATHNAME)
              end
              if covered
                puts "ok: #{path} is excluded from the dependabot scan"
              else
                msg = "MISSING: #{path} is gh-aw-generated but not "
                msg += "covered by an exclude-paths pattern in #{cfg_path}"
                warn msg
                missing = true
              end
            end
            exit(missing ? 4 : 0)
          ' .github/dependabot.yml "${generated_files[@]}" || rc=$?

          case "$rc" in
            0) ;;
            2)
              echo "ERROR: dependabot.yml is not valid YAML (see above)." >&2
              exit 1
              ;;
            3)
              echo "ERROR: no exclude-paths in the github-actions" \
                   "ecosystem block of .github/dependabot.yml" >&2
              exit 1
              ;;
            4)
              echo "dependabot.yml is missing exclude-paths coverage for" \
                   "a gh-aw-generated lock file (nsip#363)." >&2
              exit 1
              ;;
            *)
              echo "ERROR: exclude-paths check failed (ruby exited $rc)" >&2
              exit 1
              ;;
          esac

  msrv:
    name: MSRV Check
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - name: Checkout repository
        # yamllint disable-line rule:line-length
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1

      - name: Setup Rust with caching
        uses: ./.github/actions/setup-rust-cached
        with:
          toolchain: "1.92"
          cache-key: msrv

      - name: Check MSRV
        run: cargo check --all-features

  coverage:
    name: Coverage
    runs-on: ubuntu-latest
    timeout-minutes: 30
    environment: copilot
    steps:
      - name: Checkout repository
        # yamllint disable-line rule:line-length
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1

      - name: Setup Rust with caching
        uses: ./.github/actions/setup-rust-cached
        with:
          toolchain: stable
          components: llvm-tools-preview
          cache-key: cov

      - name: Install cargo-llvm-cov
        # yamllint disable-line rule:line-length
        uses: ./.github/actions/install-cargo-tool
        with:
          tool: cargo-llvm-cov

      - name: Generate coverage report
        run: >-
          cargo llvm-cov --all-features
          --lcov --output-path lcov.info

      - name: Enforce 90% line coverage
        run: >-
          cargo llvm-cov --all-features
          --fail-under-lines 90

      - name: Upload coverage to Codecov
        # yamllint disable-line rule:line-length
        uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f  # v7.0.0
        with:
          files: lcov.info
          fail_ci_if_error: false
          verbose: true
        env:
          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

  pin-check:
    # Central reusable workflow: every `uses:` must be pinned to a 40-char SHA.
    # Required-check context name: "pin-check / pin-check"
    permissions:
      contents: read
    # yamllint disable-line rule:line-length
    uses: zircote/.github/.github/workflows/pin-check.yml@229e6e6887c2493d43020b934983259361f2cc1b  # main

  all-checks-pass:
    name: All Checks Pass
    if: always()
    # yamllint disable-line rule:line-length
    needs: [fmt, clippy, test, doc, deny, msrv, coverage, slug-docs, dependabot-lock-exclusions, pin-check]
    runs-on: ubuntu-latest
    steps:
      - name: Check all jobs passed
        env:
          FMT_RESULT: ${{ needs.fmt.result }}
          CLIPPY_RESULT: ${{ needs.clippy.result }}
          TEST_RESULT: ${{ needs.test.result }}
          DOC_RESULT: ${{ needs.doc.result }}
          DENY_RESULT: ${{ needs.deny.result }}
          MSRV_RESULT: ${{ needs.msrv.result }}
          COVERAGE_RESULT: ${{ needs.coverage.result }}
          SLUG_DOCS_RESULT: ${{ needs.slug-docs.result }}
          DEP_LOCK_RESULT: ${{ needs.dependabot-lock-exclusions.result }}
          PIN_CHECK_RESULT: ${{ needs.pin-check.result }}
        run: |
          if [[ "$FMT_RESULT" != "success" ]] || \
             [[ "$CLIPPY_RESULT" != "success" ]] || \
             [[ "$TEST_RESULT" != "success" ]] || \
             [[ "$DOC_RESULT" != "success" ]] || \
             [[ "$DENY_RESULT" != "success" ]] || \
             [[ "$MSRV_RESULT" != "success" ]] || \
             [[ "$COVERAGE_RESULT" != "success" ]] || \
             [[ "$SLUG_DOCS_RESULT" != "success" ]] || \
             [[ "$DEP_LOCK_RESULT" != "success" ]] || \
             [[ "$PIN_CHECK_RESULT" != "success" ]]; then
            echo "One or more jobs failed"
            exit 1
          fi
          echo "All checks passed!"