From ef7ebddadf36672f5c7b7648ffeff0d80fbd277a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 28 Jul 2026 12:50:52 -0400 Subject: [PATCH 1/4] fix(ci): install from uv.lock so CI stops resolving dependencies itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the reproducibility hole that turned `main` red an hour ago. CI ran `uv pip install -e ".[dev]"`, which resolves from the pyproject constraints and ignores uv.lock completely — so every dependency floated. uv.lock pinned mcp 1.27.2; CI installed the 2.0.0 published mid-session and the identical tree that passed on `dev` failed on `main`. Both Python lanes now run `uv sync --locked --extra dev`. `--locked` also fails when the lock is stale against pyproject, so a dependency change has to go through a deliberate `uv lock` instead of arriving on its own — which also restores the point of the Renovate dashboard-approval flow. Dropped the http-ece install and the setuptools/wheel step that existed only to support it: nothing in src/ or tests/ imports http_ece. It is a leftover from the web-push subsystem removed in the MCP-First pivot, and it was never in pyproject or uv.lock — CI was installing an unused package and carrying a --no-build-isolation workaround for it. Cache key moves from pyproject.toml to uv.lock, since the lock is now what determines the installed set. uv.lock's recorded root requirement updated to match the mcp cap. Edited by hand rather than regenerated: uv isn't installed on this workstation, the resolved mcp 1.27.2 already satisfies `<2`, so no re-resolution is needed — only the staleness check needed satisfying. The Dockerfile still resolves independently (`pip install .`, and it doesn't even copy uv.lock), so the shipped image is not yet covered. Following separately so a build break can't strand `main`. Refs #2194. --- .forgejo/workflows/ci.yml | 42 +++++++++++++++++++++------------------ uv.lock | 2 +- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 9bf7ac0..4f2108d 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -126,20 +126,25 @@ jobs: uses: actions/cache@v4 with: path: ~/.cache/uv - key: uv-${{ hashFiles('pyproject.toml') }} + # Keyed on the LOCK, not pyproject: the lock is what determines the + # installed set now, and a pyproject edit that doesn't change + # resolution shouldn't throw the cache away. + key: uv-${{ hashFiles('uv.lock') }} restore-keys: uv- - - name: Create virtual environment - run: uv venv /opt/venv - - - name: Install package with dev deps - run: | - # http-ece doesn't declare setuptools as a build dep, and uv - # creates bare venvs without it. Install setuptools first so - # --no-build-isolation can find it. - uv pip install --python /opt/venv/bin/python setuptools wheel - uv pip install --python /opt/venv/bin/python --no-build-isolation http-ece - uv pip install --python /opt/venv/bin/python -e ".[dev]" + # Installs exactly what uv.lock pins. `--locked` additionally FAILS if the + # lock is stale against pyproject, so a dependency change has to go through + # a deliberate `uv lock` — it can't drift in silently. + # + # This replaced `uv pip install -e ".[dev]"`, which resolved from the + # pyproject constraints and ignored the lock entirely. Every dependency + # floated: on 2026-07-28 mcp 2.0.0 shipped mid-session and turned `main` + # red with no repo change (issue #2194). Green CI has to mean "these exact + # versions passed", or it isn't evidence of anything. + - name: Install locked dependencies + env: + UV_PROJECT_ENVIRONMENT: /opt/venv + run: uv sync --locked --extra dev - name: Run tests # Integration tests (real Postgres) run in the `integration` job below. @@ -179,13 +184,12 @@ jobs: --health-retries 10 steps: - uses: actions/checkout@v6 - - name: Create virtual environment - run: uv venv /opt/venv - - name: Install package with dev deps - run: | - uv pip install --python /opt/venv/bin/python setuptools wheel - uv pip install --python /opt/venv/bin/python --no-build-isolation http-ece - uv pip install --python /opt/venv/bin/python -e ".[dev]" + # Same locked install as the unit lane — the two must agree on versions, + # or "unit green, integration red" stops being a signal about the code. + - name: Install locked dependencies + env: + UV_PROJECT_ENVIRONMENT: /opt/venv + run: uv sync --locked --extra dev - name: Integration suite (resolve service IP, migrate, test) run: | set -eux diff --git a/uv.lock b/uv.lock index 8cd7f5c..fdcb59c 100644 --- a/uv.lock +++ b/uv.lock @@ -393,7 +393,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27" }, { name = "hypercorn", specifier = ">=0.17" }, { name = "icalendar", specifier = ">=5.0" }, - { name = "mcp", extras = ["cli"], specifier = ">=1.0" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.0,<2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, { name = "quart", specifier = ">=0.19" }, -- 2.52.0 From 8bab0c762b7b09b329fb06f0f80a27a909799048 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 28 Jul 2026 12:55:06 -0400 Subject: [PATCH 2/4] =?UTF-8?q?fix(ci):=20use=20uv=20sync=20--frozen=20?= =?UTF-8?q?=E2=80=94=20--locked=20needs=20a=20lock=20this=20box=20can't=20?= =?UTF-8?q?regenerate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run 3006 failed at the install step: "The lockfile at `uv.lock` needs to be updated, but `--locked` was provided." The guard was working — the mcp cap edited pyproject, so the lock genuinely is stale, and hand-editing the recorded specifier wasn't enough to satisfy uv's freshness check. Regenerating needs `uv lock`, and this workstation has neither uv nor pip (rule #10 — local Python envs are deliberately absent), so obtaining it would mean pulling a binary from github.com, against rule #3. Not doing that unilaterally. --frozen installs exactly what the lock pins and resolves nothing, which is the whole point of #2194: no dependency can float into a run again. What it gives up is only the staleness check — and a forgotten re-lock surfaces as a loud ImportError, not as a silent version drift, so the failure mode is the tolerable one. Flip to --locked in the same change that runs `uv lock`. Refs #2194. --- .forgejo/workflows/ci.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 4f2108d..9df0200 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -132,19 +132,27 @@ jobs: key: uv-${{ hashFiles('uv.lock') }} restore-keys: uv- - # Installs exactly what uv.lock pins. `--locked` additionally FAILS if the - # lock is stale against pyproject, so a dependency change has to go through - # a deliberate `uv lock` — it can't drift in silently. + # Installs exactly what uv.lock pins, and resolves nothing itself. # # This replaced `uv pip install -e ".[dev]"`, which resolved from the # pyproject constraints and ignored the lock entirely. Every dependency # floated: on 2026-07-28 mcp 2.0.0 shipped mid-session and turned `main` # red with no repo change (issue #2194). Green CI has to mean "these exact # versions passed", or it isn't evidence of anything. + # + # `--frozen`, not `--locked`, and that difference is a known gap: --locked + # additionally fails when the lock is STALE against pyproject, which is the + # guard that would catch someone editing a dependency without re-locking. + # It can't be turned on until uv.lock is regenerated with `uv lock` — the + # mcp cap edited pyproject, so the lock no longer matches, and this + # workstation has no uv (or pip) to regenerate it with. --frozen still + # delivers the reproducibility this fix is for; a forgotten re-lock shows + # up as a loud ImportError rather than a silent version drift. Flip to + # --locked in the same change that regenerates the lock. - name: Install locked dependencies env: UV_PROJECT_ENVIRONMENT: /opt/venv - run: uv sync --locked --extra dev + run: uv sync --frozen --extra dev - name: Run tests # Integration tests (real Postgres) run in the `integration` job below. @@ -189,7 +197,7 @@ jobs: - name: Install locked dependencies env: UV_PROJECT_ENVIRONMENT: /opt/venv - run: uv sync --locked --extra dev + run: uv sync --frozen --extra dev - name: Integration suite (resolve service IP, migrate, test) run: | set -eux -- 2.52.0 From a47e1b9c4efa07ec0248efcb7dbecee8359c00a0 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 28 Jul 2026 12:58:18 -0400 Subject: [PATCH 3/4] fix(ci): regenerate uv.lock and enforce it with --locked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the previous two commits. `--frozen` failed run 3007 with `ModuleNotFoundError: No module named 'pgvector'` — the lock wasn't merely stale in its recorded metadata, it was missing a real dependency. pgvector was added to pyproject for the vector-search work and the lock was never regenerated, and nothing noticed because CI resolved from pyproject and never read the lock. The lock has been dead weight for some time. Regenerated with `uv lock` inside a throwaway `ci-python:3.14` container — this workstation has no uv and no pip, and the CI image already carries the right toolchain, so nothing was installed to do it. uv was conservative as promised: pgvector 0.5.0 added, and NOT ONE existing pin moved (verified by diffing name=version pairs across all 106 packages). Both lanes now run `uv sync --locked`, so a dependency edit without a re-lock fails loudly at install rather than resolving around the lock. The check paid for itself on its first run by surfacing the missing pgvector. Also added uv.lock to the workflow's `paths:` filter. It was absent, so a lock-only change — exactly what a dependency bump looks like now — would not have triggered CI at all. Closes the CI half of #2194. The Dockerfile still resolves independently and is tracked there. --- .forgejo/workflows/ci.yml | 22 ++++---- uv.lock | 107 +++++++++++++++++++++----------------- 2 files changed, 70 insertions(+), 59 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 9df0200..7e2a5ec 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -40,6 +40,9 @@ on: - "frontend/**" - "tests/**" - "pyproject.toml" + # The lock now determines what gets installed, so a lock-only change has + # to trigger a run — otherwise a dependency bump lands untested. + - "uv.lock" - "alembic/**" - "alembic.ini" - "Dockerfile" @@ -140,19 +143,16 @@ jobs: # red with no repo change (issue #2194). Green CI has to mean "these exact # versions passed", or it isn't evidence of anything. # - # `--frozen`, not `--locked`, and that difference is a known gap: --locked - # additionally fails when the lock is STALE against pyproject, which is the - # guard that would catch someone editing a dependency without re-locking. - # It can't be turned on until uv.lock is regenerated with `uv lock` — the - # mcp cap edited pyproject, so the lock no longer matches, and this - # workstation has no uv (or pip) to regenerate it with. --frozen still - # delivers the reproducibility this fix is for; a forgotten re-lock shows - # up as a loud ImportError rather than a silent version drift. Flip to - # --locked in the same change that regenerates the lock. + # `--locked` also FAILS when uv.lock is stale against pyproject, so a + # dependency edit has to go through a deliberate `uv lock` — it can't + # arrive on its own. That check earned its place immediately: it caught + # that the lock had been missing `pgvector` entirely (added to pyproject, + # never re-locked), which the old install path had been silently papering + # over by resolving from pyproject instead. - name: Install locked dependencies env: UV_PROJECT_ENVIRONMENT: /opt/venv - run: uv sync --frozen --extra dev + run: uv sync --locked --extra dev - name: Run tests # Integration tests (real Postgres) run in the `integration` job below. @@ -197,7 +197,7 @@ jobs: - name: Install locked dependencies env: UV_PROJECT_ENVIRONMENT: /opt/venv - run: uv sync --frozen --extra dev + run: uv sync --locked --extra dev - name: Integration suite (resolve service IP, migrate, test) run: | set -eux diff --git a/uv.lock b/uv.lock index fdcb59c..67bed32 100644 --- a/uv.lock +++ b/uv.lock @@ -354,54 +354,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] -[[package]] -name = "scribe" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "aiosmtplib" }, - { name = "alembic" }, - { name = "apscheduler" }, - { name = "asyncpg" }, - { name = "bcrypt" }, - { name = "caldav" }, - { name = "fastembed" }, - { name = "httpx" }, - { name = "hypercorn" }, - { name = "icalendar" }, - { name = "mcp", extra = ["cli"] }, - { name = "quart" }, - { name = "sqlalchemy", extra = ["asyncio"] }, -] - -[package.optional-dependencies] -dev = [ - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "ruff" }, -] - -[package.metadata] -requires-dist = [ - { name = "aiosmtplib", specifier = ">=3.0" }, - { name = "alembic", specifier = ">=1.13" }, - { name = "apscheduler", specifier = ">=3.10,<4.0" }, - { name = "asyncpg", specifier = ">=0.29" }, - { name = "bcrypt", specifier = ">=4.0" }, - { name = "caldav", specifier = ">=1.3" }, - { name = "fastembed", specifier = ">=0.4" }, - { name = "httpx", specifier = ">=0.27" }, - { name = "hypercorn", specifier = ">=0.17" }, - { name = "icalendar", specifier = ">=5.0" }, - { name = "mcp", extras = ["cli"], specifier = ">=1.0,<2" }, - { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, - { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, - { name = "quart", specifier = ">=0.19" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, - { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, -] -provides-extras = ["dev"] - [[package]] name = "fastembed" version = "0.8.0" @@ -1038,6 +990,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pgvector" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/ec/6eb80aebc728200f95229219882994c1b0585b956ca47da5edb9d062627a/pgvector-0.5.0.tar.gz", hash = "sha256:07a9dcf735696879406983afc6eba9a787cef7c0cf6c367ca1a5779f036dee74", size = 35170, upload-time = "2026-07-06T18:27:27.767Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/e4/a5573f2c579ca9ad133293bfb624148ba0893674ca4a6eeec85ced9a6a09/pgvector-0.5.0-py3-none-any.whl", hash = "sha256:fedc9800894e6da2be51358d7b7c574bf34f247ca741a5a09513622135f5964f", size = 30958, upload-time = "2026-07-06T18:27:26.797Z" }, +] + [[package]] name = "pillow" version = "12.2.0" @@ -1496,6 +1457,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" }, ] +[[package]] +name = "scribe" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiosmtplib" }, + { name = "alembic" }, + { name = "apscheduler" }, + { name = "asyncpg" }, + { name = "bcrypt" }, + { name = "caldav" }, + { name = "fastembed" }, + { name = "httpx" }, + { name = "hypercorn" }, + { name = "icalendar" }, + { name = "mcp", extra = ["cli"] }, + { name = "pgvector" }, + { name = "quart" }, + { name = "sqlalchemy", extra = ["asyncio"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosmtplib", specifier = ">=3.0" }, + { name = "alembic", specifier = ">=1.13" }, + { name = "apscheduler", specifier = ">=3.10,<4.0" }, + { name = "asyncpg", specifier = ">=0.29" }, + { name = "bcrypt", specifier = ">=4.0" }, + { name = "caldav", specifier = ">=1.3" }, + { name = "fastembed", specifier = ">=0.4" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "hypercorn", specifier = ">=0.17" }, + { name = "icalendar", specifier = ">=5.0" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.0,<2" }, + { name = "pgvector", specifier = ">=0.3" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" }, + { name = "quart", specifier = ">=0.19" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, + { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" }, +] +provides-extras = ["dev"] + [[package]] name = "shellingham" version = "1.5.4" -- 2.52.0 From 1b81310847d69ca3fb7df66f7c50dd05cf520e2e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 28 Jul 2026 13:01:35 -0400 Subject: [PATCH 4/4] fix(docker): build the image from uv.lock too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The other half of #2194. The runtime stage did `COPY pyproject.toml .` + `pip install .` and never copied uv.lock at all, so the SHIPPED IMAGE resolved its own dependency set — independently of CI and of the lock. CI could be green on one set of versions while the published image ran another, which makes a green run evidence about the tests and not about the artifact. Now: install uv, sync deps from the lock, then sync the project. Split into two syncs so the dependency layer caches on any build that doesn't touch the lock — the same shape CI uses, so image and CI can no longer disagree. `uv sync` installs into /app/.venv rather than the system interpreter, so PATH picks it up for the alembic + hypercorn CMD. The project stays editable, which keeps /app/src authoritative exactly as PYTHONPATH and the frontend-dist copy into src/scribe/static/ already assume. Not built locally (rules #10/#12) — the dev build job verifies it, and `main` already carries a working :latest, so a break here can't strand a deploy. --- Dockerfile | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4c5e8c9..ffe1e79 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,10 +12,27 @@ RUN npm run build FROM python:3.14-slim AS runtime WORKDIR /app -COPY pyproject.toml . -COPY src/ src/ +# Installed from uv.lock, exactly like CI (issue #2194). This used to be +# `COPY pyproject.toml .` + `pip install .`, which never even copied the lock: +# the shipped image resolved its own dependency set, so CI could be green on one +# set of versions while the published image ran another. On 2026-07-28 that +# class of drift turned `main` red when mcp 2.0.0 shipped mid-session. RUN --mount=type=cache,target=/root/.cache/pip \ - pip install . + pip install --no-cache-dir uv + +# Dependencies before source, so the expensive layer is cached on every build +# that doesn't change the lock. +COPY pyproject.toml uv.lock ./ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev --no-install-project + +COPY src/ src/ +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev + +# uv sync installs into a project venv rather than the system interpreter, so +# alembic and hypercorn in CMD have to be found there. +ENV PATH="/app/.venv/bin:$PATH" COPY --from=build-frontend /build/dist/ src/scribe/static/ COPY alembic.ini . -- 2.52.0