Skip to content

Auto-bump version on staging and prod deploys - #150

Closed
aditeyabaral wants to merge 9 commits into
pesu-dev:devfrom
aditeyabaral:feat/auto-version-bump
Closed

aditeyabaral wants to merge 9 commits into
pesu-dev:devfrom
aditeyabaral:feat/auto-version-bump

Conversation

@aditeyabaral

@aditeyabaral aditeyabaral commented May 22, 2026

Copy link
Copy Markdown
Member

This pull request introduces automated version bumping to the deployment workflows and improves the safety and consistency of deployments to both staging and production environments. The main changes include adding a script for version bumping, updating the GitHub Actions workflows to use this script, and enhancing deployment checks to prevent accidental or redundant deployments.

Automated version management and deployment workflow improvements:

Version bumping automation:

  • Added a new script scripts/bump_version.py that automatically bumps the patch or minor version in pyproject.toml based on the argument provided. This script is now used in both staging and production deployment workflows.

Production deployment workflow (.github/workflows/deploy-prod.yaml):

  • Introduced new jobs to bump the minor version on the main branch after syncing from dev, and to sync this version bump back to dev to keep branches aligned. These jobs ensure version consistency and are required by subsequent deployment steps.
  • Updated dependencies between jobs so that Docker image builds and deployments only occur after the version bump is completed, ensuring that production deployments always use the correct version. [1] [2] [3]
  • Explicitly set contents: write permissions for jobs that modify repository contents.

Staging deployment workflow (.github/workflows/deploy-staging.yaml):

  • Added concurrency control to prevent overlapping staging deployments for the same branch.
  • Updated the deployment trigger to skip runs for commits with messages starting with chore: bump, preventing unnecessary redeployments for automated version bumps.
  • Inserted a patch version bump (using the new script) immediately before deploying to staging, and committed the change back to the dev branch. This ensures each staging deployment corresponds to a unique version.
  • Added steps to verify that the deployment is using the validated commit and that dev has not advanced, improving deployment safety.

- Patch version bumped in deploy-staging.yaml before calling the Render
  hook, ensuring staging always runs the version shown on the dev branch
- Minor version bumped in deploy-prod.yaml after syncing dev to main,
  with patch reset to 0 and the bump synced back to dev afterward to
  prevent branch divergence on subsequent deploys

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds automated semantic version bumping to the deployment workflows: patch bumps on dev during staging deploys, and minor bumps on main during production deploys, with additional branch sync steps to keep dev and main aligned.

Changes:

  • Add a staging workflow concurrency group and a patch-version bump + commit/push step on dev before staging deployment.
  • Add a production workflow job to bump the minor version on main (commit/push) after syncing dev → main.
  • Add a production workflow job to fast-forward dev to the updated main, and update downstream job dependencies to follow the new bump/sync sequence.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
.github/workflows/deploy-staging.yaml Adds concurrency control and auto patch bump + push to dev before triggering the staging deploy hook.
.github/workflows/deploy-prod.yaml Adds auto minor bump on main, syncs the bump back to dev, and rewires job needs to reflect the new sequencing.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread .github/workflows/deploy-staging.yaml Outdated
Comment thread .github/workflows/deploy-staging.yaml Outdated
Comment thread .github/workflows/deploy-prod.yaml Outdated
- staging: guard against feedback loop by skipping bot bump commits,
  checkout exact validated SHA instead of rebasing on latest dev, verify
  dev HEAD matches before bumping, push via HEAD:dev from detached HEAD
- prod: add error handler with recovery message to sync-minor-to-dev
  fast-forward step, add missing permissions/contents:write to
  sync-dev-to-main, remove no-op git merge --abort after --ff-only failure

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 5 comments.

Comment thread .github/workflows/deploy-staging.yaml
Comment thread .github/workflows/deploy-staging.yaml Outdated
Comment thread .github/workflows/deploy-staging.yaml
Comment thread .github/workflows/deploy-prod.yaml Outdated
Comment thread .github/workflows/deploy-prod.yaml

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.

