Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
65 changes: 65 additions & 0 deletions .github/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# GitHub Actions and Workflows

This directory contains the CI/CD configuration for **network_wrangler**: workflows that run on pull requests and pushes, plus reusable composite actions used by those workflows.

---

## Workflows

Workflows live in [`.github/workflows/`](workflows/). They run on different triggers and coordinate linting, testing, docs, benchmarks, and releases.

| Workflow | Trigger | Lint | Format | Tests | Benchmark | Docs | Coverage |
|----------|---------|:----:|:------:|:-----:|:---------:|:----:|:--------:|
| **PR Checks** (`pullrequest.yml`) | `pull_request` (opened, synchronize, reopened) | ✓ | ✓ (fix) | ✓ | ✓ | ✓ | ✓ |
| **CI** (`push.yml`) | `push` to `main` or `develop` | ✓ | ✓ (check) | ✓ | ✓ | ✓ | — |
| **Prepare Release** (`prepare-release.yml`) | Release **created** or manual | — | — | — | — | — | — |
| **Publish Release** (`publish.yml`) | Release **published** or manual | — | — | — | — | ✓ | — |
| **Clean Documentation** (`clean-docs.yml`) | Branch/tag **deleted** or PR **closed** | — | — | — | — | ✓ (delete) | — |

- **Lint**: `ruff check` (PR: auto-fix and commit; Push: check only).
- **Format**: `ruff format` (PR: apply fixes; Push: check only).
- **Tests**: pytest on Python 3.10–3.13
- **Docs**: build and deploy to GitHub Pages (PR/Push/Release); **Clean** removes a version when a branch is deleted or PR closed.
- **Benchmark**: run and compare benchmarks.
- **Coverage**: post coverage comment on PR (when base is `main`/`develop`).

### Workflow details

- **PR Checks**
- Lint job can auto-commit formatting/lint fixes to the PR branch (with `[skip ci]`).
- Tests run in a matrix (3.10–3.13); only 3.13 produces coverage and benchmark artifacts.
- Benchmark and coverage jobs run on Python 3.13 and only when the PR base is `main` or `develop`.
- Docs are built per-PR branch; a comment with the docs URL is posted when the PR is opened.

- **Push (main/develop)**
- Same test matrix and artifact strategy.
- Benchmark comparison is only on Python 3.13 and against the previous commit on the branch.
- Docs are deployed for the pushed branch name.

- **Releases**
- **Prepare**: runs on release *created* (or manual); ensures version matches tag, publishes to TestPyPI, and verifies install.
- **Publish**: runs on release *published* as a pre-release or release (or manual); publishes to PyPI and then deploys release docs.

- **Clean docs**
- Uses `get-branch-name` to resolve the branch/tag from the event, then deletes that version from the docs site (skips `main` and `develop`).

---

## Reusable Actions

Reusable actions live in [`.github/actions/`](actions/). Workflows call them with `uses: ./.github/actions/<name>`.

| Action | Purpose |
|--------|---------|
| **setup-python-uv** | Sets up the requested Python version, installs [uv](https://github.com/astral-sh/uv), and caches UV packages (keyed by `pyproject.toml`). Used by lint, test, docs, and benchmark jobs. |
| **get-branch-name** | Outputs a normalized branch (or tag) name from the GitHub event (`push`, `pull_request`, `delete`, etc.). Used by docs and clean-docs workflows. |
| **build-docs** | Installs deps with `.[docs]`, runs `mike deploy` for the given branch name, and updates the `latest` alias when the branch is `main`. |
| **compare-benchmarks** | Compares `benchmark.json` either to the previous commit (`push`) or to the base branch (`pr`). Commits `benchmark.json` to the branch and, for PRs, posts a comment with the comparison (and regression warning if applicable). |
| **post-coverage** | Downloads the `coverage-py3.13` artifact, normalizes paths into a `coverage/` directory, and uses `MishaKav/pytest-coverage-comment` to post a coverage comment on the PR. |

---

## Other contents

- **Issue templates** ([`ISSUE_TEMPLATE/`](ISSUE_TEMPLATE/)) – Templates for bugs, features, docs, performance, and chores.
- **Pull request template** ([`pull_request_template.md`](pull_request_template.md)) – Default body for new pull requests.
47 changes: 47 additions & 0 deletions .github/actions/build-docs/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: 'Build and Deploy Docs'
description: 'Build and deploy documentation using mike'
inputs:
python-version:
description: 'Python version to use'
required: true
default: '3.13'
branch-name:
description: 'Branch name for docs version'
required: true

runs:
using: 'composite'
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Setup Python with UV
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ inputs.python-version }}

- name: Configure Git user
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
shell: bash

- name: Install dependencies
run: |
uv pip install --system -e .[docs]
shell: bash

- name: Build and deploy docs
continue-on-error: true
run: |
mike deploy --push ${{ inputs.branch-name }}
shell: bash

