# Try to get a short workflow name and a job name that start with Python
# version to make it easier to check the status inside GitHub UI.
#
# When using external actions check that the external repos are permitted via
# the GitHub configuration at https://github.com/twisted/twisted/settings/actions
#
name: CI

on:
  push:
    branches:
     - trunk
     - release-*
    tags:
      - twisted-*
  pull_request:
    branches: [ trunk ]

  workflow_dispatch:
    inputs:
      debug_enabled:
        type: boolean
        description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)'
        required: false
        default: false

permissions:
  contents: read

# Only have a run a single parallel for each branch.
# Runs for trunk are queues.
# Older runs for non-trunk branches are cancelled and the jobs are executed
# only for the latest push to the branch.
concurrency:
  group: ${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/trunk' }}


defaults:
  run:
    shell: bash

env:
  # Set to 'yes' to open a tunnel to GitHub's VMs through tmate on failures.
  # You can also trigger it via manual workflow trigger.
  # https://github.com/mxschmitt/action-tmate#manually-triggered-debug
  TMATE_DEBUG: 'no'
  # The default values in the job generated by the matrix.
  DEFAULT_PYTHON_VERSION: '3.11'
  # Dump Python tracebacks on segfault:
  PYTHONFAULTHANDLER: '1'

jobs:
  testing:
    # We can't use `env.*` in the job name, only in the steps.
    name: ${{ matrix.python-version }}-${{ matrix.job-name || 'default-tests' }}
    # We have Ubuntu as the base for running agains multiple Python versions.
    runs-on: "${{ matrix.runs-on || 'ubuntu-22.04' }}"
    env:
      # By default we run all tests with all deps with coverage.
      TOXENV: "${{ matrix.tox-env || 'alldeps-withcov-posix' }}"
      # As of April 2021 GHA VM have 2 CPUs - Azure Standard_DS2_v2
      # Trial distributed jobs enabled to speed up the CI jobs.
      # On Windows, we don't yet enable distributed tests as is not yet
      # supported.
      TRIAL_ARGS: "${{ matrix.trial-args || '-j 4' }}"
    strategy:
      fail-fast: false
      # The matrix is designed to not expand into any job.
      # It is used to document the test environement variations.
      # The actual job enviroment are defined in the `include` section.
      matrix:
        # The Python version on which the job is executed.
        # We need at least one value here, so we go with latest Python version
        # that we support..
        python-version: ['3.12']
        # Just use the default OS.
        runs-on: ['']
        # Human readable short description for this job.
        job-name: ['']
        # This is the main tox target.
        # It is later extended with more jobs that should run in a specific
        # OS + Python version configuration.
        tox-env: ['']
        # We just go with the default arguments.
        trial-args: ['']
        # By default, tests are executed without disabling IPv6.
        noipv6: ['']
        # By default, tests are executed without extra platform dependencies.
        platform-deps: ['']
        # By default, tests are executed directly via tox.
        tox-wrapper: ['']
        # Tests are executed with the default target which is the full test suite.
        trial-target: ['']
        # By default the coverage is not skipped.
        skip-coverage: ['']

        # Here the matrix is extended with variations of the default
        # value.
        include:

          # `nodeps` is created to make sure we don't have import errors in the
          # runtime and production code.

          # The minimum supported Python version should be used to maximize
          # coverage of code that otherwise depends on backports.

          # Distributed test run is disabled here so that we also have
          # end to end functional test usage for non-distributed trial runs.
          # When updating the minimum Python version here, also update the
          # `python_requires` from `setup.cfg`.
          # Here we should have the oldest Python version supported
          # by GitHub. The availability of the Python version
          # depends on the version of Ubuntu in use.
          # See https://raw.githubusercontent.com/actions/python-versions/main/versions-manifest.json
          - python-version: '3.9.12'
            tox-env: 'nodeps-withcov-posix'
            job-name: 'nodeps-test'
            runs-on: 'ubuntu-22.04'

          # `noipv6` is created to make sure all is OK on an OS which doesn't
          # have IPv6 available.
          # Any supported Python version is OK for this job.
          # We go with latest micro release for the smallest minor release
          # that we support.
          # This is also used to run tests with minimum dependency versions.
          - python-version: '3.9'
            noipv6: '-noipv6'
            tox-env: 'mindeps-withcov-posix'
            job-name: 'minimum-deps-noipv6'

          # Just Python 3.9 with default settings.
          - python-version: '3.9'

          # Just Python 3.10 with default settings.
          - python-version: '3.10'

          # Just Python 3.11 with default settings.
          - python-version: '3.11'

          # Just Python 3.13 with default settings.
          - python-version: '3.13'

          # Newest Intel macOS and oldest Python (major) supported versions.
          # On 3.9 pyobjc-core build fails.
          - python-version: '3.10'
            runs-on: 'macos-14'
            job-name: 'macos-14-default-tests'
            tox-env: 'macos-withcov-alldeps'

          # Newest ARM macOS and newest Python supported versions.
          - python-version: '3.13'
            runs-on: 'macos-15'
            job-name: 'macos-15-default-tests'
            tox-env: 'macos-withcov-alldeps'

          # Windows, minimum Python version with select reactor.
          - python-version: '3.9'
            runs-on: 'windows-2022'
            tox-env: 'alldeps-withcov-windows'
            job-name: 'win-default-tests-select'
            # Distributed trial is not yet suported on Windows so we overwrite
            # the default trial-args to remove concurrent runs and to
            # select a reactor.
            trial-args: '--reactor=select'

          # Windows, newest Python supported version with iocp reactor.
          - python-version: '3.13'
            runs-on: 'windows-2022'
            tox-env: 'alldeps-withcov-windows'
            job-name: 'win-default-tests-iocp'
            # Distributed trial is not yet suported on Windows.
            trial-args: '--reactor=iocp'

          # On PYPY coverage is very slow (5min vs 30min) as there is no C
          # extension.
          # There is very little PYPY specific code so there is not much to
          # gain from reporting coverage.
          - python-version: 'pypy3.11-v7.3.20'
            tox-env: 'alldeps-nocov-posix'
            job-name: 'no-coverage'
            skip-coverage: yes

          # We still run some tests with coverage,
          # as there are test with specific code branches for pypy.
          - python-version: 'pypy3.11-v7.3.21'
            tox-env: 'nodeps-withcov-posix'
            trial-target: '-- twisted.test.test_compat twisted.test.test_defer twisted.internet.test.test_socket twisted.trial.test.test_tests'
            job-name: 'with-coverage'

          # We run the full test suite with the GI reactor against an X virtual
          # framebuffer server.  This covers our integration with the version
          # of Gtk packaged within our selected version of Linux above.
          - python-version: '3.9'
            tox-env: 'alldeps-gtk-withcov-posix'
            platform-deps: 'gtk-platform'
            tox-wrapper: 'xvfb-run -a'
            job-name: 'gtk-tests'
            trial-args: '--reactor=gi -j 4'

          - python-version: '3.14'
            tox-env: 'nodeps-withcov-posix'
            job-name: 'nodeps-linux'
            runs-on: 'ubuntu-22.04'
            # twisted.conch https://github.com/twisted/twisted/issues/12512
            trial-target: '-- twisted.application twisted.cred twisted.internet twisted.logger twisted.mail twisted.names twisted.pair twisted.persisted twisted.protocols twisted.python twisted.runner twisted.scripts twisted.spread twisted.test twisted.trial twisted.web twisted.words'

          - python-version: '3.14'
            tox-env: 'nodeps-withcov-windows'
            job-name: 'nodeps-windows-select'
            runs-on: 'windows-2022'
            # To add support for iocp and asyncio reactors:
            # twisted.internet https://github.com/twisted/twisted/issues/12513
            trial-target: '-- twisted.application twisted.cred twisted.logger twisted.mail twisted.names twisted.pair twisted.persisted twisted.protocols twisted.python twisted.runner twisted.scripts twisted.spread twisted.test twisted.trial twisted.web twisted.words'
            # Should be removed once we can run distributed tests on Windows
            # https://github.com/twisted/twisted/issues/12521
            trial-args: '--reactor=select'

          - python-version: '3.14'
            tox-env: 'nodeps-withcov-posix'
            job-name: 'nodeps-macos'
            runs-on: 'macos-14'
            trial-target: '-- twisted.application twisted.cred twisted.internet twisted.logger twisted.mail twisted.names twisted.pair twisted.persisted twisted.protocols twisted.python twisted.runner twisted.scripts twisted.spread twisted.test twisted.trial twisted.web twisted.words'

    steps:
    - uses: actions/checkout@v6

    - name: Set up Python
      uses: actions/setup-python@v6
      with:
        python-version: ${{ matrix.python-version }}
        allow-prereleases: true
        check-latest: true
        cache: pip
        cache-dependency-path: |
          pyproject.toml
          setup.cfg
          tox.ini

    # Make sure the matrix is defined in such a way that this is triggered
    # only on Linux.
    - name: Disable IPv6
      if: matrix.noipv6
      run: |
        sudo sysctl -w net.ipv6.conf.all.disable_ipv6=1
        sudo sysctl -w net.ipv6.conf.default.disable_ipv6=1
        sudo sysctl -w net.ipv6.conf.lo.disable_ipv6=1
        ip a

    - uses: twisted/python-info-action@v1
    - name: Install dependencies
      run: |
        openssl version
        python -m pip install --upgrade pip tox~=3.0 coverage

    # Make sure the matrix is defined in such a way that this is triggered
    # only on Linux.
    - name: Install GTK system deps
      if: matrix.platform-deps == 'gtk-platform'
      run: |
        # *-dev dependencies are for pygobject
        # https://gitlab.gnome.org/GNOME/pygobject/-/blob/3.42.0/setup.py#L129-L134
        sudo apt-get update
        sudo apt-get install -y \
          libgirepository1.0-dev \
          libglib2.0-dev \
          libcairo2-dev \
          libffi-dev \
          gir1.2-gtk-3.0 \
          xvfb

    - name: Test
      run: |
        ${{ matrix.tox-wrapper }} tox ${{ matrix.trial-target }}

    # If one of the above steps fails, fire up tmate for remote debugging.
    # This is fired for manual trigger or via the environment variable.
    - name: Tmate debug session
      if: ${{ failure() && (env.TMATE_DEBUG == 'yes' || github.event_name == 'workflow_dispatch' && inputs.debug_enabled ) }}
      uses: mxschmitt/action-tmate@v3
      with:
        limit-access-to-actor: true

    - name: Prepare coverage results
      if: ${{ !cancelled() && !matrix.skip-coverage }}
      run: |
        # sub-process coverage are generated in separate files so we combine them
        # to get an unified coverage for the local run.
        python -m coverage combine
        python -m coverage xml -o coverage.xml -i

        # It is important to keep the unique names for the coverage files
        # so that when uploaded to the same artifact, they don't overlap.
        # We don't combine the files yet.
        # The files from all jobs will be combined later.
        # We still do a initial combine for each job as otherwise
        # we wil end up with many files and it will take a long time
        # to upload/download/combine them.
        mv .coverage .coverage-job-${{ matrix.python-version || env.DEFAULT_PYTHON_VERSION }}-${{ matrix.job-name || 'default-tests' }}

    - name: Store coverage file
      if: ${{ !cancelled() && !matrix.skip-coverage }}
      uses: actions/upload-artifact@v7
      with:
        name: coverage-${{ matrix.python-version || env.DEFAULT_PYTHON_VERSION }}-${{ matrix.job-name || 'default-tests' }}
        include-hidden-files: true
        if-no-files-found: error  # 'warn' or 'ignore', defaults to `warn`
        path: .coverage-job-*


  coverage-report:
    name: Coverage report
    runs-on: ubuntu-latest
    if: always()  # Always run the coverage report, even when the tests fail.
    needs:
      - testing  # Code coverage only needs to wait for test jobs.
    steps:
    - uses: actions/checkout@v6
      with:
        fetch-depth: 0

    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        python -m pip install --upgrade coverage[toml] diff_cover

    - name: Download coverage reports
      uses: actions/download-artifact@v8
      with:
        pattern: coverage-*
        merge-multiple: true
        path: .

    - name: Prepare coverage
      run: |
          coverage combine .coverage-job-*
          # XML is needed for the diff-cover.
          coverage xml

    - name: Report coverage
      run: |
          # Report for the job log.
          diff-cover --markdown-report coverage-report.md --compare-branch origin/trunk coverage.xml
          cat coverage-report.md >> $GITHUB_STEP_SUMMARY

          {
            echo '# Coverage Report'
            echo '```'
            coverage report --skip-covered --skip-empty --show-missing
            echo '```'
          } >> $GITHUB_STEP_SUMMARY

    - name: Enforce diff coverage
      run: |
          diff-cover --fail-under=100 --compare-branch origin/trunk coverage.xml

    - name: Generate HTML report on failure
      if: ${{ failure() }}
      run: |
          coverage html --skip-covered --skip-empty

    - name: Upload HTML report on failure
      if: ${{ failure() }}
      uses: actions/upload-artifact@v7
      with:
        name: html-report
        path: htmlcov

    - name: Publish to codecov.io
      uses: codecov/codecov-action@v4
      if: ${{ !cancelled() }}
      with:
        files: coverage.xml
        name: tests-combined
        fail_ci_if_error: true


  benchmarks:
    runs-on: ubuntu-22.04

    steps:
    - uses: actions/checkout@v6

    - name: Set up Python
      uses: actions/setup-python@v6
      with:
        # When you will need to change this to run the benchmarks
        # on a different Python version,
        # it will also change the benchmark results.
        # This is expected and unavoidable.
        # This is why it's best not to configure the default
        # Python version here,
        # but rather have an explicit Python version.
        # The PRs that change the Python version can
        # be merged by ignoring the benchmark results.
        python-version: '3.12'

    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip
        python -m pip install '.[tls,conch,http2]' pytest pytest-codspeed pytest-cov pytest-benchmark

    - name: Run benchmarks
      uses: CodSpeedHQ/action@v3
      with:
        token: ${{ secrets.CODSPEED_TOKEN }}
        # codspeed runs this command under a CPU emulator, so it's super-slow,
        # so we try to just do the benchmarks and nothing else.
        run: python -m pytest --codspeed benchmarks/

    - name: Collect benchmarks coverage
      run: |
        # We try to run the benchmark tests only once.
        # It should be enough to collect the coverage.
        # The previous Codspeed run takes care of the actual benchmark
        # measurements.
        pytest \
          --benchmark-min-rounds 1 --benchmark-max-time 0.00001 \
          --benchmark-columns=rounds \
          --cov benchmarks --cov twisted --cov-config=.coveragerc \
          --cov-report=xml \
          benchmarks

    - name: Publish benchmarks coverage
      uses: codecov/codecov-action@v4
      if: ${{ !cancelled() }}
      with:
        files: coverage.xml
        name: benchmarks
        fail_ci_if_error: true

    - name: Fail on missing benchmarks coverage
      run: |
        # This makes sure that the benchmark tests always have 100% coverage.
        # Make sure the include expression is escaped to prevent shell
        # expansion.
        coverage report --fail-under=100 --include 'benchmarks/*' --show-missing


  static-checks:
    runs-on: ubuntu-22.04
    env:
      TOX_PARALLEL_NO_SPINNER: 1

    steps:
    - uses: actions/checkout@v6
      with:
        # Need full history for various diff checks to work.
        fetch-depth: 0

    - name: Set up Python
      uses: actions/setup-python@v6
      with:
        python-version: '${{ env.DEFAULT_PYTHON_VERSION }}'

    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip tox~=3.0

    - name: Run the checks
      run: |
        python --version
        tox --parallel -e lint,mypy,newsfragment


  # Used to validate the narrative and API docs.
  # Checks for invalid syntax or broken links.
  # This is only the validation and help highlight docs errors.
  # The actual docs are built and published via Read The Docs.
  apidocs:
    name: API docs build
    runs-on: ubuntu-22.04
    steps:
    - uses: actions/checkout@v6
    - name: Set up Python
      uses: actions/setup-python@v6
      with:
        python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip tox~=3.0

    - name: Build apidocs
      run: |
        tox -e apidocs

  narrativedocs:
    name: Narrative docs build
    runs-on: ubuntu-22.04
    steps:
    - uses: actions/checkout@v6
    - name: Set up Python
      uses: actions/setup-python@v6
      with:
        python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
    - name: Install dependencies
      run: |
        python -m pip install --upgrade pip tox~=3.0

    - name: Run the checks
      run: |
        tox -e narrativedocs

  # Used for various release automation, but not for publishing, as publishing needs extra permissions for the API token.
  # So we isolate the actual publish step in a separate job.
  # This is also executed for each PR to exercise the release as much
  # as possible and reduce the possibility of finding bugs in the release
  # process late in the release cycle,
  # The files are published only when a tag is created using the next job, release-publish
  release-build:
    name: Build and check release, then upload artifact
    runs-on: 'ubuntu-22.04'
    steps:
    - uses: actions/checkout@v6

    - name: Set up Python
      uses: actions/setup-python@v6
      with:
        python-version: '${{ env.DEFAULT_PYTHON_VERSION }}'

    - name: Test
      run: |
        python -m pip install --upgrade pip 'tox>4'
        tox -e release-prepare

    - uses: twisted/python-info-action@v1

    - name: Files to be pushed to PyPi
      run: |
        ls -R dist/

    - uses: actions/upload-artifact@v7
      with:
        name: dist
        path: dist

    - name: Check matched tag version and branch version - on tag
      if: startsWith(github.ref, 'refs/tags/twisted-')
      run: |
        python -m pip install pep517==0.13.1
        python admin/check_tag_version_match.py "${{ github.ref }}"

  # Used exclusively for PyPi release publishing.
  # This is only executed on tags and only does the publishing.
  # The actual build is done by `release-build` job,
  # which is executed for each PR to help detect release
  # defects during the normal development cycle.
  release-publish:
    needs:
      release-build
    if: startsWith(github.ref, 'refs/tags/twisted-')
    environment:
      release
    permissions:
      # IMPORTANT: this permission is mandatory for trusted publishing
      id-token: write
    name: publish on twisted-* tag
    runs-on: ubuntu-latest
    steps:
    - uses: actions/download-artifact@v8
      with:
        name: dist
        path: dist

    - name: Publish to PyPI - on tag
      uses: pypa/gh-action-pypi-publish@release/v1


  # We have this job so that the PR can be blocked on a single job.
  # In this way, each time a job is modified,
  # we don't have to go to GitHub UI and reconfigure branch protection.
  # See GitHub support answer for this hack.
  # https://gist.github.com/altendky/2e3483a1f7e1ba21cc97de75db9b7d1c
  all-successful:
    # Is very important to force running this always, as otherwise it will be
    #  skipped by default.
    if: always()
    runs-on: ubuntu-latest
    # Here should be the list of all the other jobs defined in this file.
    needs:
      - benchmarks
      - testing
      - narrativedocs
      - apidocs
      - static-checks
      - release-build
      - coverage-report
    steps:
      - name: Require all successes
        shell: python
        env:
          RESULTS: ${{ toJSON(needs.*.result) }}
        run: |
          import json
          import os
          import sys
          results = json.loads(os.environ["RESULTS"])
          sys.exit(0 if all(result == "success" for result in results) else 1)