Comment thread .github/workflows/deploy-staging.yaml Outdated
Comment thread .github/workflows/deploy-staging.yaml
Comment thread .github/workflows/deploy-staging.yaml Outdated
Comment thread .github/workflows/deploy-prod.yaml
Comment thread .github/workflows/deploy-staging.yaml
Comment thread .github/workflows/deploy-prod.yaml

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

Comment thread .github/workflows/deploy-staging.yaml
Comment thread scripts/bump_version.py
Comment thread .github/workflows/deploy-staging.yaml Outdated
Comment thread .github/workflows/deploy-prod.yaml
Comment thread .github/workflows/deploy-prod.yaml Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

Comment thread .github/workflows/deploy-staging.yaml
Comment thread .github/workflows/deploy-staging.yaml
Comment thread .github/workflows/deploy-prod.yaml
Comment thread .github/workflows/deploy-prod.yaml Outdated
@aditeyabaral

Copy link
Copy Markdown
Member Author

Closing this in favour of #156, which takes the parts of it that work — and, more importantly, because the auto-bump mechanism here cannot work as written.

The blocker: both the staging job and sync-minor-to-dev git push version-bump commits to dev, but dev is a protected branch — required_approving_review_count: 1 with enforce_admins: false. The github-actions[bot] push via secrets.GITHUB_TOKEN is not an admin, so the protected-branch rule rejects it. And because the bump-and-push step is sequenced before the deploy step, the job fails there and staging never deploys at all — so this would have traded automatic version bumps for a broken staging pipeline.

Making it work would mean putting an admin bypass credential (a PAT with contents: write) into CI. That isn't a good trade for automatic version bumping, so version bumps stay manual in the release PR, as done for v4.0.0 in #155.

Two other defects worth recording, both found while folding this in:

  1. The stale-deploy guard is a no-op. It is a run: step ending in exit 0, which succeeds — so the subsequent deploy step runs regardless. It only skips the bump lines that follow it inside that same step. As written it gives the appearance of stale-deploy protection with none of the effect. fix!: client leaks, log hygiene, CI gaps, upgraded dependencies and a Python 3.14 floor #156 implements it as a step output consumed by if: so it actually skips.
  2. permissions: contents: write on deploy-staging was only required because the job pushed commits. Without the bump, the correct grant is contents: read.

What #156 carries over: the concurrency group (re-keyed to a constant — keying it on github.event.workflow_run.head_branch leaves it empty on a manual dispatch), the job permissions block, a working version of the stale-deploy guard, and the git merge --abort removal in deploy-prod.yaml (confirmed correct: an --ff-only merge that fails never creates MERGE_HEAD, so the abort only logged fatal: There is no merge to abort).

scripts/bump_version.py is not carried over. Worth noting for any future attempt that it only supports patch and minor, so the two major bumps this project has actually shipped (3.0.0 and 4.0.0) would still have been manual.

Thanks — the stale-deploy guard and the concurrency group were both good ideas, and they live on in #156.

aditeyabaral added a commit to aditeyabaral/auth that referenced this pull request Sep 12, 2026
Nothing bumps the version automatically, and pesu-dev#150's attempt to do it in
CI cannot work -- it pushes to `dev`, which is protected, and the push is
sequenced before the deploy step, so staging would never deploy. Rather
than give CI a bypass credential, this asks the author instead: a check
that compares `project.version` against the base branch and fails the
pull request if it was not raised.

Reading instead of writing sidesteps branch protection entirely, and it
is also more honest about semver -- no script can tell a patch from a
breaking change, but the person writing the change can.

The check also verifies uv.lock records the same version. Bumping
pyproject.toml without re-running `uv lock` leaves the lockfile stale,
which is a mistake easily made by hand (I nearly made it myself).

