Skip to content

ci: run the pipeline on 3.12, fix the gates it was hiding, and cover dev - #47

Merged
Chaddacus merged 6 commits into
devfrom
codex/ci-repair
Sep 1, 2026
Merged

Chaddacus merged 6 commits into
devfrom
codex/ci-repair

Conversation

@Chaddacus

@Chaddacus Chaddacus commented Sep 1, 2026

Copy link
Copy Markdown
Owner

What this fixes

.github/workflows/ci.yml has never been green, and it never ran for PRs into dev.

The root cause is one line: the workflow pinned python-version: '3.9' while the
backend ships on Python 3.12 (backend/Dockerfile). On 3.9,
backend/tests/test_mcp_queries.py fails to import (PEP 604 X | None), so pytest
executed nothing and reported 31% coverage against the --cov-fail-under=60
gate. The typecheck job never got as far as a type error. Moving CI to the real
runtime exposed the checks that were hiding behind that failure, and this PR fixes
each of them in the source. No gate was lowered and nothing was suppressed.

Backend

change why
python-version: '3.9' -> '3.12' in all three backend jobs matches the runtime the code actually targets
mypy.ini python_version = 3.9 -> 3.12 at 3.9, mypy aborts inside site-packages (anyio/_core/_tasks.py uses match) with "errors prevented further checking" and never analyses a single project file. anyio is a transitive dep with no pin, so CI resolves the same version. Raising the target is not a loosening -- it is the only setting under which mypy checks apps/ at all, and it is stricter about the syntax the code really runs.
backend-typecheck gains DEBUG / SECRET_KEY env the mypy_django_plugin imports config.settings, which raises ImproperlyConfigured: You must set a secure SECRET_KEY when DEBUG=False. This is the actual failure in run 28346913057 -- mypy produced no output at all. Same two values backend-test already uses.
FolderSerializer.parent moves into a get_fields() override DRF's own Field.parent occupies that attribute name on the base class, so the declarative parent = UserScopedFolderField(...) reads to mypy as an override of an unrelated attribute ([assignment]). get_fields() is a public DRF extension point and produces the identical field map, so the user-scoped queryset (the IDOR guard) is unchanged.
VoiceNoteListSerializer.folder gets PrimaryKeyRelatedField[Folder] [var-annotated]: a read_only=True related field has no queryset for the stubs to infer the element type from. Quoted so the subscript is never evaluated at runtime.

No # type: ignore was added anywhere.

Frontend