- name: Update latest docs alias
if: inputs.branch-name == 'main'
run: |
mike alias ${{ inputs.branch-name }} latest --update-aliases --push
shell: bash

203 changes: 203 additions & 0 deletions .github/actions/compare-benchmarks/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
name: 'Compare Benchmarks'
description: 'Compare benchmark results between branches or commits and commit benchmark.json to the branch'
inputs:
benchmark-json-path:
description: 'Path to benchmark.json file'
required: false
default: 'benchmark.json'
comparison-type:
description: 'Type of comparison: push (compare to previous commit) or pr (compare to base branch)'
required: true
base-branch:
description: 'Base branch name for PR comparisons'
required: false
github-token:
description: 'GitHub token for posting comments and committing'
required: true
pr-number:
description: 'Pull request number for posting comments'
required: false
alert-threshold:
description: 'Alert threshold percentage (e.g., 125 means 25% slower triggers alert)'
required: false
default: '125'
python-version:
description: 'Python version for installing pytest-benchmark'
required: false
default: '3.13'

runs:
using: 'composite'
steps:
- name: Setup Python with UV
uses: ./.github/actions/setup-python-uv
with:
python-version: ${{ inputs.python-version }}

- name: Install pytest-benchmark
shell: bash
run: |
uv pip install --system pytest-benchmark

- name: Compare benchmarks (Push - previous commit)
id: compare-push
if: inputs.comparison-type == 'push'
shell: bash
run: |
set -e

CURRENT_COMMIT=$(git rev-parse HEAD)
PREVIOUS_COMMIT=$(git rev-parse HEAD~1 2>/dev/null || echo "")

if [ -z "$PREVIOUS_COMMIT" ]; then
echo "No previous commit found. This appears to be the first commit with benchmarks."
echo "comparison_result=No previous commit to compare against" >> $GITHUB_OUTPUT
exit 0
fi

# Check if current benchmark.json exists
if [ ! -f "${{ inputs.benchmark-json-path }}" ]; then
echo "ERROR: Current benchmark.json not found at ${{ inputs.benchmark-json-path }}"
exit 1
fi

# Try to get previous commit's benchmark.json
PREV_BENCHMARK=$(git show ${PREVIOUS_COMMIT}:${{ inputs.benchmark-json-path }} 2>/dev/null || echo "")

if [ -z "$PREV_BENCHMARK" ]; then
echo "No previous benchmark.json found in commit ${PREVIOUS_COMMIT}. This is the first benchmark run."
echo "comparison_result=No previous benchmark to compare against" >> $GITHUB_OUTPUT
exit 0
fi

# Save previous benchmark to temp file
echo "$PREV_BENCHMARK" > /tmp/previous_benchmark.json

# Compare benchmarks
echo "Comparing current commit (${CURRENT_COMMIT:0:7}) to previous commit (${PREVIOUS_COMMIT:0:7})"
COMPARE_OUTPUT=$(pytest-benchmark compare /tmp/previous_benchmark.json ${{ inputs.benchmark-json-path }} 2>&1 || echo "Comparison completed")
echo "$COMPARE_OUTPUT"

echo "comparison_result<<EOF" >> $GITHUB_OUTPUT
echo "$COMPARE_OUTPUT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT

- name: Compare benchmarks (PR - base branch)
id: compare-pr
if: inputs.comparison-type == 'pr'
shell: bash
run: |
set -e

BASE_BRANCH="${{ inputs.base-branch }}"
CURRENT_COMMIT=$(git rev-parse HEAD)

if [ -z "$BASE_BRANCH" ]; then
echo "ERROR: Base branch not specified for PR comparison"
exit 1
fi

# Check if current benchmark.json exists
if [ ! -f "${{ inputs.benchmark-json-path }}" ]; then
echo "ERROR: Current benchmark.json not found at ${{ inputs.benchmark-json-path }}"
exit 1
fi

# Fetch base branch with full history
git fetch origin ${BASE_BRANCH} --depth=100 || git fetch origin ${BASE_BRANCH}

# Get base branch's latest commit
BASE_COMMIT=$(git rev-parse origin/${BASE_BRANCH} 2>/dev/null || echo "")

if [ -z "$BASE_COMMIT" ]; then
echo "ERROR: Could not find base branch ${BASE_BRANCH}"
exit 1
fi

# Try to get base branch's benchmark.json from the latest commit
BASE_BENCHMARK=$(git show origin/${BASE_BRANCH}:${{ inputs.benchmark-json-path }} 2>/dev/null || echo "")

if [ -z "$BASE_BENCHMARK" ]; then
echo "WARNING: No benchmark.json found in base branch ${BASE_BRANCH}. Cannot compare."
echo "This PR will be the baseline for future comparisons."
echo "comparison_result=No benchmark.json found in base branch" >> $GITHUB_OUTPUT
echo "has_regression=false" >> $GITHUB_OUTPUT
exit 0
fi

