Auto-bump version on staging and prod deploys - #150
aditeyabaral wants to merge 9 commits into
Conversation
- 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
There was a problem hiding this comment.
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
devbefore staging deployment. - Add a production workflow job to bump the minor version on
main(commit/push) after syncingdev → main. - Add a production workflow job to fast-forward
devto the updatedmain, 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.
- 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
|
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 Making it work would mean putting an admin bypass credential (a PAT with Two other defects worth recording, both found while folding this in:
What #156 carries over: the
Thanks — the stale-deploy guard and the concurrency group were both good ideas, and they live on in #156. |
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
… 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>
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:
scripts/bump_version.pythat automatically bumps the patch or minor version inpyproject.tomlbased on the argument provided. This script is now used in both staging and production deployment workflows.Production deployment workflow (
.github/workflows/deploy-prod.yaml):mainbranch after syncing fromdev, and to sync this version bump back todevto keep branches aligned. These jobs ensure version consistency and are required by subsequent deployment steps.contents: writepermissions for jobs that modify repository contents.Staging deployment workflow (
.github/workflows/deploy-staging.yaml):chore: bump, preventing unnecessary redeployments for automated version bumps.devbranch. This ensures each staging deployment corresponds to a unique version.devhas not advanced, improving deployment safety.