Publish releases to PyPI through trusted publishing, when enabled #9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Lint and Test | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main] | |
| workflow_dispatch: | |
| # The release workflow calls this one, so the gate a release passes is this exact job | |
| # set rather than a copy of it that can drift. | |
| workflow_call: | |
| permissions: | |
| contents: read | |
| concurrency: | |
| # github.workflow is the calling workflow's name under workflow_call, so a release and a | |
| # push to main land in their own groups and do not cancel each other. | |
| group: ci-${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| env: | |
| GITLEAKS_VERSION: 8.30.1 | |
| # From the release's checksums file. Dependabot cannot update a pinned download, so bump | |
| # both together when moving to a new gitleaks release. | |
| GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb | |
| PIP_AUDIT_VERSION: 2.10.1 | |
| jobs: | |
| secrets: | |
| name: Secret scan (gitleaks) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Checkout (full history) | |
| uses: actions/checkout@v7 | |
| with: | |
| # Every commit, so a secret added and later removed is still found. | |
| fetch-depth: 0 | |
| - name: Install gitleaks | |
| run: | | |
| archive="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | |
| curl -sSfL -o "$RUNNER_TEMP/$archive" \ | |
| "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/$archive" | |
| echo "${GITLEAKS_SHA256} $RUNNER_TEMP/$archive" | sha256sum -c - | |
| tar -xzf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" gitleaks | |
| - name: Scan every commit | |
| run: '"$RUNNER_TEMP/gitleaks" git --config .gitleaks.toml --redact --verbose .' | |
| lint: | |
| name: Lint and format check | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Set up uv | |
| uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 | |
| with: | |
| enable-cache: true | |
| # --locked fails the job when uv.lock is out of step with pyproject.toml. | |
| - name: Install | |
| run: uv sync --locked | |
| - name: Lint | |
| run: uv run ruff check --output-format=github src tests scripts | |
| # Check only: formatting is fixed locally with 'just fmt', never in CI. | |
| - name: Format check | |
| run: uv run ruff format --check src tests scripts | |
| audit: | |
| name: Dependency audit (pip-audit) | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Set up uv | |
| uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 | |
| # The locked, hashed dependency tree, runtime and dev, exactly as CI installs it. | |
| - name: Audit the locked dependencies for known vulnerabilities | |
| run: | | |
| uv export --frozen --all-extras --format requirements-txt --no-emit-project \ | |
| --output-file "$RUNNER_TEMP/requirements.txt" | |
| uvx "pip-audit==${PIP_AUDIT_VERSION}" --strict --disable-pip --require-hashes \ | |
| --requirement "$RUNNER_TEMP/requirements.txt" | |
| test: | |
| name: Test (Python ${{ matrix.python-version }}, ${{ matrix.os }}) | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 15 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest] | |
| python-version: ["3.11", "3.12", "3.13", "3.14"] | |
| # The config path and file permission handling differ on Windows and macOS, | |
| # so run the suite there too, on one Python version. | |
| include: | |
| - os: windows-latest | |
| python-version: "3.13" | |
| - os: macos-latest | |
| python-version: "3.13" | |
| env: | |
| UV_PYTHON: ${{ matrix.python-version }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Set up uv | |
| uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 | |
| with: | |
| enable-cache: true | |
| - name: Install | |
| run: uv sync --locked | |
| # Includes the rebrand test, which renames a copy of the repository and runs its | |
| # whole suite, so 'just rebrand' is proven on every platform. | |
| - name: Test | |
| run: uv run pytest | |
| # Line and branch coverage on one platform, so the number is the same on every run. | |
| # The floor (fail_under) lives in pyproject.toml, beside the rest of the coverage config. | |
| coverage: | |
| name: Coverage | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Set up uv | |
| uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 | |
| with: | |
| enable-cache: true | |
| - name: Install | |
| run: uv sync --locked | |
| - name: Test with coverage | |
| run: uv run pytest --cov --cov-report=term-missing --cov-report=xml | |
| - name: Summarise | |
| if: always() | |
| shell: bash | |
| run: | | |
| if [ -f .coverage ]; then | |
| { | |
| echo "## Coverage" | |
| echo | |
| uv run coverage report --format=markdown --fail-under=0 | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| fi | |
| - name: Keep the report | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: coverage | |
| path: coverage.xml | |
| if-no-files-found: ignore | |
| retention-days: 14 | |
| # The total as a shields.io endpoint; badges.yml publishes it for the README badge | |
| # after a push to main. | |
| - name: Write the coverage badge | |
| shell: bash | |
| run: | | |
| total="$(uv run coverage report --format=total --precision=1 --fail-under=0)" | |
| python3 - "$total" > coverage-badge.json <<'PY' | |
| import json | |
| import sys | |
| total = float(sys.argv[1]) | |
| colour = next(c for floor, c in ((90, "brightgreen"), (80, "green"), (70, "yellow"), (0, "red")) if total >= floor) | |
| print(json.dumps({"schemaVersion": 1, "label": "coverage", "message": f"{total:.1f}%", "color": colour})) | |
| PY | |
| cat coverage-badge.json | |
| - name: Keep the badge | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: coverage-badge | |
| path: coverage-badge.json | |
| if-no-files-found: error | |
| retention-days: 14 | |
| build: | |
| name: Build | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| needs: [secrets, lint, audit, test, coverage] | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v7 | |
| - name: Set up uv | |
| uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 | |
| - name: Build the sdist and wheel | |
| run: uv build | |
| # Proves the wheel installs cleanly and the entry point resolves outside the source tree. | |
| - name: Smoke test the wheel | |
| shell: bash | |
| run: | | |
| uv venv "$RUNNER_TEMP/smoke" | |
| uv pip install --python "$RUNNER_TEMP/smoke" dist/*.whl | |
| "$RUNNER_TEMP/smoke/bin/ldo" --version | |
| "$RUNNER_TEMP/smoke/bin/ldo" --help > /dev/null | |
| # Built once here; a release publishes these exact files rather than rebuilding. | |
| - name: Keep the build | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: dist | |
| path: dist/ | |
| if-no-files-found: error | |
| retention-days: 14 |