name: CI

# One pipeline for lint, tests, build, and docs. Replaces the near-duplicate
# ci.yml + ci-cd.yml pair, which between them ran the same unit suite five times
# (unit-tests, unit-test, cross-platform, python-versions, and release.yml) over
# Python versions this package no longer supports.
#
# Everything runs through uv, per CLAUDE.md: no `pip install`, no bare
# `python -m venv`. `uv sync --locked` installs from the committed uv.lock, so CI
# resolves exactly the dependency set a developer's checkout does.

on:
  push:
    branches: [ main ]
  pull_request:
    branches: [ main ]
  workflow_dispatch:

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

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v7
        with:
          enable-cache: true
      - name: Sync environment
        run: uv sync --locked --extra dev --extra test
      - name: Lint with ruff
        # Whole repo. The 107 pre-existing errors (bare excepts, unused imports)
        # that forced a narrower scope were all in the tools/ one-off debug
        # scripts #93 removed. flake8 was never a declared dependency, so the old
        # `flake8 parsl_ephemeral_provider tests` step could only ever have failed.
        run: uv run ruff check .
      - name: Check formatting with ruff
        # ruff-format, not black: .pre-commit-config.yaml formats with ruff-format,
        # while [tool.black] line-length = 100 is stale config that disagrees with
        # how the tree is actually formatted. Running black churns unrelated lines.
        #
        # Whole repo, because the pre-commit hook formats whatever is staged.
        # A narrower check lets a file drift out of format until some later commit
        # stages it and the hook rewrites it, failing a commit that had nothing to
        # do with the change.
        run: uv run ruff format --check .
      - name: Security scan with bandit
        run: uv run bandit -r parsl_ephemeral_provider -c pyproject.toml

  type-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v7
        with:
          enable-cache: true
      - name: Sync environment
        run: uv sync --locked --extra dev --extra test
      - name: Type check with mypy
        # Reported, not gated: 76 pre-existing errors are being worked down under
        # #81/#82, most of them in the unreachable modules #90 removes. Drop
        # continue-on-error once the count reaches zero, so this becomes a real
        # gate rather than a signal.
        continue-on-error: true
        run: uv run mypy parsl_ephemeral_provider

  unit-tests:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        # requires-python = ">=3.10". Parsl 2026.x dropped 3.9, so the old 3.8/3.9
        # matrix entries could not have installed the package at all.
        python-version: ["3.10", "3.11", "3.12"]
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v7
        with:
          enable-cache: true
          python-version: ${{ matrix.python-version }}
      - name: Sync environment
        # `--extra globus` is here so tests/unit/test_globus_config_loading.py
        # actually runs: it is the only test that loads a generated endpoint
        # config through globus-compute-endpoint's own loader, which is the
        # assertion #87 needed and text-comparison tests cannot make. Without
        # the extra it importorskips, and the regression could return silently.
        # The extra resolves alongside `test` since #125 removed localstack and
        # with it the dill conflict.
        run: uv sync --locked --extra dev --extra test --extra globus
      - name: Run unit and security tests
        # tests/security is pure-mock and marked `unit`; it belongs to this job
        # rather than a separate one. Selection is by path, not `-m unit`, so a
        # newly-added file without a marker still runs -- the divergence that let
        # the Makefile pass while CI failed.
        #
        # This is the real coverage gate. pyproject's --cov-fail-under is a 25%
        # smoke floor because it applies to narrow invocations too; these two
        # paths together measure 68%, so 65 leaves a little headroom without
        # letting coverage silently rot.
        run: uv run pytest tests/unit tests/security -v --cov-fail-under=65
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v7
        with:
          files: ./coverage.xml
          flags: unittests
          name: python-${{ matrix.python-version }}
        continue-on-error: true

  integration-tests:
    runs-on: ubuntu-latest
    needs: unit-tests
    # Substrate replaces LocalStack as the emulator (#125). LocalStack OSS is
    # end-of-life: the upstream repository was archived read-only in March 2026,
    # `4.14.0` is the last community image, and `localstack/localstack:latest` now
    # resolves to the Pro build (same digest as `localstack/localstack-pro`), which
    # exits 55 on "License activation failed!" before any step runs. Since
    # `continue-on-error` does not cover service-container startup, that failed the
    # job unconditionally -- which is why this ran with no endpoint at all for a
    # while.
    services:
      substrate:
        # Pinned rather than `latest`, matching docker-compose.substrate.yml: an
        # emulator that changes underneath CI turns an unrelated PR red. Bump the
        # two together, deliberately -- they had drifted, this pin sitting on
        # 0.76.0 while compose moved to 0.82.0, so CI was validating against an
        # emulator six releases older than the one developers ran. See that file
        # for what each release fixes.
        image: ghcr.io/scttfrdmn/substrate:0.88.0
        ports:
          - 4566:4566
        # No `--health-cmd`. The image is Alpine and ships no curl, and GitHub
        # evaluates service healthchecks with the *runner's* docker, which cannot
        # see into the container's PATH assumptions reliably. The wait step below
        # polls from the runner instead, where curl is guaranteed.
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v7
        with:
          enable-cache: true
      - name: Sync environment
        run: uv sync --locked --extra dev --extra test
      - name: Wait for substrate
        run: ./scripts/substrate-wait.sh
      - name: Run integration tests
        # Now gating. This carried `continue-on-error: true` while #92's debt was
        # outstanding -- 46 mode constructions omitted the network IDs #69 made
        # required, so the suite could not pass and gating would have made every
        # PR red. #92 closed in v0.8.0 and the suite is green (131 passed, 0
        # skipped against substrate 0.87.1 -- 130 passed and 1 skipped on 0.85.0,
        # the skip being the CloudFormation guard that lifted itself when
        # substrate#483 landed), so the exemption now only hides regressions.
        env:
          # Structural, not secret: substrate authenticates against nothing, but
          # botocore refuses to sign a request without credentials present.
          AWS_ACCESS_KEY_ID: test
          AWS_SECRET_ACCESS_KEY: test
          AWS_DEFAULT_REGION: us-east-1
          SUBSTRATE_ENDPOINT: http://localhost:4566
        run: uv run pytest tests/integration -v
      - name: Emulator conformance
        # Separate from the suite above because this one *is* gated: it drives raw
        # boto3 and imports no package code, so a failure here means the emulator
        # regressed on something the provider depends on -- not that the provider
        # broke. It cannot be masked by #92's test-side debt.
        env:
          AWS_DEFAULT_REGION: us-east-1
          SUBSTRATE_ENDPOINT: http://localhost:4566
        run: uv run pytest tests/test_substrate_emulation.py -v --no-cov
      - name: Upload coverage to Codecov
        uses: codecov/codecov-action@v7
        with:
          files: ./coverage.xml
          flags: integration
          name: integration
        continue-on-error: true

  aws-e2e-tests:
    # Real AWS: bills money and needs a pre-provisioned VPC/subnet/SG, so it is
    # manual-only. #60 closed with tests under tests/aws that no workflow ever
    # referenced; there are 87 now.
    #
    # The `vars.AWS_TEST_REGION != ''` half of the gate is what makes an
    # unconfigured run a no-op (#161). Relying on conftest.py's skip was not
    # enough: pytest is never reached, because configure-aws-credentials fails
    # first on the empty region with "Input required and not supplied:
    # aws-region". So the job went red on every dispatch, which made manual
    # dispatch useless as a green signal -- and dispatch is the only way to get
    # CI evidence for a stacked PR, since the workflow triggers on pull_request
    # into main and a PR based on a feature branch reports no checks at all.
    if: github.event_name == 'workflow_dispatch' && vars.AWS_TEST_REGION != ''
    runs-on: ubuntu-latest
    needs: unit-tests
    environment: aws-e2e
    permissions:
      id-token: write      # OIDC, so no long-lived keys live in secrets
      contents: read
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v7
        with:
          enable-cache: true
      - name: Sync environment
        run: uv sync --locked --extra dev --extra test
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: ${{ secrets.AWS_E2E_ROLE_ARN }}
          aws-region: ${{ vars.AWS_TEST_REGION }}
      - name: Run real-AWS E2E tests
        env:
          AWS_TEST_REGION: ${{ vars.AWS_TEST_REGION }}
          AWS_TEST_VPC_ID: ${{ vars.AWS_TEST_VPC_ID }}
          AWS_TEST_SUBNET_ID: ${{ vars.AWS_TEST_SUBNET_ID }}
          AWS_TEST_SG_ID: ${{ vars.AWS_TEST_SG_ID }}
        run: uv run pytest tests/aws -m aws -v --no-cov
      - name: Report orphaned resources
        # always(): a failed test is exactly when instances are most likely to be
        # left running. --dry-run reports without deleting, so a shared account is
        # never touched by CI.
        #
        # No --profile: the script now defaults to the boto3 credential chain,
        # which picks up the OIDC credentials configure-aws-credentials exported
        # (#161). It used to default to the local "aws" profile and die here.
        if: always()
        env:
          AWS_TEST_REGION: ${{ vars.AWS_TEST_REGION }}
        run: uv run parsl-ephemeral-cleanup --dry-run --region "${AWS_TEST_REGION}"

  test-bats:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - name: Set up bats
        # `sudo` applied to the npm line only, so `mkdir -p /usr/local/lib/bats`
        # ran unprivileged and the step died on "Permission denied" once the
        # runner image stopped leaving /usr/local/lib group-writable.
        #
        # The three helper libraries are cloned under sudo for the same reason,
        # but they stay optional: tests/bats/test_environment.bats loads each one
        # only `if [[ -f "$BATS_SUPPORT" ]]`, and no test in this suite calls an
        # assert_* helper, so the suite passes with or without them.
        run: |
          sudo npm install -g bats
          sudo mkdir -p /usr/local/lib/bats
          sudo git clone --depth 1 https://github.com/bats-core/bats-support.git /usr/local/lib/bats/bats-support
          sudo git clone --depth 1 https://github.com/bats-core/bats-assert.git /usr/local/lib/bats/bats-assert
          sudo git clone --depth 1 https://github.com/bats-core/bats-file.git /usr/local/lib/bats/bats-file
      - name: Run bats tests
        run: bats tests/bats/

  build:
    runs-on: ubuntu-latest
    needs: [lint, unit-tests]
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v7
        with:
          enable-cache: true
      - name: Build package
        run: uv build
      - name: Check package metadata
        run: uvx twine check dist/*
      - name: Verify CloudFormation templates ship in the wheel
        # #112: get_cf_template() resolved templates by filesystem path, so a
        # wheel that omitted them failed only at runtime, on a real AWS call.
        run: |
          python -m zipfile -l dist/*.whl | grep -q 'templates/cloudformation/' \
            || { echo "::error::CloudFormation templates missing from wheel"; exit 1; }
      - uses: actions/upload-artifact@v7
        with:
          name: dist
          path: dist/

  docs:
    runs-on: ubuntu-latest
    needs: [lint, unit-tests]
    steps:
      - uses: actions/checkout@v7
      - uses: astral-sh/setup-uv@v7
        with:
          enable-cache: true
      - name: Sync environment
        run: uv sync --locked --extra dev --extra docs
      - name: Build documentation
        # -W turns warnings into errors. The tree carried 398 of them before
        # #124, which hid ~65 toctree entries pointing at pages nobody had
        # written. Zero now, so keep it that way.
        run: uv run make -C docs html SPHINXOPTS="-W"
      - uses: actions/upload-artifact@v7
        with:
          name: documentation
          path: docs/_build/html/
      - name: Package for Pages
        # Only on main: a PR must not publish. Uploaded as a *Pages* artifact,
        # which is a distinct format from the plain artifact above -- that one
        # stays, because it is how a PR author inspects a docs change before it
        # ships.
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        uses: actions/upload-pages-artifact@v5
        with:
          path: docs/_build/html/

  deploy-docs:
    # #191: the `Documentation` URL in pyproject.toml pointed at a Read the Docs
    # site that was never set up -- 404 under both the old and new package names.
    # That URL is baked into wheel and sdist metadata, so it becomes one of three
    # links on the PyPI landing page; publishing (#180) with it dead ships a
    # prominent 404.
    #
    # Pages rather than RTD because the HTML already exists: the `docs` job has
    # built it on every PR all along and simply never published it. This is a
    # deploy step, where RTD would have meant a new external account and a
    # `.readthedocs.yaml` duplicating the build this repo already does under uv.
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    needs: docs
    runs-on: ubuntu-latest
    permissions:
      pages: write         # deploy to the Pages site
      id-token: write      # OIDC, which deploy-pages requires
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v5