npm run build under CI=true turns eslint warnings into errors. There were 17,
not the 12 that npm run lint reports -- the lint script is
eslint src/**/*.{ts,tsx} run under sh, which has no globstar, so **
collapses to one directory level and files like
src/components/AudioRecorder/*.tsx are never linted. The build output is the
authoritative list. (The glob itself is left alone; it is out of scope for this PR
but worth a follow-up.)

Every warning is fixed in the code. No eslint-disable was added.

  • Dead imports and bindings removed: DocumentTextIcon (Layout),
    MicrophoneIcon (LoginPage, RegisterPage), ExclamationTriangleIcon
    (MicrophonePermission), PerformanceIndicator plus its
    showPerformanceIndicator prop (RecorderControls -- the component was never
    rendered and no caller passes the prop), getPerformanceStatusColor
    (PerformanceIndicator), and four leftovers from the disabled performance manager
    in useAudioRecorder (enablePerformanceManagement binding,
    lastAudioLevelUpdate, getAudioAnalysisInterval, mediaRecorderOptions and
    the loop that filled it -- the MediaRecorder creation loop below already walks
    the same config list directly).

  • jsx-a11y/anchor-is-valid x2 (RegisterPage): both anchors were href="#".
    The app has no /terms or /privacy route, so a Link would point nowhere and
    a <button> would have nothing to do. LoginPage already renders the same
    notice as plain text; RegisterPage now matches. Visible wording is unchanged.

  • no-redeclare (speech.d.ts): SpeechRecognition was both an interface and
    a declare var. The constructor now has its own named type,
    SpeechRecognitionConstructor, used by the Window interface. Nothing referenced
    the bare global as a value -- AudioDebugPanel goes through
    window.SpeechRecognition -- so the type surface is preserved.

  • PerformanceManager: the CPU-benchmark accumulator was written and never read,
    which is exactly what lets an engine delete the loop the benchmark is timing. It
    is now read after the loop. The unused getCurrentTier() call in
    downgradeQuality() is a pure classifier and was dropped.

  • react-hooks/exhaustive-deps in useAudioRecorder (the interesting one):
    startRecording was missing state.recordingTime and updateAudioLevel.
    Neither could simply be added:

    • state.recordingTime increments every second while recording, so listing it
      would rebuild startRecording on every tick. It was only read for a
      console.error diagnostic, and the closure meant that diagnostic always logged
      0. It now reads recordingTimeRef.current, alongside the isRecordingRef /
      isPausedRef pattern the hook already uses. This fixes the diagnostic as well.
    • updateAudioLevel is declared below startRecording, so naming it in the
      dependency array would be a TDZ ReferenceError at render time. It is invoked
      through updateAudioLevelRef.current().

    With those two resolved the rule then reported mimeType, sampleRate and
    shouldEnableDebugLogging as unnecessary -- the first two reach the callback
    only via the memoised getEffective* getters, which are already dependencies,
    and the third is not read at all. Removed.

  • react-hooks/exhaustive-deps in MicrophonePermission and
    RecorderControls:
    the mount-time permission probe is now a useCallback that
    the effect depends on. RecordPage.handlePermissionChange is not memoised, so
    depending on the prop directly would re-run the probe -> setState -> re-render
    -> new prop identity -> loop. The prop is read through a ref instead, which also
    means permissionStatus.onchange reports to the current handler rather than the
    one captured at mount. RecorderControls destructures the hook's stable
    checkMicrophonePermission rather than depending on a property access the rule
    cannot verify.

Triggers

on.push.branches and on.pull_request.branches were [main] only, so nothing on
the dev line was ever checked. Both are now [main, dev].

e2e

frontend/e2e/tests/production-sweep.spec.js drives https://clio.chadacus.dev and
registers real accounts on it. The chromium project declares no testMatch, so it
matched everything under testDir -- the e2e-test job would have run a
registration sweep against production as soon as the earlier jobs went green.
playwright.config.js now sets a top-level testIgnore for that spec, with a
comment. It is unconditional rather than gated on CI, so no environment variable
can turn it back on. The spec is not deleted. npx playwright test --list --project=chromium drops from 11 files to 10 (50 tests); the spec was never
executed locally.

Verification

Run locally against the exact CI commands, on a Python 3.12 venv
(uv pip install -r requirements.txt, plus ruff==0.1.9 as CI pins) and a
throwaway postgres:15 container.

$ ruff check .
ruff-exit=0

$ DEBUG=True SECRET_KEY=ci-test-secret-key mypy apps/ --ignore-missing-imports
Success: no issues found in 47 source files
mypy-exit=0

$ DEBUG=True SECRET_KEY=... DB_HOST=127.0.0.1 DB_PORT=5437 \
    pytest --cov=apps --cov-report=term-missing --cov-fail-under=60 -q
TOTAL                                            1400    227    84%
Required test coverage of 60% reached. Total coverage: 83.79%
pytest-exit=0

(pytest.ini already sets -q, so the CI invocation is effectively -qq and
suppresses the count line. The same suite run as
pytest -o addopts= -m "not live" --tb=short -q reports
139 passed, 6 deselected, 204 warnings in 6.80s.)

$ npm ci && npm run lint
lint-exit=0    (no output -- 0 problems; was "12 problems (0 errors, 12 warnings)")

$ npx eslint 'src/**/*.{ts,tsx}'      # recursive, the full set
exit=0         (0 problems)

$ CI=true npm run build
Compiled successfully.

File sizes after gzip:
  116.42 kB (-12 B)  build/static/js/main.d24b0881.js
  9.86 kB            build/static/css/main.3a1ac151.css
  1.78 kB            build/static/js/453.e78c7972.chunk.js
build-exit=0   (was "Failed to compile." with 17 eslint warnings-as-errors)

Verification on this PR

CI runs on this PR: pull_request resolves the workflow from the merge ref, so the trigger change applies to the PR itself. The checks on the PR head are the evidence for every job, including e2e-test.

Follow-ups landed after review: the health probe is exempt from the anonymous throttle (a liveness probe must never 429), THROTTLE_ANON_RATE is plumbed through docker-compose.yml and raised for the CI e2e job only, and the celery service waits for the backend so both containers do not race to create the shared media volume path.

The CI workflow pinned Python 3.9 while the backend ships on 3.12
(backend/Dockerfile). Test collection died on PEP 604 syntax, so pytest ran
nothing and reported 31% coverage against a 60% floor; mypy never reached
apps/ at all. Every backend job now runs 3.12, the runtime the code targets.

Backend:
- mypy.ini python_version 3.9 -> 3.12. At 3.9 mypy aborts inside site-packages
  (anyio uses match statements) before checking a single project file.
- backend-typecheck gains DEBUG/SECRET_KEY. The django-stubs plugin imports
  config.settings, which refuses to load without them; the job failed with
  ImproperlyConfigured before mypy produced any output.
- FolderSerializer installs its user-scoped `parent` field through get_fields()
  instead of a class attribute. DRF's own Field.parent owns that name on the
  base class, so the declarative form reads as an override of an unrelated
  attribute. Same field map, same IDOR scoping.
- VoiceNoteListSerializer.folder gets the element-type annotation
  PrimaryKeyRelatedField needs when it is read-only.

