Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
217 changes: 194 additions & 23 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,173 @@ 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 }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
set -euo pipefail

# Files release-please touches when cutting a release for this repo.
# Keep in sync with release-please-config.json: the .packages."." entry
# determines which files are bumped. Today (release-type=python +
# extra-files=src/runpod_flash/__init__.py) those are:
# - CHANGELOG.md (always)
# - .release-please-manifest.json (always)
# - pyproject.toml (release-type=python)
# - src/runpod_flash/__init__.py (extra-files)
# If you change release-please-config.json, update this regex too.
ALLOWED='^(CHANGELOG\.md|\.release-please-manifest\.json|pyproject\.toml|src/runpod_flash/__init__\.py)$'

# Identity check: real release-please bot PRs are authored by the
# GitHub App. Used as a defense-in-depth layer on top of the branch
# prefix + diff content check.
BOT_AUTHOR='runpod-release-please-bot[bot]'

# Push to main: classify by diff against the previous commit. When
# release-please's release PR is squash-merged, the resulting commit
# on main only touches the files in ALLOWED -- same code, just
# version-bumped. Re-running the full matrix would be pure waste, and
# release-please.yml's pypi-publish builds its own wheel before
# publishing, so we don't need ci.yml's build either. Anything else
# (feature merges, direct admin pushes) gets the full matrix.
# On push events the actor isn't a reliable signal (admins can merge
# the bot's PR by hand) -- diff content is the only check.
if [ "$EVENT_NAME" = "push" ]; then
CHANGED="$(git diff --name-only HEAD~1...HEAD)"
echo "Changed files in this push:"
echo "$CHANGED"
UNEXPECTED="$(echo "$CHANGED" | grep -vE "$ALLOWED" || true)"
if [ -n "$CHANGED" ] && [ -z "$UNEXPECTED" ]; then
echo "Classification: release-please release commit on main (release-please files only)."
echo "mode=release-please-bypass" >> "$GITHUB_OUTPUT"
echo "should_build=false" >> "$GITHUB_OUTPUT"
exit 0
Comment thread
deanq marked this conversation as resolved.
Outdated
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 on PR: requires ALL THREE:
# 1. Branch prefix matches `release-please--`
# 2. PR author is the release-please GitHub App
# 3. Diff contains only release-please-managed files
# Any one missing -> fall through to full CI. Together these mean a
# contributor can't fake the bypass by naming a branch
# release-please-- (author check fails) or by spoofing the bot
# account (write-access required to push as the bot is even stronger).
if [[ "$HEAD_REF" == release-please--* ]] && [ "$PR_AUTHOR" = "$BOT_AUTHOR" ]; then
UNEXPECTED="$(echo "$CHANGED" | grep -vE "$ALLOWED" || true)"
if [ -z "$UNEXPECTED" ]; then
echo "Classification: release-please bot PR (release-please files only)."
echo "mode=release-please-bypass" >> "$GITHUB_OUTPUT"
echo "should_build=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "release-please-- branch from bot contains unexpected files; falling through to full CI."
echo "Unexpected files:"
echo "$UNEXPECTED"
elif [[ "$HEAD_REF" == release-please--* ]]; then
echo "release-please-- branch but author is '$PR_AUTHOR' (not '$BOT_AUTHOR'); running full CI."
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 +179,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: uv.lock

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 +195,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: uv.lock

- name: Build package
run: make build
Expand All @@ -82,9 +223,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