Deliberately not using a trigger-level `paths:` filter: a required status
check whose workflow never runs stays pending forever and blocks the pull
request. The job always runs and decides inside itself.

Verified against all four outcomes: an unchanged version fails, a version
that decreases fails, a bumped version with a stale uv.lock fails, and a
bumped version with a matching lockfile passes.

To actually block merges this has to be added to the branch protection
rule for `dev` as a required status check; the workflow alone only
reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ
aditeyabaral added a commit that referenced this pull request Sep 12, 2026
… Python 3.14 floor (#156)

* fix: close the httpx client on every authentication failure path

`authenticate()` acquired a client but only closed it as its last
statement, so every raise before that leaked the client and its
connection pool until garbage collection. The most common case is
`AuthenticationError` -- a wrong password, which is routine traffic
rather than an edge case -- alongside `CSRFTokenError` and the
`ProfileFetchError`/`ProfileParseError` raised out of
`get_profile_information()`. The post-login body is now wrapped in
try/finally.

Two more instances of the same bug in the same file:

- `_fetch_new_client_with_csrf_token()` created a client and never closed
  it when the CSRF meta tag was absent or when the initial GET raised.
  It cannot use try/finally, because on success the client *is* the
  return value, so it closes only on the way out via except/raise.
- `_prefetch_client_with_csrf_token()` leaked the freshly fetched client
  if it never reached the cache -- if the old client's `aclose()` raised,
  or if the task was cancelled while waiting for the lock, which can
  happen during shutdown.

Six of the seven new tests fail against the previous code; the seventh
pins the success path, which was already correct.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ

* perf: stop double-prefetching the unauthenticated CSRF token

Two independent prefetch paths fired on every `/authenticate` call, both
introduced together in 13c1475, so this has been the behaviour since
async support landed:

- `PESUAcademy._get_client_with_csrf_token()` spawns a prefetch task
  right after handing out the cached client, and
- the `/authenticate` endpoint queued another via
  `background_tasks.add_task(...)` after the response was sent.

The second closed the client the first had just cached and replaced it,
so each authentication made two `GET https://www.pesuacademy.com/Academy/`
calls and discarded one. Container logs showed the pair plainly: two
fetches, two different tokens, per request. The endpoint's copy is now
gone, halving our upstream request volume.

Two related fixes in the same area:

- The prefetch task was created with a bare `asyncio.create_task()` and
  no reference kept, so it could be garbage collected mid-flight. Tasks
  are now held in a set until they finish.
- A prefetch that raised was never observed -- the exception surfaced
  only as "Task exception was never retrieved" at GC time. The done
  callback now retrieves and logs it. A failed prefetch stays non-fatal:
  the cache is left empty and the next request fetches inline.

`CSRF_TOKEN_REFRESH_LOCK` is removed and `_refresh_csrf_token_with_lock`
renamed to `_refresh_csrf_token`. With the endpoint's path gone its only
caller is the 45-minute refresh loop, which is inherently serial, and
`lifespan()` always bypassed that lock anyway -- so it never provided
mutual exclusion. `PESUAcademy._csrf_lock` still guards every read and
write of the `(client, token)` pair, which is the invariant that matters.
The prefetch continues to fetch outside that lock and take it only for
the swap, so a 10s upstream timeout cannot block incoming requests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ

* ci: allow staging to be deployed manually

`deploy-staging.yaml` had a single `workflow_run` trigger, so staging
could not be redeployed on demand. That nearly cost us on 2026-09-12:
the v4.0.0 merge's Pre-Commit Checks failed on one matrix leg, the deploy
correctly skipped, and only the fact that a re-run emits a fresh
`workflow_run` event kept staging updatable without a full production
deploy.

Adding the trigger alone is not enough, and fails silently: on a
dispatch every `github.event.workflow_run.*` expression is empty, so the
old `if` would evaluate false and the job would always skip. The
condition now branches on `github.event_name` -- the automatic path is
unchanged, and the manual path additionally requires
`github.ref_name == 'dev'` so a dispatch cannot ship an arbitrary branch
to staging. The concurrency group is a constant rather than being keyed
on `head_branch`, which would be empty on a dispatch.

Also carried over from #150, which this supersedes:

- A freshness guard that skips the deploy when `dev` has advanced past
  the commit CI validated. #150 implemented this as a `run:` step ending
  in `exit 0`, which succeeds and lets the deploy step run regardless --
  the appearance of protection with none of the effect. It is a step
  output consumed by `if:` here so it actually skips. On a manual
  dispatch there is no validated SHA, so the guard is a deliberate no-op
  that logs the SHA going out.
- Removal of the stray `git merge --abort` in `deploy-prod.yaml`: an
  `--ff-only` merge that fails never creates MERGE_HEAD, so the abort
  only emitted "fatal: There is no merge to abort" into the logs.

The deploy hook call gains `curl -fsS`. Without `-f`, curl exits 0 on an
HTTP 4xx/5xx, so a rejected deploy hook reported "✅ deployment
completed successfully". The same latent issue exists in the three curl
calls in `deploy-prod.yaml`; those are left for a separate change.

`permissions: contents: read` -- #150 needed write only because it
pushed version bumps, which are not part of this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ

* docs: correct the /health response types and ruff target version

The `/health` response table described `status` as a `str` when the
endpoint returns a `bool`, typed `timestamp` as `string` where the
`/authenticate` table above uses `datetime`, and described that
timestamp as "the time of authentication" in a health-check table. All
three now match the conventions of the `/authenticate` table. `message`
stays `str`, which was already right.

Separately, ruff's `target-version` was `py314` while `requires-python`
is `>=3.12`, so lint would accept 3.14-only syntax that breaks the 3.12
leg of the CI matrix. Lowered to `py312` to match `requires-python`, the
README, CONTRIBUTING and the 3.12/3.13/3.14 matrix. Verified against the
ruff version pinned in .pre-commit-config.yaml rather than a newer local
one: `ruff check` and `ruff format --check` both pass unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ

* ci: require every pull request to bump the project version

Nothing bumps the version automatically, and #150's attempt to do it in
CI cannot work -- it pushes to `dev`, which is protected, and the push is
sequenced before the deploy step, so staging would never deploy. Rather
than give CI a bypass credential, this asks the author instead: a check
that compares `project.version` against the base branch and fails the
pull request if it was not raised.

Reading instead of writing sidesteps branch protection entirely, and it
is also more honest about semver -- no script can tell a patch from a
breaking change, but the person writing the change can.

The check also verifies uv.lock records the same version. Bumping
pyproject.toml without re-running `uv lock` leaves the lockfile stale,
which is a mistake easily made by hand (I nearly made it myself).

Deliberately not using a trigger-level `paths:` filter: a required status
check whose workflow never runs stays pending forever and blocks the pull
request. The job always runs and decides inside itself.

Verified against all four outcomes: an unchanged version fails, a version
that decreases fails, a bumped version with a stale uv.lock fails, and a
bumped version with a matching lockfile passes.

To actually block merges this has to be added to the branch protection
rule for `dev` as a required status check; the workflow alone only
reports.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ

* chore: bump version to 4.0.1

These are bug fixes and internal changes with no API change, so patch is
the right level. Satisfies the version check added in the previous
commit -- the pull request that introduces that rule should be the first
to follow it, not the first to be exempt from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ

* fix: harden the HTTP client lifecycle in PESUAcademy

Three related fixes to client handling, all in app/pesu.py.

**Cleanup can no longer mask the original error.** The try/finally added
earlier in this branch closes the request's client on every path, but if
`aclose()` itself raised it would replace the exception the body raised,
turning a routine 401 into a 500 -- a failure mode this branch
introduced, since the old code never closed on the error paths at all.
All four close sites now go through `_close_client_quietly()`, which logs
a warning instead of raising. `CancelledError` is deliberately not
caught, because cancellation still has to propagate.

One behaviour change falls out of that: a cached client which refuses to
close no longer aborts a token refresh. Previously the refresh raised and
left the cache empty; now the failure is logged and the fresh client is
cached as intended. `test_prefetch_closes_new_client_when_caching_fails`
asserted the old outcome and is replaced by two tests -- one for the
broken-old-client case, and one covering the window it was really meant
to protect, a prefetch cancelled while waiting for the lock.

**Shutdown stops in-flight prefetches.** `close_client()` closed the
cached client but left prefetch tasks running, so one could complete
*after* the close and quietly cache a fresh client that nobody would ever
close. It also touched `self._client` without holding `_csrf_lock`,
racing the swap in `_prefetch_client_with_csrf_token`, and left
`_csrf_token` set. Prefetches are now cancelled and awaited first, then
the cached client is closed under the lock and both cache slots cleared.
Cancelling is safe rather than leaky precisely because both prefetch
stages close their own client if interrupted before it reaches the cache.

**A cold cache no longer fetches under the lock.**
`_get_client_with_csrf_token` performed its inline fetch while holding
`_csrf_lock`, so a cold start or any burst outpacing the prefetch queued
every concurrent request behind a 10s upstream timeout. The lock now
covers only the cache read and clear. This costs nothing, because each
caller needs its own client anyway -- they were already fetching one
apiece, just one at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ

* fix: log expected client errors without a stack trace

`pesu_exception_handler` called `logging.exception()` for every
`PESUAcademyError`, so each wrong password wrote a full traceback at
ERROR level. That is wrong on three counts: a 401 is the API working
correctly rather than a server fault, the noise buries genuine errors and
makes ERROR useless as a signal, and anyone alerting on error rate gets
paged by users typo'ing their passwords. The traceback is identical every
time and carries no diagnostic value.

Severity now follows the status code -- 4xx logs a warning with no
`exc_info`, 5xx keeps the stack trace, since a 502 from the upstream or a
500 from us is genuinely worth a trace. `validation_exception_handler`
had the same problem for 400s and gets the same treatment, and now logs
the validation errors themselves, which are actually useful.

Verified in a container: a wrong password and a malformed request each
produce one WARNING line, and the whole log contains zero tracebacks
where it previously held one per failed login.

Also in this commit, since it lives in the same file: the unit
`TestClient` fixture now patches the prefetch and close, so entering the
real lifespan no longer makes a live request to pesuacademy.com on every
test that uses it. `authenticate` is left unpatched so individual tests
can still patch it themselves. The integration suite's module-scoped
fixture is deliberately left alone -- exercising the real lifespan once
per module is the point of an integration test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ

* ci: fail loudly on rejected deploy hooks, and drop deprecated actions

Both deploy-hook calls in `deploy-prod.yaml` used `curl -X POST` with no
`-f`, so curl exits 0 on an HTTP 4xx/5xx and a *rejected* deploy hook
reported "✅ deployment completed successfully". Now `curl -fsS`, matching
the staging workflow, and reading the hook URL from the job `env` rather
than interpolating the secret into the shell source.

Separately, every workflow pinned `actions/checkout@v4` and
`actions/setup-python@v5`, both of which run on Node 20. CI has started
warning "Node.js 20 is deprecated ... being forced to run on Node.js 24"
on every job, and it will break outright when GitHub drops the shim.
Bumped to `actions/checkout@v5` and `actions/setup-python@v6`.

The lint, pre-commit, docker and version-check workflows all run on pull
requests, so those bumps are exercised by this PR's own CI across
3.12/3.13/3.14. `deploy-prod.yaml` runs only on `workflow_dispatch`, so
its bumps and its curl change can only be confirmed on the next
production deploy. `docker/login-action@v3` is left alone -- it is not in
the deprecation warning and cannot be verified from a PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ

* fix: stop the validation handler logging submitted passwords

The previous commit made `validation_exception_handler` log
`exc.errors()` so the failure was diagnosable. That was a mistake:
each entry carries an "input" key, and for a *missing required field*
pydantic sets it to the entire request body -- so a request that omits
`username` logged the caller's password in plaintext:

    Request data could not be validated: [{'type': 'missing',
      'loc': ('body', 'username'), 'msg': 'Field required',
      'input': {'password': 'hunter2-actual-secret'}}]

Only `type`, `loc` and `msg` are logged now, which keeps everything that
made the change worthwhile -- which field failed and why -- and none of
the submitted values. The response body was never affected; it is built
from `loc` and `msg` only.

This is distinct from the username in failed-authentication logs, which
is deliberate and stays: tracing a failed login back to an account is
the entire point of that line. A password is never traceability data.

Two tidy-ups found in the same review pass:

- `close_client()` snapshotted `_prefetch_tasks` twice, once to cancel
  and once to await. Correct only because `cancel()` cannot run the done
  callbacks that mutate the set; now snapshotted once so it does not
  depend on that.
- The deploy-hook guard steps in `deploy-prod.yaml` still interpolated
  `${{ secrets... }}` into the shell source while the curl calls beside
  them read from the job `env`. Both now read from `env`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Coun9gitMaSfa58zivRJwQ

* ci: track latest action majors and stop linting three times over

actions/checkout v5 -> v7, actions/setup-python v6 -> v7 and
docker/login-action v3 -> v4. The earlier round bumped only the actions named
in the Node 20 deprecation warning, which is why login-action was left behind
at v3 while everything around it moved; v4 is where it picks up Node 24.

lint.yaml loses its Python matrix. Ruff is a self-contained binary whose
results depend on its own version and on tool.ruff.target-version, not on the
interpreter installed next to it, so the 3.12, 3.13 and 3.14 legs were running
byte-identical work three times. The test suite is where interpreter
differences actually surface, and pre-commit.yaml already matrices that.

It also stops installing ruff unpinned. The repo had three ruff versions at
once -- `pip install ruff` here, v0.12.7 in .pre-commit-config.yaml and
>=0.15.14 in pyproject.toml -- so a ruff release adding a rule could fail a PR
that changed nothing. The job now runs the ruff from uv.lock, and
`pre-commit autoupdate` brings the hook to the same version.

NOTE: collapsing the matrix renames the reported check from
"lint (3.12)" / "lint (3.13)" / "lint (3.14)" to "lint", so dev's required
status check contexts must be updated to match or they will sit permanently
pending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* test: cover the lifespan shutdown failure path

app/app.py:74-75 -- the branch that reports a refresh task which fails its own
cancellation -- was the last uncovered code in the project. The test stubs the
refresh loop with one that turns CancelledError into a RuntimeError, and
asserts both that the failure is logged and that shutdown carries on past it
to close the client.

Coverage is now 100% across app/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* fix: close the HTTP client even when its cleanup is cancelled

Every caller of _close_client_quietly runs it from an `except BaseException`
handler or a `finally` -- which is exactly where a *second* cancellation can
land, when a shutdown cancels a task that is already unwinding from its first
one. A plain `await client.aclose()` there is abandoned part-way and the
connection pool is never released, so the leak this helper exists to prevent
came back under the one condition the helper is most likely to run in.

The close now runs as its own shielded task: it completes regardless, while
CancelledError still propagates to the caller, so cancellation semantics are
unchanged. A module-level set holds a strong reference, since a close that
outlives its awaiter would otherwise be a bare task and free to be garbage
collected mid-flight.

The new test drives the exact sequence -- cancel while waiting for the lock,
then cancel again once the close is in flight -- and fails against the
previous implementation, where the close never finished.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* chore(deps)!: upgrade every dependency and migrate httpx -> httpx2

`uv lock --upgrade` across the board, with the pyproject floors raised to the
versions actually locked so they stop understating what the project needs.
Notable: fastapi 0.136.1 -> 0.141.1, starlette 1.0.1 -> 1.6.0, uvicorn
0.47.0 -> 0.52.4, pytest 9.0.3 -> 9.1.1 and ruff 0.15.14 -> 0.16.7. That last
one also settles the three-way ruff disagreement between this file, the
pre-commit hook and the lint workflow.

httpx is the one that is not a plain version bump. The distribution is frozen
at 0.28.1; the project continued under a new name at pydantic/httpx2, still
authored by Tom Christie, and starlette 1.6.0 now imports httpx2 first and
only falls back to httpx with a StarletteDeprecationWarning. So staying put
means staying on an abandoned distribution and on the deprecated path through
starlette's TestClient.

The API surface we use is unchanged -- AsyncClient(follow_redirects, timeout),
get, post, aclose -- so the migration is the import, the annotations and the
29 test patch targets. Production picks up httpcore2 and truststore in place
of httpcore; httpx2-jsfetch resolves into the lockfile but is emscripten-only
and never installs in the image.

Minor rather than patch, since the runtime dependency set changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* ci: bootstrap uv in the lint job the same way pre-commit does

astral-sh/setup-uv publishes no floating major tag -- only exact versions like
v10.1.0 -- so `@v10` failed to resolve and the job died before running ruff.
Using the action would mean pinning an exact version and bumping it by hand,
so this installs uv the way pre-commit.yaml already does instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* chore!: require Python 3.14 and test only what ships

The Docker image has shipped `python:3.14-alpine` for both stages while
`requires-python` said `>=3.12`, so CI spent three serial legs validating two
interpreters nobody deploys. The matrix also never produced a signal: in every
failure on record the first leg failed and fail-fast cancelled the other two,
so it only ran to completion when it had nothing to report. And the project is
not on PyPI, so nothing consumes it as a library with its own choice of
interpreter.

Rather than keep testing a promise that was never exercised, the promise is
narrowed to match reality:

- `requires-python` `>=3.12` -> `>=3.14`, with `uv.lock` relocked
- ruff `target-version` back to `py314`, which is now correct rather than
  ahead of the floor -- verified zero-churn, `ruff check` and
  `ruff format --check` both clean
- `pre-commit.yaml` drops its matrix and its now-meaningless `max-parallel`
- `lint.yaml` sets up 3.14 instead of 3.12
- README, CONTRIBUTING, the PR template and the bug-report template all said
  3.12; every one of them now says 3.14

Suite re-run on 3.14 locally: 101 passed, 100.00% coverage, all 12 pre-commit
hooks green.

Version stays at 4.1.0: the HTTP API is unchanged, which is what this
project's version has always tracked (3.0.0 was camelCase keys, 4.0.0 was the
KYCAS removal). This changes runtime requirements only, the same class of
change as the httpx2 migration already in this PR.