Frontend: `npm run build` under CI=true treats eslint warnings as errors, and
there were 17 of them (npm run lint saw only 12 -- its glob is not recursive).
All fixed in the source, none suppressed:
- Dropped imports and bindings nothing read: DocumentTextIcon, MicrophoneIcon
  (x2), ExclamationTriangleIcon, PerformanceIndicator and its never-rendered
  showPerformanceIndicator prop, getPerformanceStatusColor, and four leftovers
  from the disabled performance manager in useAudioRecorder.
- RegisterPage's two href="#" anchors became plain text. The app has no /terms
  or /privacy route, and LoginPage already states the same notice as text.
- speech.d.ts declares SpeechRecognitionConstructor instead of redeclaring the
  SpeechRecognition name as both interface and var.
- PerformanceManager reads its CPU-benchmark accumulator so the timed loop
  cannot be optimised away, and drops an unused getCurrentTier() call.
- startRecording's dependency array is now accurate: state.recordingTime and
  updateAudioLevel move behind refs. Adding recordingTime directly would rebuild
  the callback every second, and updateAudioLevel is declared below it. The ref
  also fixes the diagnostic that always logged a duration of 0.
- MicrophonePermission's mount probe declares its dependency; the callback stays
  stable via a ref because RecordPage passes a fresh handler identity each
  render, which would otherwise loop.

Triggers now include dev on push and pull_request, so PRs into dev get checks.

playwright.config.js ignores production-sweep.spec.js everywhere. It drives
https://clio.chadacus.dev and registers real accounts there; the e2e job would
have run it against production.
comprehensive-harness.spec.js expected an h1 of "Record Voice Note". No such
string exists in frontend/src -- RecordPage.tsx renders "New Voice Note"
(line 168) -- so the assertion failed against a correct page. The two sibling
assertions in the same block ("Ready to record" from RecorderControls,
"Recording Tips" from RecordPage) do match the source and are unchanged.

production-sweep.spec.js carries the same stale assertion but is excluded from
automated runs, so it is left alone.
@Chaddacus

Copy link
Copy Markdown
Owner Author

Additional fix pushed as 11e33b8.

frontend/e2e/tests/comprehensive-harness.spec.js:103 asserted an h1 of
"Record Voice Note". That string does not exist anywhere in frontend/src --
src/pages/RecordPage.tsx:168 renders <h1 ...>New Voice Note</h1> -- so the
assertion failed against a correct page and would have kept the e2e-test job
red once it became reachable. The assertion now matches the real heading; the
test is not deleted or skipped.

The two sibling assertions in the same block were checked against the source and
are correct as written: "Ready to record"
(src/components/AudioRecorder/RecorderControls.tsx:117) and the
"Recording Tips" h3 (src/pages/RecordPage.tsx:321). Both left unchanged.

frontend/e2e/tests/production-sweep.spec.js:49 carries the same stale
assertion. It is excluded from every automated run by the testIgnore added in
this PR, so it is left as is.

Verification: npx playwright test --list --project=chromium still resolves
Total: 50 tests in 10 files (the specs parse, and production-sweep stays
excluded). The e2e job itself still cannot be executed locally, so this removes a
known failure rather than proving the job green.

The e2e suite's registration and login tests share the anon bucket (60/minute)
with the health check, so the health spec failed with 429 once the whole suite
ran in one worker. A liveness probe must never be throttled; uptime checks hit
it too. Adds a test that pins the exemption.
conftest disables throttling for the suite, so the first version passed with or
without the exemption. Restore the production throttle on the base view for
this test, tighten the anon rate, and prove with a control request that the
throttle bites before asserting the probe stays 200.
… volume mount

- Plumb THROTTLE_ANON_RATE through docker-compose.yml and raise it for the CI
  e2e job only; one runner address sends the suite's anonymous traffic inside
  the production 60/minute window. Production default unchanged.
- celery now waits for the backend to be healthy: both images carry /app/media
  and both mounted the empty media_files volume at once, which failed with
  'mkdir ...: file exists' on the second CI run.
- playwright.config.js comment now says what testIgnore really does.
- Folder update path gets the IDOR regression test review asked for.
The response interceptor retried every 401 through /auth/refresh/, including
the refresh call's own 401, so a visitor with no session looped profile ->
refresh -> refresh ... and never left the loading state. Locally the anon
throttle (60/minute) turned the loop into a 429 within a second, which is why
the redirect-to-login tests passed; the raised CI throttle exposed it. The
refresh request is now excluded from the retry path, so its 401 rejects and the
existing redirect runs.
@Chaddacus
Chaddacus merged commit b7f2663 into dev Sep 1, 2026
6 checks passed
@Chaddacus
Chaddacus deleted the codex/ci-repair branch September 1, 2026 20:52
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.

1 participant