diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 413dd94..56fff13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,34 +4,65 @@ on: push: branches: [master] pull_request: - branches: [master] + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +# Read-only by default. A job that needs more asks for it itself, so a +# compromised dependency in one step cannot push to the repository. +permissions: + contents: read + +env: + PYTHONUNBUFFERED: "1" + PIP_DISABLE_PIP_VERSION_CHECK: "1" + FORCE_COLOR: "1" jobs: + lint: + name: Lint and types + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: pyproject.toml + - run: pip install -e ".[dev]" + # No `ruff format --check` yet: twenty files predate the formatter and + # reformatting them belongs in its own commit, not in a lint gate that + # would be red from the day it lands. pre-commit formats what a + # contributor touches in the meantime. + - name: ruff check + run: python -m ruff check . + - name: mypy + run: python -m mypy glia + test: + name: Tests (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.10", "3.11", "3.12"] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: pyproject.toml - name: Install (dev extras) run: | python -m pip install --upgrade pip pip install -e ".[dev]" - - name: Lint (ruff) - run: python -m ruff check . - - - name: Types (mypy) - run: python -m mypy glia - - name: Tests (pytest, fully offline) + coverage gate run: python -m pytest -q --cov=glia --cov-report=term-missing --cov-fail-under=90 @@ -41,3 +72,72 @@ jobs: echo "--- $f ---" python "$f" done + + platforms: + name: Tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: pyproject.toml + - run: pip install -e ".[dev]" + # The suite only, without the coverage gate and the examples loop. The + # shell ships Windows and macOS binaries, so the library underneath them + # has to be known to work there; the gate and the examples are already + # measured once on Linux and measuring them three times says nothing new. + - name: Tests + run: python -m pytest -q + + build: + name: Package builds and installs cleanly + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + - run: pip install build twine + - name: Build the wheel and sdist + run: python -m build + - name: Check the metadata + run: twine check dist/* + # An editable install hides packaging mistakes: it puts the working tree + # on the path, so a module missing from the wheel still imports and a + # broken console script still resolves. + - name: Install the wheel into a clean environment + run: | + python -m venv /tmp/fresh + /tmp/fresh/bin/pip install dist/*.whl + # The distribution is glia-agents and the import package is glia. + # That mismatch is the one packaging mistake nobody notices locally. + /tmp/fresh/bin/python -c "import glia; print(glia.__name__)" + /tmp/fresh/bin/glia-shell --help > /dev/null + - uses: actions/upload-artifact@v7 + with: + name: dist + path: dist/ + retention-days: 14 + + ci: + name: CI + runs-on: ubuntu-latest + if: always() + needs: [lint, test, platforms, build] + steps: + # One aggregate check to require in branch protection. Without it, adding + # a job to the matrix silently leaves it unrequired, and a red job stops + # blocking merges. + - name: Fail if any job did not succeed + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') + run: | + echo "One or more jobs failed:" + echo '${{ toJSON(needs) }}' + exit 1 + - run: echo "All checks passed." diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..419104b --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,39 @@ +name: CodeQL + +on: + push: + branches: [master] + pull_request: + branches: [master] + schedule: + # Weekly, because advisories land between pushes: a rule added after the + # last commit would otherwise never run against this code. + - cron: "29 5 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + analyze: + name: Analyze Python + runs-on: ubuntu-latest + if: github.event.repository.visibility == 'public' + permissions: + security-events: write + actions: read + contents: read + steps: + - uses: actions/checkout@v7 + + - uses: github/codeql-action/init@v4 + with: + languages: python + # security-and-quality over the default: this library executes + # user-declared tools and serves HTTP from the shell, so the extra + # checks earn their noise. + queries: security-and-quality + + - uses: github/codeql-action/analyze@v4 + with: + category: "/language:python" diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 0000000..d06aeda --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,49 @@ +name: Dependabot auto-merge + +# What this does and does not do: +# +# auto-merged - GitHub Actions bumps, and patch-level bumps of anything else +# left for you - every minor and major bump of a runtime dependency +# +# A patch release and an action bump are the updates that pile up unread until +# the queue is too long to review honestly. A minor bump can change behaviour, +# so it keeps a human. Auto-merge is queued, not immediate: GitHub still waits +# for the required checks to pass, and a red build leaves the PR open. + +on: pull_request_target + +permissions: + contents: read + +jobs: + auto-merge: + if: github.actor == 'dependabot[bot]' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + # Reads the update type from the PR that Dependabot opened. Nothing from + # the branch is checked out or executed, which is what makes + # pull_request_target safe to use here. + - id: metadata + uses: dependabot/fetch-metadata@v2 + + - name: Approve and queue the merge + if: >- + steps.metadata.outputs.package-ecosystem == 'github_actions' || + steps.metadata.outputs.update-type == 'version-update:semver-patch' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR: ${{ github.event.pull_request.html_url }} + run: | + gh pr review --approve "$PR" + gh pr merge --auto --squash "$PR" + + - name: Explain why this one was left alone + if: >- + steps.metadata.outputs.package-ecosystem != 'github_actions' && + steps.metadata.outputs.update-type != 'version-update:semver-patch' + env: + TYPE: ${{ steps.metadata.outputs.update-type }} + run: echo "$TYPE is not auto-merged; this pull request needs a human." diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 33a20fc..4bd4a85 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -20,11 +20,11 @@ jobs: deploy-docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8793b93..52973be 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -18,9 +18,9 @@ jobs: permissions: id-token: write # required for Trusted Publishing (OIDC) steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" diff --git a/.github/workflows/release-app.yml b/.github/workflows/release-app.yml index 0ed577e..58edac1 100644 --- a/.github/workflows/release-app.yml +++ b/.github/workflows/release-app.yml @@ -36,9 +36,9 @@ jobs: bin: dist/glia-shell runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" @@ -64,7 +64,7 @@ jobs: run: cp "${{ matrix.bin }}" "${{ matrix.asset }}" - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: ${{ matrix.asset }} path: ${{ matrix.asset }} @@ -76,7 +76,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download all binaries - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: path: bins merge-multiple: true diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3e4f250 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,42 @@ +# Install with: pre-commit install +# +# ruff-format runs here and not in CI on purpose. Twenty files predate the +# formatter; a CI gate would be red on day one, while this hook only touches +# what a commit already changes. +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.20 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-added-large-files + args: [--maxkb=512] + - id: check-merge-conflict + - id: check-toml + - id: check-yaml + - id: end-of-file-fixer + - id: mixed-line-ending + args: [--fix=lf] + - id: trailing-whitespace + + - repo: local + hooks: + # A PyInstaller build directory is the output of release-app.yml. It is + # large, it is per-platform, and it has no business in the history. + - id: reject-pyinstaller-output + name: reject PyInstaller build output + entry: build/ and dist/ are release output, not source + language: fail + files: ^(build|dist)/ + + - id: pytest + name: pytest + entry: pytest -q + language: system + pass_filenames: false + stages: [pre-push]