# Save base benchmark to temp file
echo "$BASE_BENCHMARK" > /tmp/base_benchmark.json

# Compare benchmarks
echo "Comparing PR branch (${CURRENT_COMMIT:0:7}) to base branch ${BASE_BRANCH} (${BASE_COMMIT:0:7})"
COMPARE_OUTPUT=$(pytest-benchmark compare /tmp/base_benchmark.json ${{ inputs.benchmark-json-path }} 2>&1 || echo "Comparison completed")
echo "$COMPARE_OUTPUT"

echo "comparison_result<<EOF" >> $GITHUB_OUTPUT
echo "$COMPARE_OUTPUT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT

# Check for regressions (look for significant slowdown indicators)
if echo "$COMPARE_OUTPUT" | grep -qiE "slower|regression|worse"; then
echo "has_regression=true" >> $GITHUB_OUTPUT
else
echo "has_regression=false" >> $GITHUB_OUTPUT
fi

- name: Commit benchmark.json to branch
shell: bash
run: |
set -e

# Configure git
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"

# Check if benchmark.json exists and has changes
if [ ! -f "${{ inputs.benchmark-json-path }}" ]; then
echo "No benchmark.json to commit"
exit 0
fi

# Check if file is already committed and unchanged
if git diff --quiet HEAD -- "${{ inputs.benchmark-json-path }}" 2>/dev/null; then
echo "benchmark.json is already committed and unchanged"
exit 0
fi

# Add and commit benchmark.json
git add "${{ inputs.benchmark-json-path }}"
git commit -m "ci: Update benchmark results [skip ci]" || echo "No changes to commit"

# Push to current branch
git push || echo "Push failed (may not have permissions or branch may be protected)"

- name: Post PR comment with comparison
id: post-comment
if: inputs.comparison-type == 'pr' && inputs.pr-number != ''
uses: actions/github-script@v7
with:
github-token: ${{ inputs.github-token }}
script: |
const comparison = `${{ steps.compare-pr.outputs.comparison_result }}`;
const hasRegression = '${{ steps.compare-pr.outputs.has_regression }}' === 'true';

let comment = '## 📊 Benchmark Comparison\n\n';
comment += `Comparing PR branch to base branch \`${{ inputs.base-branch }}\`\n\n`;

if (comparison && comparison.trim() && !comparison.includes('No benchmark')) {
comment += '```\n' + comparison + '\n```\n';
} else {
comment += 'No previous benchmark found in base branch. This will serve as the baseline.\n';
}

if (hasRegression === 'true') {
comment += '\n⚠️ **Performance regression detected!**';
}

await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: ${{ inputs.pr-number }},
body: comment,
});
48 changes: 48 additions & 0 deletions .github/actions/get-branch-name/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: 'Get Branch Name'
description: 'Get normalized branch name from GitHub event context'
outputs:
branch-name:
description: 'Normalized branch name (without refs/heads/ prefix)'
value: ${{ steps.branch-name.outputs.value }}

runs:
using: 'composite'
steps:
- name: Determine branch name
id: branch-name
run: |
if [ "${{ github.event_name }}" = "delete" ]; then
# For delete events, event.ref contains the full ref path
BRANCH_NAME="${{ github.event.ref }}"
# Remove refs/heads/ or refs/tags/ prefix if present
BRANCH_NAME="${BRANCH_NAME#refs/heads/}"
BRANCH_NAME="${BRANCH_NAME#refs/tags/}"
elif [ "${{ github.event_name }}" = "pull_request" ]; then
# For PR events, use the head branch name (source branch of the PR)
BRANCH_NAME="${{ github.event.pull_request.head.ref }}"
elif [ "${{ github.event_name }}" = "push" ]; then
# For push events, ref_name is the branch name without prefix
BRANCH_NAME="${{ github.ref_name }}"
else
# Fallback: try ref_name which works for most event types
BRANCH_NAME="${{ github.ref_name }}"
fi

# Validate that we got a branch name
if [ -z "$BRANCH_NAME" ] || [ "$BRANCH_NAME" = "" ]; then
echo "ERROR: Could not determine branch name from event" >&2
echo "Event: ${{ github.event_name }}" >&2
if [ "${{ github.event_name }}" = "delete" ]; then
echo "Ref: ${{ github.event.ref }}" >&2
elif [ "${{ github.event_name }}" = "pull_request" ]; then
echo "PR head ref: ${{ github.event.pull_request.head.ref }}" >&2
else
echo "Ref name: ${{ github.ref_name }}" >&2
fi
exit 1
fi

echo "Branch name determined: $BRANCH_NAME"
echo "value=$BRANCH_NAME" >> $GITHUB_OUTPUT
shell: bash

Loading
Loading