Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
43ddb7d
fix: close the httpx client on every authentication failure path
aditeyabaral Sep 12, 2026
40b6107
perf: stop double-prefetching the unauthenticated CSRF token
aditeyabaral Sep 12, 2026
f569aed
ci: allow staging to be deployed manually
aditeyabaral Sep 12, 2026
6204d6b
docs: correct the /health response types and ruff target version
aditeyabaral Sep 12, 2026
5e79b19
ci: require every pull request to bump the project version
aditeyabaral Sep 12, 2026
2602fdc
chore: bump version to 4.0.1
aditeyabaral Sep 12, 2026
d4fe4e6
fix: harden the HTTP client lifecycle in PESUAcademy
aditeyabaral Sep 12, 2026
15458c3
fix: log expected client errors without a stack trace
aditeyabaral Sep 12, 2026
ebf70df
ci: fail loudly on rejected deploy hooks, and drop deprecated actions
aditeyabaral Sep 12, 2026
1a39d82
fix: stop the validation handler logging submitted passwords
aditeyabaral Sep 12, 2026
d10dcdb
ci: track latest action majors and stop linting three times over
aditeyabaral Sep 12, 2026
ca68745
test: cover the lifespan shutdown failure path
aditeyabaral Sep 12, 2026
00aa9ca
fix: close the HTTP client even when its cleanup is cancelled
aditeyabaral Sep 12, 2026
0614f2f
chore(deps)!: upgrade every dependency and migrate httpx -> httpx2
aditeyabaral Sep 12, 2026
851dd89
ci: bootstrap uv in the lint job the same way pre-commit does
aditeyabaral Sep 12, 2026
a31df98
chore!: require Python 3.14 and test only what ships
aditeyabaral Sep 12, 2026
eb08fd5
ci: run live tests on pull requests behind an approval gate, and scop…
aditeyabaral Sep 12, 2026
18c101a
ci: print the test run even when the pytest hook passes
aditeyabaral Sep 12, 2026
247b803
Revert "ci: run live tests on pull requests behind an approval gate"
aditeyabaral Sep 12, 2026
956c8b8
Revert "ci: scope deploy secrets to staging and production environments"
aditeyabaral Sep 12, 2026
8c6df63
Revert "ci: declare per-job permissions in the deploy workflows"
aditeyabaral Sep 12, 2026
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
21 changes: 18 additions & 3 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ your development environment and contributing to the project.
- [Pre-commit Hooks](#pre-commit-hooks-1)
- [Linting & Formatting](#linting--formatting)
- [πŸ§ͺ Running Tests](#-running-tests)
- [Tests that need credentials](#tests-that-need-credentials)
- [Writing Tests](#writing-tests)
- [πŸš€ Submitting Changes](#-submitting-changes)
- [πŸ”€ Create a Branch](#-create-a-branch)
Expand Down Expand Up @@ -74,7 +75,7 @@ projects.

### Prerequisites

- Python 3.12 or higher
- Python 3.14 or higher
- Git
- Docker

Expand All @@ -83,7 +84,7 @@ projects.
1. **Create and activate a virtual environment:**

```bash
uv venv --python 3.12
uv venv --python 3.14
source .venv/bin/activate
```

Expand Down Expand Up @@ -167,6 +168,20 @@ uv run pytest --cov
> [!NOTE]
> The pre-commit hook runs `python scripts/run_tests.py`, which uses the same underlying `pytest` runner.

### Tests that need credentials

Eleven tests are marked `secret_required` and log in to PESU Academy for real. They need the
`TEST_*` variables in your `.env`; without them `scripts/run_tests.py` deselects those tests, warns
that it has done so, and still enforces the coverage gate on the rest.

The test account allows **one active session**, so never run the live tests while another run is in
flight -- including CI. A second login is rejected and shows up as a puzzling `401`.

In CI, pull requests come from forks, and GitHub withholds secrets from fork pull requests. So
*Pre-Commit Checks* runs the reduced suite on every pull request -- it says so in the run's summary
-- and the live tests only run once the change reaches `dev`. Run them locally before you open a
pull request; CI will not cover them for you.

### Writing Tests

- Write tests for all new features and bug fixes
Expand Down Expand Up @@ -258,7 +273,7 @@ To keep the codebase clean and maintainable, please follow these conventions:
- Write clean, readable code
- Use meaningful variable and function names
- Avoid large functions; keep logic modular and composable
- Use Python 3.12+ syntax when appropriate (e.g., `match`, `|` union types)
- Use Python 3.14+ syntax when appropriate (e.g., `match`, `|` union types)
- Keep imports sorted and remove unused ones (handled automatically via `ruff`)

### πŸ“ Docstrings & Comments
Expand Down
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ body:
attributes:
label: πŸ§ͺ Runtime Environment
description: OS, Python version, Docker/uv, etc.
placeholder: e.g. "PopOS 24.04, Python 3.12, uv, Docker 26.1"
placeholder: e.g. "PopOS 24.04, Python 3.14, uv, Docker 26.1"
validations:
required: true

Expand Down
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Please provide a concise summary of the changes:
> βš™οΈ **Test Configuration:**
>
> - OS: (e.g., `Linux`)
> - Python: (e.g., `3.12` via `uv`)
> - Python: (e.g., `3.14` via `uv`)
> - [ ] Docker build tested

## βœ… Checklist
Expand Down
114 changes: 114 additions & 0 deletions .github/scripts/check_version_bump.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Check that a pull request raises the project version and keeps uv.lock in step.

Used by .github/workflows/version-check.yaml. Compares the ``project.version`` in the base
branch's pyproject.toml against the pull request's, and requires the latter to be strictly
greater. Also checks that uv.lock records the same version, since bumping pyproject.toml without
re-running ``uv lock`` leaves the lockfile stale.
"""

from __future__ import annotations

import argparse
import re
import sys
import tomllib
from pathlib import Path

LOCK_VERSION_PATTERN = re.compile(
r'^name = "pesu-auth"\nversion = "(?P<version>[^"]+)"',
re.MULTILINE,
)


def read_project_version(path: Path) -> str:
"""Read ``project.version`` out of a pyproject.toml file.

Args:
path (Path): Path to the pyproject.toml file.

Returns:
str: The declared project version.
"""
with path.open("rb") as handle:
return str(tomllib.load(handle)["project"]["version"])


def read_lock_version(path: Path) -> str | None:
"""Read the pesu-auth version recorded in a uv.lock file.

Args:
path (Path): Path to the uv.lock file.

Returns:
str | None: The locked version, or None if the package entry is absent.
"""
match = LOCK_VERSION_PATTERN.search(path.read_text())
return match.group("version") if match else None


def parse_version(version: str) -> tuple[int, ...]:
"""Parse a dotted version string into a comparable tuple of integers.

Args:
version (str): A version such as "4.0.1".

Returns:
tuple[int, ...]: The numeric components, e.g. (4, 0, 1).

Raises:
SystemExit: If the version is not a plain dotted-numeric string.
"""
if not re.fullmatch(r"\d+(\.\d+)*", version):
raise SystemExit(f"❌ Cannot compare non-numeric version {version!r}.")
return tuple(int(part) for part in version.split("."))


def main() -> int:
"""Compare the base and head versions and report the outcome.

Returns:
int: 0 if the version was bumped correctly, 1 otherwise.
"""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-pyproject", type=Path, required=True)
parser.add_argument("--head-pyproject", type=Path, required=True)
parser.add_argument("--head-lock", type=Path, required=True)
parser.add_argument("--base-ref", default="the base branch")
args = parser.parse_args()

base_version = read_project_version(args.base_pyproject)
head_version = read_project_version(args.head_pyproject)
lock_version = read_lock_version(args.head_lock)

print(f"{args.base_ref} version : {base_version}")
print(f"this PR's version : {head_version}")
print(f"uv.lock version : {lock_version}")

if parse_version(head_version) <= parse_version(base_version):
print(
f"\n❌ The project version must be raised above {base_version}, but this PR leaves it "
f"at {head_version}.\n\n"
" Every pull request has to bump `version` in pyproject.toml so that what is\n"
" deployed can be identified. Pick the level that matches the change:\n\n"
" major (X.0.0) - a backwards-incompatible API or schema change\n"
" minor (x.Y.0) - new functionality that keeps existing APIs working\n"
" patch (x.y.Z) - a bug fix or an internal change\n\n"
" Then run `uv lock` so uv.lock records the new version, and commit both files.",
)
return 1

if lock_version != head_version:
print(
f"\n❌ pyproject.toml says {head_version} but uv.lock says {lock_version}.\n\n"
" Run `uv lock` and commit uv.lock alongside pyproject.toml, otherwise the\n"
" lockfile is stale and the Docker build installs a differently versioned project.",
)
return 1

print(f"\nβœ… Version raised from {base_version} to {head_version}, with uv.lock in step.")
return 0


if __name__ == "__main__": # pragma: no cover
sys.exit(main()) # pragma: no cover
19 changes: 9 additions & 10 deletions .github/workflows/deploy-prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
if: ${{ contains(fromJson(vars.PROD_DEPLOYMENT_ALLOWED_USERS), github.actor) }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
Expand All @@ -33,7 +33,6 @@ jobs:
git merge --ff-only origin/dev || {
echo "❌ Fast-forward merge failed. Manual conflict resolution required."
echo "Please ensure dev branch is ahead of main with no conflicts."
git merge --abort
exit 1
}

Expand All @@ -50,7 +49,7 @@ jobs:
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
ref: main

Expand All @@ -66,7 +65,7 @@ jobs:
run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"

- name: Log in to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
Expand All @@ -91,7 +90,7 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
ref: main

Expand All @@ -100,7 +99,7 @@ jobs:
run: echo "tag=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"

- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
Expand All @@ -126,15 +125,15 @@ jobs:
steps:
- name: Check Staging Deploy Hook URL
run: |
if [ -z "${{ secrets.RENDER_DEPLOY_HOOK_URL_DEV }}" ]; then
if [ -z "$RENDER_DEPLOY_HOOK_URL_DEV" ]; then
echo "❌ Staging deploy hook missing!"
exit 1
fi

- name: Deploy to Staging
run: |
echo "πŸš€ Deploying to Staging..."
curl -X POST ${{ secrets.RENDER_DEPLOY_HOOK_URL_DEV }} || {
curl -fsS -X POST "$RENDER_DEPLOY_HOOK_URL_DEV" || {
echo "❌ Staging deploy failed!"
exit 1
}
Expand All @@ -150,15 +149,15 @@ jobs:
steps:
- name: Check Production Deploy Hook URL
run: |
if [ -z "${{ secrets.RENDER_DEPLOY_HOOK_URL_PROD }}" ]; then
if [ -z "$RENDER_DEPLOY_HOOK_URL_PROD" ]; then
echo "❌ Production deploy hook missing!"
exit 1
fi

- name: Deploy to Production
run: |
echo "πŸš€ Deploying to Production..."
curl -X POST ${{ secrets.RENDER_DEPLOY_HOOK_URL_PROD }} || {
curl -fsS -X POST "$RENDER_DEPLOY_HOOK_URL_PROD" || {
echo "❌ Production deploy failed!"
exit 1
}
Expand Down
39 changes: 36 additions & 3 deletions .github/workflows/deploy-staging.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,59 @@ on:
workflows: [ "Pre-Commit Checks" ]
types:
- completed
workflow_dispatch:

concurrency:
group: deploy-staging
cancel-in-progress: false

jobs:
# Deploy to staging environment
deploy-staging:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'dev' }}
if: >-
${{
(github.event_name == 'workflow_run'
&& github.event.workflow_run.conclusion == 'success'
&& github.event.workflow_run.head_branch == 'dev')
|| (github.event_name == 'workflow_dispatch' && github.ref_name == 'dev')
}}
env:
RENDER_DEPLOY_HOOK_URL_DEV: ${{ secrets.RENDER_DEPLOY_HOOK_URL_DEV }}
steps:
- name: Check Staging Deploy Hook URL
run: |
if [ -z "${{ secrets.RENDER_DEPLOY_HOOK_URL_DEV }}" ]; then
if [ -z "$RENDER_DEPLOY_HOOK_URL_DEV" ]; then
echo "❌ Staging deploy hook missing!"
exit 1
fi

- name: Verify dev has not advanced past the validated commit
id: freshness
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EVENT_NAME: ${{ github.event_name }}
VALIDATED_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
DEV_HEAD="$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/heads/dev" --jq .object.sha)"
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "ℹ️ Manual dispatch: deploying current dev HEAD ($DEV_HEAD)."
echo "stale=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$DEV_HEAD" != "$VALIDATED_SHA" ]; then
echo "::notice::dev advanced from $VALIDATED_SHA to $DEV_HEAD; skipping stale deploy."
echo "stale=true" >> "$GITHUB_OUTPUT"
else
echo "βœ… dev HEAD matches the validated commit $VALIDATED_SHA."
echo "stale=false" >> "$GITHUB_OUTPUT"
fi

- name: Deploy to Staging Environment
if: steps.freshness.outputs.stale == 'false'
run: |
echo "πŸš€ Deploying to Staging..."
curl -X POST ${{ secrets.RENDER_DEPLOY_HOOK_URL_DEV }} || {
curl -fsS -X POST "$RENDER_DEPLOY_HOOK_URL_DEV" || {
echo "❌ Staging deploy failed!"
exit 1
}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/docker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Get short commit hash
id: vars
Expand Down
23 changes: 9 additions & 14 deletions .github/workflows/lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,20 @@ on:
jobs:
lint:
runs-on: ubuntu-latest
strategy:
max-parallel: 5
matrix:
python-version: [ "3.12", "3.13", "3.14" ]

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

- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
python-version: "3.14"

- name: Install Ruff
run: pip install ruff
- name: Install uv
run: pip install uv

- name: Run Ruff Lint
run: |
ruff check . --output-format=github
run: uv run --only-group dev ruff check . --output-format=github

- name: Run Ruff Format Check
run: |
ruff format . --check
run: uv run --only-group dev ruff format . --check
Loading
Loading