NOTE: dropping the matrix renames the checks from
"pre-commit-checks (3.12|3.13|3.14)" to "pre-commit-checks", so dev's required
status check contexts need updating again -- 7 contexts down to 5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* ci: run live tests on pull requests behind an approval gate, and scope deploy secrets

Two problems, one root cause. `validate-pr-origin` requires pull requests to
come from a fork, and GitHub withholds secrets from fork pull requests, so
`scripts/run_tests.py` always took its no-secrets branch on a PR. That branch
passed **no `--cov-fail-under` at all**, so every pull request reported green
having enforced nothing about coverage and having skipped all 11 live tests.
The gate and the live tests only ever ran on a push to `dev` -- after merge.

- `scripts/run_tests.py` now applies the coverage gate on both paths. Measured
  before changing it: the no-secrets run is 90 passed / 11 deselected at
  99.16%, so 95% holds with room to spare. It also announces the skip via
  `::warning::` and the step summary, because pre-commit swallows a passing
  hook's output and the fallback was completely invisible.

- `live-tests.yaml` runs the pull request's own code against the real service.
  This needs `pull_request_target`, which is the only way a fork PR can see
  secrets -- and normally a credential-exfiltration vector, since the checked
  out code is untrusted. Two things make it acceptable here:

  1. `environment: live-tests` carries required reviewers, so nothing runs
     until a maintainer approves, and every new push needs approval again.
  2. Only `TEST_*` is injected. A secret reaches the runner solely because
     this file -- which comes from the base branch and cannot be edited by a
     pull request -- puts it there. The worst case of a careless approval is a
     leaked disposable student account, which can be rotated.

  Deliberately not a required status check: it waits on a human, so requiring
  it would block every pull request on reviewer availability.

