From c4910d82efd8486690fdfced91531ff949bd23ce Mon Sep 17 00:00:00 2001 From: Holden Date: Wed, 17 Jun 2026 17:16:04 +0000 Subject: [PATCH 01/11] fix: treat Frigate 500 'Could not process' as permanent rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frigate returns HTTP 500 with 'Could not process' when its face detector cannot find or embed a face in the uploaded crop — this will never succeed on retry. Previously these were silently logged at DEBUG and retried on every future run. - Surface 500 error details inline (same display path as 400) - Mark 500 + 'could not process' as a permanent rejection so the asset is skipped on future runs instead of retried indefinitely --- winnow/executor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/winnow/executor.py b/winnow/executor.py index b5a3822..1aa9877 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -566,13 +566,14 @@ def upload_to_frigate(jobs: list[dict]) -> None: error_detail = resp.json().get("message", full_body[:100]) except Exception: error_detail = full_body[:100] - if resp.status_code == 400: + if resp.status_code in (400, 500): progress.console.print(f" [dim]{error_detail}[/dim]") else: logger.debug("%s HTTP %s: %s", fname, resp.status_code, error_detail) _is_permanent = ( (resp.status_code == 400 and "face" in full_body.lower()) or resp.status_code == 422 + or (resp.status_code == 500 and "could not process" in full_body.lower()) ) if _is_permanent: asset_id = asset_map.get(fname) From b69f77637856f4b98017c5418c1a4cba2104ceb5 Mon Sep 17 00:00:00 2001 From: Holden Date: Wed, 17 Jun 2026 17:18:43 +0000 Subject: [PATCH 02/11] fix: surface skip reasons and suppress norm_crop FutureWarning process_face_mode now returns a descriptive string instead of None for filtered-out faces ("face too small 45x38px, min 90px", "no face metadata"), so the executor can print a useful reason rather than the generic "no usable face data". Also suppresses the InsightFace norm_crop FutureWarning about deprecated estimate usage, which was noisy at INFO level on every aligned crop. --- winnow/executor.py | 3 ++- winnow/image_processing.py | 13 ++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/winnow/executor.py b/winnow/executor.py index 1aa9877..6eb0a9c 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -202,8 +202,9 @@ def execute_jobs(jobs: list[dict]) -> None: count += 1 else: + reason = saved if isinstance(saved, str) else "no usable face data" progress.console.print( - f"[yellow]Skipped {asset['id']} (no usable face data)[/yellow]" + f"[yellow]Skipped {asset['id']} ({reason})[/yellow]" ) except Exception as e: logger.error("Failed to process asset %s: %s", asset.get("id", ""), e) diff --git a/winnow/image_processing.py b/winnow/image_processing.py index 23c3f0e..c9c9b85 100644 --- a/winnow/image_processing.py +++ b/winnow/image_processing.py @@ -48,7 +48,9 @@ def align_face(img: Image.Image, landmarks: list[list[float]] | np.ndarray) -> I if lm.shape != (5, 2): logger.debug("Invalid landmark shape: %s, expected (5, 2)", lm.shape) return None - aligned = norm_crop(img_np, lm) + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message=".*estimate.*is deprecated", category=FutureWarning) + aligned = norm_crop(img_np, lm) return Image.fromarray(aligned) except ImportError: logger.debug("InsightFace not available for face alignment") @@ -66,10 +68,11 @@ def process_face_mode( count: int, min_width: int | None = None, insightface_app=None, -) -> tuple[int, int] | None: +) -> tuple[int, int] | str | None: """Crop face based on Immich metadata and save to output directory. - Returns (width, height) of the saved crop, or None if no crop was saved. + Returns (width, height) of the saved crop, a skip-reason string if the + face was filtered out, or None if no crop was saved for other reasons. When insightface_app is provided and ENABLE_FACE_ALIGNMENT is True, re-detects the face in the Immich bbox region using InsightFace to get precise landmarks for a proper 112x112 aligned crop. Falls back to @@ -89,7 +92,7 @@ def process_face_mode( if not face_info: logger.debug("No face info for %s in asset %s", person.get("name"), asset.get("id")) - return None + return "no face metadata" img_w, img_h = img.size meta_w = face_info.get("imageWidth") or 0 @@ -108,7 +111,7 @@ def process_face_mode( face_w, face_h = x2 - x1, y2 - y1 if face_w < min_width or face_h < min_width: logger.debug("Face too small (%.1fx%.1f)", face_w, face_h) - return None + return f"face too small ({face_w:.0f}x{face_h:.0f}px, min {min_width}px)" # Re-detect face with InsightFace for landmark-based alignment. # Immich's /api/faces endpoint does not include landmarks, so the From f6e494071e1a9ee71f69ea449f0f487d5e26c19e Mon Sep 17 00:00:00 2001 From: Holden Date: Wed, 17 Jun 2026 17:19:09 +0000 Subject: [PATCH 03/11] fix: lower MAX_AUTO_IMAGES default from 20 to 5 Smaller default cap is more conservative for new installs and better reflects the minimum viable training set for Frigate face recognition. Users who need more can set MAX_AUTO_IMAGES explicitly. --- README.md | 2 +- compose.yml | 2 +- winnow/config.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f4c0968..197cb09 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ In scheduled mode the process (and loaded models) stays resident between runs. T | Variable | Default | Description | | :--- | :--- | :--- | -| `MAX_AUTO_IMAGES` | `20` | Maximum training images per person in Frigate | +| `MAX_AUTO_IMAGES` | `5` | Maximum training images per person in Frigate | | `QUALITY_REPLACEMENT` | `true` | When at cap, swap a weaker tracked image for a better candidate. With Frigate scoring active, targets the most redundant image (highest pre-upload recognize score); otherwise uses blur score. Never touches manually added Frigate files. Set `false` to skip people at cap | #### Advanced Tuning *(calibrated — do not adjust)* diff --git a/compose.yml b/compose.yml index d1ce394..c1ce25e 100644 --- a/compose.yml +++ b/compose.yml @@ -31,7 +31,7 @@ services: # - USE_FULL_RESOLUTION=true # Use full-res images vs thumbnails (default: true) # - MIN_CONFIDENCE=0.7 # Minimum face detection confidence (default: 0.7) # - BLUR_THRESHOLD=100.0 # Laplacian blur threshold; lower = accept more blur (default: 100.0) - # - MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 20) + # - MAX_AUTO_IMAGES=80 # Hard cap on auto-diversity selection (default: 5) # ── Caching & Models ────────────────────────────────────────────────── # - FORCE_CPU=true # Disable GPU, fall back to CPU diff --git a/winnow/config.py b/winnow/config.py index 2a33680..48a62c5 100644 --- a/winnow/config.py +++ b/winnow/config.py @@ -132,7 +132,7 @@ def _load(self) -> None: self.MERGE_DUPLICATE_PEOPLE = _getenv_bool("MERGE_DUPLICATE_PEOPLE", False) self.BLUR_THRESHOLD = _getenv_float("BLUR_THRESHOLD", 120.0) self.MIN_CONFIDENCE = _getenv_float("MIN_CONFIDENCE", 0.7) - self.MAX_AUTO_IMAGES = _getenv_int("MAX_AUTO_IMAGES", 20) + self.MAX_AUTO_IMAGES = _getenv_int("MAX_AUTO_IMAGES", 5) self.QUALITY_REPLACEMENT = _getenv_bool("QUALITY_REPLACEMENT", True) self.FRIGATE_SCORE_CEILING = _getenv_optional_float("FRIGATE_SCORE_CEILING") self.ENABLE_FRIGATE_SCORES = _getenv_bool("ENABLE_FRIGATE_SCORES", True) From 86caffc8d720939e72481f213aff94b6909d3fe7 Mon Sep 17 00:00:00 2001 From: Holden Date: Wed, 17 Jun 2026 17:31:20 +0000 Subject: [PATCH 04/11] =?UTF-8?q?fix:=203=20audit=20findings=20=E2=80=94?= =?UTF-8?q?=20truthy=20skip-reason=20bug,=20500=20match=20consistency,=20C?= =?UTF-8?q?HANGELOG=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. if saved: → if isinstance(saved, tuple): so string skip-reasons from process_face_mode no longer register as successes and create phantom asset_map entries with no JPEG on disk. Dead reason/fallback code in the else branch now correctly handles str and None returns. 2. "could not process" permanent-rejection check now uses error_detail (json message field, falling back to body[:100]) instead of full_body, keeping the match consistent with what is displayed to the user. 3. CHANGELOG [Unreleased] breaking-change note for MAX_AUTO_IMAGES 20→5 so upgrading users know to set the env var if they want the old cap. --- CHANGELOG.md | 4 ++++ winnow/executor.py | 7 +++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6966e91..92ff442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **`MAX_AUTO_IMAGES` default lowered from 20 to 5** — existing users who have not set this variable and already have more than 5 winnow-managed images in Frigate will find themselves at cap on the next run. With `QUALITY_REPLACEMENT=true` (the default), winnow will attempt to swap weaker images rather than uploading new ones. Set `MAX_AUTO_IMAGES=20` to restore the previous behaviour. + ## [0.6.5] - 2026-06-17 ### Added diff --git a/winnow/executor.py b/winnow/executor.py index 6eb0a9c..5b0aeb6 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -186,12 +186,11 @@ def execute_jobs(jobs: list[dict]) -> None: saved = process_face_mode( img, asset, person, person_dir, count, insightface_app=insightface_app ) - if saved: + if isinstance(saved, tuple): filename = f"{count}.jpg" asset_map[filename] = asset["id"] score_map[filename] = asset.get("quality_score") - if isinstance(saved, tuple): - dims_map[filename] = saved + dims_map[filename] = saved # Time-spread path: compute blur score from the downloaded # image. Capped at 1440px via blur_score_from_image() so the # scale matches the preview thumbnails the embedding path uses @@ -574,7 +573,7 @@ def upload_to_frigate(jobs: list[dict]) -> None: _is_permanent = ( (resp.status_code == 400 and "face" in full_body.lower()) or resp.status_code == 422 - or (resp.status_code == 500 and "could not process" in full_body.lower()) + or (resp.status_code == 500 and "could not process" in error_detail.lower()) ) if _is_permanent: asset_id = asset_map.get(fname) From 5e0a8713147249217794367d431966d48d5ca5fd Mon Sep 17 00:00:00 2001 From: Holden Date: Wed, 17 Jun 2026 17:47:31 +0000 Subject: [PATCH 05/11] =?UTF-8?q?fix:=202=20low=20findings=20from=20audit?= =?UTF-8?q?=20=E2=80=94=20consistent=20500=20match=20source,=20accurate=20?= =?UTF-8?q?return=20type?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use full_body for the 500 'could not process' permanent-rejection check, consistent with the 400 'face' check on the line above. error_detail is truncated to 100 chars via the fallback path, which could silently miss the phrase in a long response body. Remove | None from process_face_mode return type — every code path returns tuple[int,int] or str; None is unreachable. Update docstring to match. --- winnow/executor.py | 2 +- winnow/image_processing.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/winnow/executor.py b/winnow/executor.py index 5b0aeb6..992c93d 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -573,7 +573,7 @@ def upload_to_frigate(jobs: list[dict]) -> None: _is_permanent = ( (resp.status_code == 400 and "face" in full_body.lower()) or resp.status_code == 422 - or (resp.status_code == 500 and "could not process" in error_detail.lower()) + or (resp.status_code == 500 and "could not process" in full_body.lower()) ) if _is_permanent: asset_id = asset_map.get(fname) diff --git a/winnow/image_processing.py b/winnow/image_processing.py index c9c9b85..0d18d2b 100644 --- a/winnow/image_processing.py +++ b/winnow/image_processing.py @@ -68,11 +68,11 @@ def process_face_mode( count: int, min_width: int | None = None, insightface_app=None, -) -> tuple[int, int] | str | None: +) -> tuple[int, int] | str: """Crop face based on Immich metadata and save to output directory. - Returns (width, height) of the saved crop, a skip-reason string if the - face was filtered out, or None if no crop was saved for other reasons. + Returns (width, height) of the saved crop, or a skip-reason string if the + face was filtered out. When insightface_app is provided and ENABLE_FACE_ALIGNMENT is True, re-detects the face in the Immich bbox region using InsightFace to get precise landmarks for a proper 112x112 aligned crop. Falls back to From 0906342edf8bbd62245753fa833fed50b15afae0 Mon Sep 17 00:00:00 2001 From: Holden Date: Wed, 17 Jun 2026 19:12:58 +0000 Subject: [PATCH 06/11] fix: 10 correctness bugs from full codebase audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - immich_api: .get("items") or [] handles {"items": null} without crashing len() - immich_api: catch TypeError alongside ValueError in filter_recent_assets for timezone-naive fileCreatedAt comparisons - scheduler: catch SystemExit in addition to KeyboardInterrupt so cli.main() cannot kill the long-running scheduler process - scheduler: reseed croniter from wall-clock time after each run so overrunning jobs don't schedule an immediate back-to-back rerun - config: reject negative YEARS_FILTER values with a warning, reset to default 10 - frigate_api: return True (not False) for empty filenames list — callers cannot distinguish no-op from network failure on False - jobs: warn on unrecognised STRATEGY value instead of silently falling back - jobs: casefold ONLY_PEOPLE / SKIP_PEOPLE matching so "john doe" matches "John Doe" - executor: <= → < so a same-score candidate can fill a freed replacement slot - embeddings: set _insightface_loaded=True on GPU+CPU double-failure to prevent N re-init attempts (one per asset) when InsightFace is broken for a whole run --- scheduler.py | 3 ++- winnow/config.py | 3 +++ winnow/embeddings.py | 5 ++++- winnow/executor.py | 4 ++-- winnow/frigate_api.py | 4 +++- winnow/immich_api.py | 4 ++-- winnow/jobs.py | 14 +++++++++----- 7 files changed, 25 insertions(+), 12 deletions(-) diff --git a/scheduler.py b/scheduler.py index 99368d5..709868b 100644 --- a/scheduler.py +++ b/scheduler.py @@ -49,11 +49,12 @@ def _run_scheduler() -> None: try: main() print("winnow run complete", flush=True) - except KeyboardInterrupt: + except (KeyboardInterrupt, SystemExit): raise except Exception as e: logger.error("winnow run failed: %s", e, exc_info=True) print(f"winnow run failed: {e}", flush=True) + cron = croniter(schedule, time.time()) next_run = cron.get_next(float) print(f"Next run: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(next_run))}", flush=True) time.sleep(min(60, max(1, next_run - time.time()))) diff --git a/winnow/config.py b/winnow/config.py index 48a62c5..57db3ad 100644 --- a/winnow/config.py +++ b/winnow/config.py @@ -127,6 +127,9 @@ def _load(self) -> None: self.API_KEY = os.getenv("API_KEY") self.OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./frigate_train") self.YEARS_FILTER = _getenv_int("YEARS_FILTER", 10) + if self.YEARS_FILTER < 0: + logging.warning("YEARS_FILTER=%s is negative — using default 10", self.YEARS_FILTER) + self.YEARS_FILTER = 10 self.MIN_FACE_WIDTH = _getenv_int("MIN_FACE_WIDTH", 90) self.MIN_FACE_COUNT = _getenv_int("MIN_FACE_COUNT", 3) self.MERGE_DUPLICATE_PEOPLE = _getenv_bool("MERGE_DUPLICATE_PEOPLE", False) diff --git a/winnow/embeddings.py b/winnow/embeddings.py index 7787da7..efd0264 100644 --- a/winnow/embeddings.py +++ b/winnow/embeddings.py @@ -107,7 +107,6 @@ def get_insightface_app(): global _insightface_app, _insightface_loaded if _insightface_loaded: return _insightface_app - _insightface_loaded = True ctx_id = -1 insightface_home = os.environ.get("INSIGHTFACE_HOME", os.path.expanduser("~/.insightface")) @@ -172,10 +171,12 @@ def get_insightface_app(): _insightface_app.prepare(ctx_id=ctx_id, det_size=(640, 640)) logger.info("InsightFace Buffalo_L: ready on %s (%.1fs)", device_str, time.time() - t0) + _insightface_loaded = True return _insightface_app except ImportError: logger.error("InsightFace not installed!") + _insightface_loaded = True return None except Exception as e: logger.error("Failed to load InsightFace: %s", e) @@ -193,9 +194,11 @@ def get_insightface_app(): ) _insightface_app.prepare(ctx_id=-1, det_size=(640, 640)) logger.info("InsightFace Buffalo_L: ready on CPU (fallback, %.1fs)", time.time() - t0) + _insightface_loaded = True return _insightface_app except Exception as ex: logger.error("InsightFace CPU fallback failed: %s", ex) + _insightface_loaded = True return None diff --git a/winnow/executor.py b/winnow/executor.py index 992c93d..d8c04e4 100644 --- a/winnow/executor.py +++ b/winnow/executor.py @@ -384,9 +384,9 @@ def upload_to_frigate(jobs: list[dict]) -> None: # freed slot isn't filled with something worse than what we removed. if min_quality_score_for_slot is not None: file_score = score_map.get(fname) - if file_score is not None and file_score <= min_quality_score_for_slot: + if file_score is not None and file_score < min_quality_score_for_slot: progress.console.print( - f" [dim]⏭ {fname}: score {file_score:.3f} ≤ freed slot floor" + f" [dim]⏭ {fname}: score {file_score:.3f} < freed slot floor" f" {min_quality_score_for_slot:.3f}, skipping[/dim]" ) progress.advance(upload_task) diff --git a/winnow/frigate_api.py b/winnow/frigate_api.py index 3d15dd7..eba6939 100644 --- a/winnow/frigate_api.py +++ b/winnow/frigate_api.py @@ -146,8 +146,10 @@ def delete_frigate_person_files(person_name: str, filenames: list[str]) -> bool: Returns True on success, False if unreachable or the request fails. """ frigate_url = _get_frigate_url() - if not frigate_url or not filenames: + if not frigate_url: return False + if not filenames: + return True from urllib.parse import quote encoded_name = quote(person_name, safe="") try: diff --git a/winnow/immich_api.py b/winnow/immich_api.py index 71ade02..436cbc0 100644 --- a/winnow/immich_api.py +++ b/winnow/immich_api.py @@ -134,7 +134,7 @@ def fetch_all_assets(person: dict) -> tuple[list[dict], int]: # Immich ≥2.x returns {"assets": {"items": [...]}}; # earlier versions returned {"assets": [...]} directly. if isinstance(page_assets, dict): - page_assets = page_assets.get("items", []) + page_assets = page_assets.get("items") or [] page_count = len(page_assets) # raw count for termination check before filtering @@ -303,7 +303,7 @@ def filter_recent_assets(assets: list[dict], years: int | None = None) -> list[d recent.append(asset) else: skipped += 1 - except ValueError: + except (ValueError, TypeError): bad_timestamp += 1 continue diff --git a/winnow/jobs.py b/winnow/jobs.py index 656be22..71545f3 100644 --- a/winnow/jobs.py +++ b/winnow/jobs.py @@ -86,7 +86,11 @@ def _resolve_strategy(strategy: str, has_embedding: bool) -> tuple[int | str, st "standard": (30, "smart"), "broad": (100, "smart"), } - return strategy_map.get(strategy, ("auto", "smart")) + result = strategy_map.get(strategy) + if result is None: + logger.warning("Unrecognised STRATEGY=%r — falling back to auto", strategy) + return ("auto", "smart") + return result def _perform_selection( @@ -244,13 +248,13 @@ def auto_configure(people: list[dict]) -> list[dict]: return [] strategy = os.environ.get("STRATEGY", "auto") - skip = [s.strip() for s in os.environ.get("SKIP_PEOPLE", "").split(",") if s.strip()] - only = [s.strip() for s in os.environ.get("ONLY_PEOPLE", "").split(",") if s.strip()] + skip = {s.strip().casefold() for s in os.environ.get("SKIP_PEOPLE", "").split(",") if s.strip()} + only = {s.strip().casefold() for s in os.environ.get("ONLY_PEOPLE", "").split(",") if s.strip()} if only: - valid_people = [p for p in valid_people if p["name"] in only] + valid_people = [p for p in valid_people if p["name"].casefold() in only] if skip: - valid_people = [p for p in valid_people if p["name"] not in skip] + valid_people = [p for p in valid_people if p["name"].casefold() not in skip] min_face_count = Config.MIN_FACE_COUNT From f3b5bd9334a0c501a46bd8ab0ddf9337caf9d6df Mon Sep 17 00:00:00 2001 From: Holden Date: Wed, 17 Jun 2026 19:16:43 +0000 Subject: [PATCH 07/11] fix: update MAX_AUTO_IMAGES default assertion to 5 --- tests/test_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_config.py b/tests/test_config.py index 353a7c6..e093269 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -21,7 +21,7 @@ def test_config_loads_defaults(monkeypatch): assert cfg.MIN_FACE_COUNT == 3 assert cfg.BLUR_THRESHOLD == 120.0 assert cfg.MIN_CONFIDENCE == 0.7 - assert cfg.MAX_AUTO_IMAGES == 20 + assert cfg.MAX_AUTO_IMAGES == 5 assert cfg.QUALITY_REPLACEMENT is True assert cfg.FACE_MARGIN == 0.15 assert cfg.USE_FULL_RESOLUTION is True From 1e4425bfd7f40a07f1498e4aec7cd5ba87cf9d82 Mon Sep 17 00:00:00 2001 From: Holden Date: Thu, 18 Jun 2026 20:23:58 +0000 Subject: [PATCH 08/11] release: v0.6.6 --- CHANGELOG.md | 2 ++ pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92ff442..c7f8a5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.6] - 2026-06-18 + ### Changed - **`MAX_AUTO_IMAGES` default lowered from 20 to 5** — existing users who have not set this variable and already have more than 5 winnow-managed images in Frigate will find themselves at cap on the next run. With `QUALITY_REPLACEMENT=true` (the default), winnow will attempt to swap weaker images rather than uploading new ones. Set `MAX_AUTO_IMAGES=20` to restore the previous behaviour. diff --git a/pyproject.toml b/pyproject.toml index 27d8aff..86e849f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "winnow" -version = "0.6.5" +version = "0.6.6" description = "Selects diverse, high-quality photos from Immich as training data for Frigate face recognition." license = "AGPL-3.0-or-later" requires-python = ">=3.13" From db30449b09601c6347cdaab4418701e3293a37f0 Mon Sep 17 00:00:00 2001 From: Holden Date: Thu, 18 Jun 2026 20:29:43 +0000 Subject: [PATCH 09/11] chore: replace lockfile auto-update with uv lock --check --- .github/workflows/test.yml | 3 ++ .github/workflows/update-lockfile.yml | 63 --------------------------- 2 files changed, 3 insertions(+), 63 deletions(-) delete mode 100644 .github/workflows/update-lockfile.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b4df253..f757851 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,6 +21,9 @@ jobs: - name: Set up Python run: uv python install 3.13 + - name: Check lockfile is up to date + run: uv lock --check + - name: Install dependencies run: uv sync --extra cpu diff --git a/.github/workflows/update-lockfile.yml b/.github/workflows/update-lockfile.yml deleted file mode 100644 index 46f983c..0000000 --- a/.github/workflows/update-lockfile.yml +++ /dev/null @@ -1,63 +0,0 @@ -# .github/workflows/update-lockfile.yml -name: Update lockfile - -on: - push: - branches: - - '**' - - '!main' - paths: - - 'pyproject.toml' - workflow_dispatch: - -jobs: - update-lockfile: - runs-on: ubuntu-latest - permissions: - contents: write - pull-requests: write - steps: - - name: Checkout repository - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - name: Install uv - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 - - - name: Set up Python - run: uv python install 3.13 - - - name: Regenerate lockfile - run: uv lock - - - name: Check for changes - id: diff - run: | - if git diff --quiet uv.lock; then - echo "changed=false" >> "$GITHUB_OUTPUT" - else - echo "changed=true" >> "$GITHUB_OUTPUT" - fi - - - name: Commit and push updated lockfile - if: steps.diff.outputs.changed == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add uv.lock - git commit -m "chore: update lockfile" - - if [ "$GITHUB_REF_NAME" = "dev" ]; then - # dev is protected — open a PR rather than pushing directly - BRANCH="chore/lockfile-$(git rev-parse --short HEAD)" - git checkout -b "$BRANCH" - git push origin "$BRANCH" - gh pr create \ - --base dev \ - --head "$BRANCH" \ - --title "chore: update lockfile" \ - --body "Automated lockfile update triggered by a \`pyproject.toml\` change on \`dev\`." - else - git push - fi From 0ac02c31081ff0ad717b76a773b7cad55a922b62 Mon Sep 17 00:00:00 2001 From: Holden Date: Thu, 18 Jun 2026 20:31:36 +0000 Subject: [PATCH 10/11] chore: update lockfile for v0.6.6 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 0c3f5ba..9e9377f 100644 --- a/uv.lock +++ b/uv.lock @@ -905,7 +905,7 @@ wheels = [ [[package]] name = "winnow" -version = "0.6.5" +version = "0.6.6" source = { editable = "." } dependencies = [ { name = "croniter" }, From e281ac01e1e19ea28ebda0df47a9ebf7cf98342e Mon Sep 17 00:00:00 2001 From: Holden Date: Thu, 18 Jun 2026 20:33:58 +0000 Subject: [PATCH 11/11] chore: add pre-commit hook to auto-update lockfile on pyproject.toml changes --- .githooks/pre-commit | 7 +++++++ CONTRIBUTING.md | 3 +++ 2 files changed, 10 insertions(+) create mode 100755 .githooks/pre-commit diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..5d23f5d --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +if git diff --cached --name-only | grep -q "^pyproject\.toml$"; then + uv lock + git add uv.lock +fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 943f2c6..5b00940 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,8 +17,11 @@ git clone https://github.com/sudolulo/winnow.git cd winnow git checkout dev uv sync +git config core.hooksPath .githooks ``` +The last line activates the project's git hooks. The pre-commit hook automatically runs `uv lock` and stages the result whenever `pyproject.toml` is part of a commit, keeping the lockfile in sync without any extra steps. + ## Running Tests and Lint ```bash