Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 172 additions & 23 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,151 @@ name: CI

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

permissions:
contents: read
pull-requests: write
issues: write
actions: read

# Cancel in-flight PR runs when a new commit is pushed; never cancel main runs.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
PYTHON_VERSION: '3.11'

jobs:
# Single source of truth for "what mode should this run be in?". Inspects the
# PR diff and outputs:
# mode = release-please-bypass | full
# should_build = true | false
# The classification is content-based, not just branch-name-based -- that
# closes the CI-bypass hole where a contributor opens a PR from a branch
# named release-please-- and inherits sentinel passes for free.
guard:
name: Guard
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
mode: ${{ steps.classify.outputs.mode }}
should_build: ${{ steps.classify.outputs.should_build }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- id: classify
env:
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
HEAD_REF: ${{ github.head_ref }}
run: |
set -euo pipefail

# Push to main: classify by diff against the previous commit.
# When release-please's release PR is merged, the resulting commit on
# main only changes CHANGELOG.md + .release-please-manifest.json --
# the same code that was already validated when the underlying
# feature PRs merged. Re-running the full matrix would be pure waste,
# so we bypass; release-please.yml's pypi-publish builds its own
# wheel before publishing, so we don't need ci.yml's build either.
# Any other push:main (feature merges, direct admin pushes) gets the
# full matrix and build.
if [ "$EVENT_NAME" = "push" ]; then
CHANGED="$(git diff --name-only HEAD~1...HEAD)"
echo "Changed files in this push:"
echo "$CHANGED"
ALLOWED='^(CHANGELOG\.md|\.release-please-manifest\.json)$'
UNEXPECTED="$(echo "$CHANGED" | grep -vE "$ALLOWED" || true)"
if [ -n "$CHANGED" ] && [ -z "$UNEXPECTED" ]; then
Comment thread
deanq marked this conversation as resolved.
Outdated
echo "Classification: release-please release commit on main (metadata only)."
echo "mode=release-please-bypass" >> "$GITHUB_OUTPUT"
echo "should_build=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Classification: regular push to main."
echo "mode=full" >> "$GITHUB_OUTPUT"
echo "should_build=true" >> "$GITHUB_OUTPUT"
exit 0
Comment thread
deanq marked this conversation as resolved.
fi

CHANGED="$(git diff --name-only "origin/${BASE_REF}...HEAD")"
echo "Changed files in this PR:"
echo "$CHANGED"

# release-please bypass: branch prefix AND diff is metadata-only.
# Both conditions required -- a contributor naming a branch
# release-please-- but changing source code falls through to full CI.
if [[ "$HEAD_REF" == release-please--* ]]; then
ALLOWED='^(CHANGELOG\.md|\.release-please-manifest\.json)$'
UNEXPECTED="$(echo "$CHANGED" | grep -vE "$ALLOWED" || true)"
if [ -z "$UNEXPECTED" ]; then
Comment thread
deanq marked this conversation as resolved.
Outdated
echo "Classification: release-please bot PR (metadata only)."
echo "mode=release-please-bypass" >> "$GITHUB_OUTPUT"
echo "should_build=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "release-please-- branch contains non-metadata files; falling through to full CI."
echo "Unexpected files:"
echo "$UNEXPECTED"
fi

# Default: full CI. Build only when packaging-relevant files changed
# or a new non-Python file is added under src/ (data files need
# explicit package-data inclusion or they're silently dropped from
# the wheel).
echo "mode=full" >> "$GITHUB_OUTPUT"
if echo "$CHANGED" | grep -qE '^(pyproject\.toml|Makefile|MANIFEST\.in|scripts/validate-wheel\.sh)$'; then
echo "should_build=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if git diff --name-only --diff-filter=A "origin/${BASE_REF}...HEAD" | grep -E '^src/.+' | grep -qv '\.py$'; then
echo "should_build=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "should_build=false" >> "$GITHUB_OUTPUT"

# Fast-fail formatting/lint check. Installs only the dev dependency group
# from the lockfile (warm-cache install is ~1s), then runs ruff via
# `uv run` so we get the exact pinned ruff version that `make
# ci-quality-github` uses. Avoids `uvx ruff` -- that pulls the latest
# ruff, which can drift from the lock and produce CI verdicts that don't
# match local `make quality-check`.
pre-check:
name: Pre-check (format + lint)
runs-on: ubuntu-latest
needs: [guard]
if: ${{ needs.guard.outputs.mode == 'full' }}
timeout-minutes: 3
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: uv.lock
- name: Install dev group (frozen, no project)
run: uv sync --only-group dev --frozen
- name: Ruff format
run: uv run ruff format --check .
- name: Ruff lint
run: uv run ruff check . --output-format=github

quality-gates:
name: Quality Gates
runs-on: ubuntu-latest
needs: [guard, pre-check]
if: ${{ needs.guard.outputs.mode == 'full' }}
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13']
timeout-minutes: 15

steps:
- name: Checkout code
uses: actions/checkout@v4
Expand All @@ -35,14 +157,14 @@ jobs:
python-version: ${{ matrix.python-version }}

- name: Install uv
uses: astral-sh/setup-uv@v2
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: "pyproject.toml"
cache-dependency-glob: pyproject.toml

Comment thread
deanq marked this conversation as resolved.
- name: Install dependencies
run: make dev

- name: Quality checks
run: make ci-quality-github

Expand All @@ -51,27 +173,24 @@ jobs:
if: always()
with:
name: test-results-${{ matrix.python-version }}
path: pytest-results.xml
path: pytest-results-*.xml

build:
name: Build Package
runs-on: ubuntu-latest
needs: [quality-gates]
needs: [guard, quality-gates]
if: ${{ needs.guard.outputs.mode == 'full' && needs.guard.outputs.should_build == 'true' }}
timeout-minutes: 5

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}

- name: Install uv
uses: astral-sh/setup-uv@v2
- uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: pyproject.toml
Comment thread
deanq marked this conversation as resolved.
Outdated

- name: Build package
run: make build
Expand All @@ -82,9 +201,39 @@ jobs:
- name: Validate wheel packaging
run: ./scripts/validate-wheel.sh

- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/

# Single aggregator. Branch protection on `main` should require ONLY this
# check ("CI / Validation"). This job runs unconditionally (`if: always()`)
# and treats "skipped" as success -- so release-please-bypass runs (where
# pre-check / quality-gates / build are all deliberately skipped by the
# guard) pass cleanly. Anything that genuinely failed or was cancelled
# upstream flips this to red.
#
# Adding or removing upstream jobs (new Python version, new security scan,
# etc.) no longer requires a branch-protection update: just include the new
# job in `needs:` and the results array.
validation:
name: Validation
runs-on: ubuntu-latest
needs: [guard, pre-check, quality-gates, build]
if: always()
timeout-minutes: 1
steps:
- name: Aggregate upstream results
env:
GUARD: ${{ needs.guard.result }}
PRE_CHECK: ${{ needs.pre-check.result }}
QUALITY_GATES: ${{ needs.quality-gates.result }}
BUILD: ${{ needs.build.result }}
run: |
set -euo pipefail
echo "guard=$GUARD"
echo "pre-check=$PRE_CHECK"
echo "quality-gates=$QUALITY_GATES"
echo "build=$BUILD"
for r in "$GUARD" "$PRE_CHECK" "$QUALITY_GATES" "$BUILD"; do
if [ "$r" != "success" ] && [ "$r" != "skipped" ]; then
echo "::error::Upstream job failed or was cancelled (got: $r)"
exit 1
fi
done
echo "All upstream jobs succeeded or were intentionally skipped."
Loading
Loading