- Deploy hooks and registry credentials are now scoped to `staging` and
  `production` environments rather than being readable by every workflow in
  the repo. Nothing needs approval there -- staging deploys automatically on a
  dev push -- the environments exist purely to bound the blast radius.

Also declares per-job `permissions` in `deploy-prod.yaml`, which is the
prerequisite for flipping the repo-wide default from write to read.
`sync-dev-to-main` is the one job that genuinely needs `contents: write`, for
its push to main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* ci: print the test run even when the pytest hook passes

The `::warning::` added in eb08fd5 never appeared: pre-commit captures a
hook's output and prints it only on failure, so the annotation was swallowed
by the very mechanism it was meant to work around. Confirmed against the run
on this PR -- zero annotations created.

`verbose: true` on the hook makes pre-commit print it regardless. That also
surfaces the pytest summary line and the coverage total in CI, where both were
previously hidden, so "90 passed, 11 deselected" is now visible rather than
looking identical to a full 101-test run.

Verified both ways: without credentials the annotation, the deselect count and
"Total coverage: 99.16%" all print; with them, 101 passed at 100.00%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* Revert "ci: run live tests on pull requests behind an approval gate"

Drops `live-tests.yaml` and its `live-tests` environment. Running a pull
request's own code with credentials needs `pull_request_target`, and the
approval gate only ever made that a judgement call a maintainer has to get
right on every push -- for a benefit that is narrow, since a broken live
integration already fails the `dev` run and so blocks the staging deploy
before it can reach anything.

