diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 9bf7ac0..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" @@ -126,20 +129,30 @@ 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, 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. + # + # `--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 --locked --extra dev - name: Run tests # Integration tests (real Postgres) run in the `integration` job below. @@ -179,13 +192,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/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 . diff --git a/uv.lock b/uv.lock index 8cd7f5c..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" }, - { 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"