name: CI - Core

on:
  push:
    branches: [main]
    paths:
      - "src/**"
      - "tests/**"
      - "pyproject.toml"
      - "packages/**"
      - "uv.lock"
      - ".github/workflows/ci-core.yml"
  # No `paths:` filter on pull_request, deliberately. A path-filtered workflow
  # produces no check run at all on a PR it skips -- not a green one, not a
  # skipped one, nothing -- and branch protection reads a check that never
  # reports as pending forever. So `test (3.x)` could not be a required check
  # while this filter stood: the first docs-only PR would have hung on it.
  # The whole workflow is ~3.5 minutes wall clock; paying it on a README typo
  # is cheaper than a required check that is only sometimes enforced.
  pull_request:

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

# Read-only by default: without this the workflow inherits the repository
# default, which is write on nearly everything. Nothing here writes.
permissions:
  contents: read

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

      - name: Set up Python
        uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
        with:
          python-version: "3.11"

      - name: Install dependencies
        # ruff comes from the pinned dev dependency, not a version pinned here.
        # Two pins meant CI and developers ran different ruffs, so rules that
        # fired locally were invisible in CI -- two UP042 findings sat unseen
        # for exactly that reason.
        run: pip install -e ".[dev]"

      - name: Ruff check (lint, no import sorting)
        # `tests` is in scope because it is what contributors already lint --
        # AGENTS.md tells them `ruff check src/ tests/`. Leaving CI at `src`
        # meant findings in tests/ were reported by hand, over and over, with
        # no gate to land the fix against. Import order is NOT checked: `I` is
        # not in ruff's `select` (#1496).
        run: ruff check src/mcp_hangar tests

      - name: Ruff format check
        run: ruff format --check src/mcp_hangar tests

      - name: Type check
        run: mypy src/mcp_hangar

      - name: Import contracts
        # Layering is checked here rather than by a grep in review. Note the
        # companion test: `lint-imports` exits 0 on an empty contract file, so
        # tests/unit/test_import_contracts.py guards the contract itself.
        run: lint-imports --config .importlinter

      - name: Dead symbols
        # Which public symbols does nothing reference? Five defects this month
        # were code that could not run -- an adapter never constructed, a port
        # never injected, a module with no callers -- and each was found by
        # accident. The baseline in pyproject.toml can only shrink; its
        # companion test guards the baseline itself.
        run: python scripts/check_dead_symbols.py

      - name: Tracing without the SDK is a no-op
        # The test jobs install the `opentelemetry` extra; this job does not, so
        # it is the one run of the API-only install (`mcp` pulls in the OTel API,
        # not the SDK). Tracing there must degrade to a no-op, never raise. The
        # first assert keeps this honest: if a dev dependency ever pulls the SDK
        # in, the step would prove nothing, so it fails instead.
        run: |
          python - <<'EOF'
          import importlib.util
          assert importlib.util.find_spec("opentelemetry.sdk") is None, "lint must stay API-only"
          from mcp_hangar.observability import tracing as t
          assert not t.OTEL_AVAILABLE and not t.is_tracing_enabled() and t.init_tracing() is False
          assert isinstance(t.get_tracer(), t.NoOpTracer)
          with t.upstream_call_span("tools/call", {"name": "x"}) as span:
              assert isinstance(span, t.NoOpSpan)
              t.mark_span_error(span, "boom")
          carrier = {}
          t.inject_trace_context(carrier)
          assert carrier == {} and t.extract_trace_context({"traceparent": "x"}) is None
          assert t.get_current_trace_id() is None and t.get_current_span_id() is None
          EOF

  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.11", "3.12", "3.13", "3.14"]

    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
        with:
          python-version: ${{ matrix.python-version }}

      - name: Install dependencies
        # `.[dev]` carries only the OTel API (via `mcp`); the tracing contract
        # tests need the SDK from the `opentelemetry` extra. Under CI they fail
        # without it (tests/conftest.py) rather than skip, so dropping the extra
        # here turns the job red. `lint` stays API-only and smoke-tests that.
        run: pip install -e ".[dev,opentelemetry]"

      - name: Run tests
        # `--ignore`, not a list of directories: a new test directory is picked
        # up here by default rather than by remembering to add it.
        #
        # Integration is excluded because it has its own job below. Run bare,
        # this matrix executed `tests/integration` three times, `decision-coverage`
        # a fourth and the `integration` job a fifth -- five runs of the same
        # in-process suite for one signal. The suite launches no containers and
        # touches no version-specific surface the unit tests do not, so one
        # version is the signal and three is the bill.
        # No coverage here: the report is measured once, by `decision-coverage`,
        # on the pinned selection the floors were measured against. Measuring it
        # three more times produced three more numbers nobody could reconcile.
        run: pytest --ignore=tests/integration

  decision-coverage:
    # A SEPARATE job, not a step folded into `test`, on purpose:
    #  * `test` is a 3-version matrix; running the gate three times triples the
    #    flake surface for one signal.
    #  * `test` passes no `-m` filter, so its selection is not the selection the
    #    floors in [tool.decision_coverage.floors] were measured against. This
    #    job pins its own so the number is reproducible.
    #  * a dedicated job name can be made a required check without making the
    #    whole matrix required.
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

      - name: Set up Python
        uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
        with:
          python-version: "3.11"

      - name: Install dependencies
        # With the SDK, as in `test` -- see the note there.
        run: pip install -e ".[dev,opentelemetry]"

      - name: Measure branch coverage
        # Selection pinned to match the one the floors were measured with.
        # Branch mode comes from [tool.coverage.run], not a flag here -- the
        # gate refuses a statement-only report outright, so the two cannot drift.
        run: |
          pytest tests/unit tests/integration -q \
            -m "not benchmark and not live" \
            --cov=mcp_hangar --cov-report=json:coverage.json --cov-report=xml

      - name: Enforce decision-path floors
        run: python scripts/check_decision_coverage.py coverage.json

      - name: Upload coverage
        # The only coverage upload in the workflow, from the only run that
        # measures it. The `test` matrix used to upload three reports of a
        # different selection under the same flag.
        uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
        with:
          files: coverage.xml
          flags: core
          fail_ci_if_error: false

      - name: Upload the coverage report on failure
        # Without this a floor breach is only ever a number in a log line, and
        # diagnosing it means guessing which branches moved. The report names
        # the missing arcs per module.
        if: failure()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
        with:
          name: decision-coverage-report
          path: coverage.json
          retention-days: 7

  integration:
    runs-on: ubuntu-latest
    needs: [test]
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

      - name: Set up Python
        uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
        with:
          python-version: "3.11"

      - name: Install dependencies
        # With the SDK, as in `test` -- see the note there.
        run: pip install -e ".[dev,opentelemetry]"

      - name: Run integration tests
        run: pytest tests/integration/ -v --tb=short --timeout=60

  build:
    runs-on: ubuntu-latest
    needs: [lint, test]
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7

      - name: Set up Python
        uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
        with:
          python-version: "3.11"

      - name: Install hatch
        run: pip install hatch

      - name: Build package
        run: hatch build

      - name: Upload artifact
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
        with:
          name: core-dist
          path: dist/