Kept, because they are independent of the approval gate and address the
problem that was actually biting:

- the coverage gate on the no-secrets path, which every pull request takes
- `verbose: true`, so a partial run is visible rather than looking identical
  to a full one
- `staging` and `production` environments, and the per-job `permissions` in
  deploy-prod.yaml

CONTRIBUTING now says plainly that the live tests do not run on a pull request
and have to be run locally, rather than describing an approval flow that no
longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* Revert "ci: scope deploy secrets to staging and production environments"

Removes the `environment:` declarations from the deploy jobs and deletes the
`staging` and `production` environments. This was the prerequisite I proposed
for the approval-gated live tests, so it goes with them.

The deploy secrets stay repo-level, which is where they already were -- nothing
regresses relative to `dev`. Note the declarations were never protecting
anything on their own: an environment only scopes a secret once the value is
moved into it and the repo-level copy deleted, and that was never done.

`permissions:` blocks are left in place. They bound each job's GITHUB_TOKEN
rather than its secrets, are unrelated to environments, and are the
prerequisite for flipping the repo-wide default from write to read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

* Revert "ci: declare per-job permissions in the deploy workflows"

Removes the five `permissions:` blocks this PR added -- four in
deploy-prod.yaml and one in deploy-staging.yaml. `push-to-ghcr`'s block is
left alone: it already exists on `dev` and predates this PR.

Both files now match `dev` exactly on permissions, so each job's GITHUB_TOKEN
falls back to the repository default (currently `write`), which is what it did
before. Nothing regresses.

The knock-on: flipping `default_workflow_permissions` to read is off the table
until these declarations are made again, since `sync-dev-to-main` needs
`contents: write` for its push to `main` and would break silently without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VU7YUhP71KSsotWQ1H7CRH

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants