From 09b0200f57deb09a50f49c0d2311487bfea3e4d0 Mon Sep 17 00:00:00 2001 From: Armando Anaya Date: Fri, 7 Aug 2026 02:05:23 -0700 Subject: [PATCH] chore(deps): a three-day cool-down on every version this repository picks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A compromised release is most dangerous between publication and yanking. Dependabot already waited three days; nothing else did. This applies the same rule everywhere a version is *chosen*, and pins the paths where one is merely installed so nothing can choose behind it. - pnpm: minimumReleaseAge in pnpm-workspace.yaml — declarative, so every pnpm add/update on a laptop, in Docker and in CI is covered with no flag. - uv: scripts/cooldown.sh, because uv has no rolling setting (--exclude-newer takes absolute dates only). It owns the number and computes the cutoff. - scripts/build_dist.sh runs uv build through it: [build-system] requires is not in uv.lock and a build backend is executed, so it is the sharpest site. - CI's uv sync becomes uv sync --locked. A cutoff on a bare sync makes uv discard the lockfile and re-resolve, which would mean CI testing a set nobody chose; --locked also gates lockfile drift. - Dependabot gains the docker and docker-compose ecosystems, which had no entry and therefore no cool-down; docker/compose.yaml's one floating :latest tag is pinned, since no cool-down can cover a re-pointed tag. - Node is pnpm only: check.sh's two npx call sites become pnpm exec. --- .agents/skills/backend/python-setup/SKILL.md | 17 +- .agents/skills/frontend/nodejs-setup/SKILL.md | 12 +- .../skills/process/refactor-protocol/SKILL.md | 2 +- .github/dependabot.yml | 61 ++++- .github/workflows/ci.yml | 26 +- CONTRIBUTING.md | 34 +++ docker/compose.yaml | 9 +- docs/releasing.md | 7 +- pnpm-workspace.yaml | 26 ++ scripts/build_dist.sh | 14 +- scripts/check.sh | 16 +- scripts/cooldown.sh | 124 ++++++++++ tests/scripts/cooldown.test.mjs | 226 ++++++++++++++++++ 13 files changed, 553 insertions(+), 21 deletions(-) create mode 100755 scripts/cooldown.sh create mode 100644 tests/scripts/cooldown.test.mjs diff --git a/.agents/skills/backend/python-setup/SKILL.md b/.agents/skills/backend/python-setup/SKILL.md index c4074b45..783976ed 100644 --- a/.agents/skills/backend/python-setup/SKILL.md +++ b/.agents/skills/backend/python-setup/SKILL.md @@ -25,12 +25,25 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task uv sync # create/refresh .venv with the package (editable) + dev group uv run python -c "..." # run anything inside the env uv run pytest # tests -uv add # runtime dependency -> [project].dependencies -uv add --dev # dev dependency -> [dependency-groups].dev + +# Adding a dependency goes through the cool-down wrapper — see below. +bash scripts/cooldown.sh uv add # runtime -> [project].dependencies +bash scripts/cooldown.sh uv add --dev # dev -> [dependency-groups].dev ``` Never edit `uv.lock` by hand; never `pip install` into `.venv`. +**The three-day cool-down.** This repository does not take a package version the ecosystem has not +had three days to look at. uv has no rolling setting for it — `--exclude-newer` accepts absolute +dates only — so `scripts/cooldown.sh` computes the cutoff at the moment of the call and exports +`UV_EXCLUDE_NEWER`. Use it for anything that **resolves**: `uv add`, `uv lock`, `uv pip install`. + +Do **not** put it in front of `uv sync`. A cutoff on a plain sync makes uv discard the lockfile and +re-resolve (`Ignoring existing lockfile due to addition of timestamp cutoff`), which is the opposite +of what a sync is for; CI uses `uv sync --locked` so it cannot happen there. The cool-down governs +what gets *into* uv.lock, and the lockfile governs everything after. Full rules and the escape +hatches are in CONTRIBUTING.md. + ## Checks that must stay green | Check | Command | diff --git a/.agents/skills/frontend/nodejs-setup/SKILL.md b/.agents/skills/frontend/nodejs-setup/SKILL.md index 88629ed8..d9dad4cb 100644 --- a/.agents/skills/frontend/nodejs-setup/SKILL.md +++ b/.agents/skills/frontend/nodejs-setup/SKILL.md @@ -17,8 +17,16 @@ allowed-tools: Read, Edit, Write, Glob, Grep, Bash, WebFetch, WebSearch, Task ## Environment - **Node.js 24**. With nvm on the host: `nvm use 24`. -- **pnpm** only (never npm, never yarn). Pinned via `packageManager` in the root - `package.json` — enable it with `corepack enable`. +- **pnpm** only (never npm, never yarn, never `npx`). Pinned via `packageManager` in + the root `package.json` — enable it with `corepack enable`. To run a binary the + workspace already has, `pnpm exec `; `npx` would *fetch and run* one it does + not, which is a resolution no lockfile names and no cool-down covers. +- **The three-day cool-down applies to every `pnpm add` and `pnpm update`**, through + `minimumReleaseAge` in `pnpm-workspace.yaml`. Nothing to type — but a package whose + newest release is younger than that is refused outright + (`ERR_PNPM_NO_MATURE_MATCHING_VERSION`) rather than silently downgraded, because a + bare `pnpm add ` asks for `latest`. Wait, name an older version, or add a + reviewed entry to `minimumReleaseAgeExclude`. See CONTRIBUTING.md. - Single **pnpm workspace** rooted at the repo root; members are `frontend/*` (`@visionset/annotator`, `@visionset/ui-core`, `@visionset/app`). - Run commands **from the repo root**. diff --git a/.agents/skills/process/refactor-protocol/SKILL.md b/.agents/skills/process/refactor-protocol/SKILL.md index a4f22333..054959a9 100644 --- a/.agents/skills/process/refactor-protocol/SKILL.md +++ b/.agents/skills/process/refactor-protocol/SKILL.md @@ -45,7 +45,7 @@ All work in the worktree; never the primary checkout. Conventional commits in lo The script sets `CI=1` for the Playwright steps itself, so that is no longer yours to remember. **`--fast` is never enough before a push.** The real-server cycle run is mandatory for anything touching state, gating, or progress: it was three separate times the *only* suite to catch a regression — a stale job declaration, a label flip standing in for feedback, and a progress counter running backwards. — 2026-08 run, T3/T5/T6; #314 - **When the machine is saturated, the fallback is declared — never silent.** A green `bash scripts/check.sh` is still what a merge requires. When another session has the box, and you can *show* it — load average, the competing processes, `ps aux | grep` output — the sanctioned substitute is: every static gate (`ruff check .`, `ruff format --check .`, `mypy`, `lint-imports`, the `node --test` script gates), the full frontend build and test suite, and every pytest module the change touches, with **full green CI on clean runners as the arbiter**. That is not a lowering of the bar: a timing-sensitive suite at load average 60 tells you nothing it would not also tell you at load average 6000. **Say so in the PR body before the merge, naming which suites did not run and why.** A merge that lets a reader infer a green local gate that never happened is a protocol violation, not a shortcut — and the fallback is only available for a machine you can evidence, not for one you are impatient with. — #339 - **Where the harness kills long-running commands, run the gate in stages rather than fighting the ceiling.** The observed limit is ~10 minutes, the kill takes the whole process group, and every way out of it fails: `run_in_background`, a watcher, and `nohup … & disown` all die at the same point (and `setsid` does not exist on macOS, so that spelling dies instantly and silently). The stages that fit: pytest split by test directory — **derived from `ls tests/` at run time, never a remembered list**, since #344's staged runs missed `tests/jobs`, new since #339 — then `ruff` / `mypy` / `lint-imports`, then frontend, then browser. **Record every stage's exit code verbatim in the PR body** — a staged gate whose stages are undocumented is indistinguishable from a partial one, the same false-calm failure as #336. And never pipe a runner through `tail` to dodge the ceiling: the repo forbids it, and it swallows the summary line along with the exit code. — #344 -- **`CI=1` on any Playwright run you invoke by hand.** `playwright.config.ts` sets `reuseExistingServer: !CI`, so a stale vite server on this worktree's derived e2e port answers instead of your build and produces failures that read as code bugs. `check.sh` does this for you; `npx playwright test` typed directly does not. — 2026-08 run, T3 +- **`CI=1` on any Playwright run you invoke by hand.** `playwright.config.ts` sets `reuseExistingServer: !CI`, so a stale vite server on this worktree's derived e2e port answers instead of your build and produces failures that read as code bugs. `check.sh` does this for you; `pnpm exec playwright test` typed directly does not. — 2026-08 run, T3 - **The browser stages take a port per worktree, so two of them may run at once.** Since #346 the number is derived from the worktree's absolute path by `frontend/app/e2e-ports.ts`; the main checkout and CI keep the old 5273 / 8123 / 5373, and every run prints the three it resolved before it starts. Override one with `VISIONSET_E2E_PORT`, `VISIONSET_CYCLE_PORT` or `VISIONSET_BENCH_PORT`. What survives from when they *were* single-occupancy: **a stage that fails far faster than its normal runtime is a setup collision, not a test failure** — read the printed port, find the occupant (`lsof -nP -iTCP: -sTCP:LISTEN`) and read its cmdline for the path that owns it, before debugging a single test. The occupant is now almost always a server *this* worktree left behind, since the port is private to it. **Never kill a process belonging to another session**; wait, or set the override. — #344, #346 - **To rerun the cycle suite N times, use `--repeat-each=N`** — it costs one build rather than N, because the suite's names are run-scoped since #314. Before that a fixed project name made repeat 2 die on `POST /projects → 409`, and repetition meant N whole invocations at ~90 s of rebuild each. - **`git add` new files before trusting any local check run.** Several gates read `git ls-files` — the index, not the working tree — so an untracked new file is invisible to them and passes locally while failing in CI. — 2026-08 run, T4 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 68964ca3..2a85122b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,9 +1,11 @@ # Automated dependency updates. # -# Three ecosystems, all at the repository root: +# Five ecosystems: # uv — the single `visionset` distribution (pyproject.toml + uv.lock) # npm — the pnpm workspace (pnpm-lock.yaml at the root, packages in frontend/*) # github-actions — .github/workflows/ +# docker — the two dev-stack images (docker/*.Dockerfile) +# docker-compose — the services docker/compose.yaml pulls rather than builds # # `cooldown` is a supply-chain margin: a version must have been published more # than three days ago before Dependabot will propose it, so a package that is @@ -11,6 +13,13 @@ # applies to *version* updates only — Dependabot security updates stay # immediate by design, which is what we want. # +# **Three days is one number with several spellings, and they must agree.** The +# same rule governs the versions a person or a CI step picks, where Dependabot is +# not involved at all: `minimumReleaseAge` in pnpm-workspace.yaml covers every +# `pnpm add`, and scripts/cooldown.sh covers uv, which has no equivalent setting. +# `tests/scripts/cooldown.test.mjs` holds all three to each other, so moving one +# and forgetting the others is a red test rather than a quiet inconsistency. +# # `ignore` is for a version this repository cannot take *yet* — a major whose # ecosystem has not caught up. Every entry must name the blocker and the # condition that retires it, because an ignore with no expiry is a pin nobody @@ -88,3 +97,53 @@ updates: commit-message: prefix: "chore(deps)" labels: ["dependencies", "ci"] + + # The base images of the two dev-stack builds — `node:24-bookworm-slim` and + # `ghcr.io/astral-sh/uv:python3.12-trixie-slim`. Until this entry existed they + # were the one class of dependency here with no automation and no cool-down at + # all: a tag is re-pointed by its publisher, so `node:24-bookworm-slim` means + # different bytes on different days and nothing in the repository recorded + # which. Dependabot cannot fix the re-pointing, but it can propose the version + # bumps, and the same three days apply. + # + # Both Dockerfiles pin a major-and-flavour tag rather than a digest, which is + # the deliberate trade docker/api.Dockerfile's header argues for (the ffmpeg + # version that comes with the distribution is the thing being chosen). A digest + # pin would be stricter and would also make every security patch a manual bump. + - package-ecosystem: "docker" + directory: "/docker" + schedule: + interval: "weekly" + day: "monday" + cooldown: + default-days: 3 + groups: + images-minor-patch: + applies-to: version-updates + patterns: ["*"] + update-types: ["minor", "patch"] + open-pull-requests-limit: 10 + commit-message: + prefix: "chore(deps)" + labels: ["dependencies", "ci"] + + # The services compose *pulls* rather than builds: nginx, postgres, minio. A + # separate ecosystem from the one above because Dependabot reads Dockerfiles and + # compose files with different parsers, and an entry for one does not see the + # other. + - package-ecosystem: "docker-compose" + directory: "/docker" + schedule: + interval: "weekly" + day: "monday" + cooldown: + default-days: 3 + groups: + services-minor-patch: + applies-to: version-updates + patterns: ["*"] + update-types: ["minor", "patch"] + open-pull-requests-limit: 10 + commit-message: + prefix: "chore(deps)" + labels: ["dependencies", "ci"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62de906f..b9fec89f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,8 +33,14 @@ jobs: with: enable-cache: true + # `--locked`, not a bare `uv sync`, and it does two jobs. It refuses to + # re-resolve, which is what keeps the dependency cool-down honest — a + # resolution that happens here would be one nobody applied the rule to (see + # scripts/cooldown.sh). And it fails when uv.lock has drifted from + # pyproject.toml, so an uncommitted lock is a named red step rather than a + # silent re-lock that makes CI test a set no reviewer chose. - name: Sync environment - run: uv sync + run: uv sync --locked - name: Ruff (lint + format) run: | @@ -97,7 +103,7 @@ jobs: enable-cache: true - name: Sync environment - run: uv sync + run: uv sync --locked # `uv run` puts the virtualenv's bin/ on PATH, so `visionset` is the # installation under test — which the HTTP leg starts a server with and the @@ -238,7 +244,7 @@ jobs: enable-cache: true - name: Sync environment - run: uv sync + run: uv sync --locked - uses: pnpm/action-setup@v6 # reads packageManager from package.json @@ -393,10 +399,18 @@ jobs: # particular not the repository, which is what makes this a test of the # artifact. `--no-cache` so a previously built wheel of the same version # cannot be served from uv's cache in place of this one. + # + # The one place in this workflow that genuinely *resolves* a third-party + # package from an index — `ultralytics` is named with no version and comes + # from PyPI, unlike everything else here, which comes from uv.lock. So it + # is the one place the cool-down wrapper is needed rather than decorative. + # The local wheel is a path, and a path has no upload date, so the cutoff + # cannot touch the artifact under test. - name: Install the wheel into a clean environment run: | uv venv /tmp/flow-venv - uv pip install --no-cache --python /tmp/flow-venv dist/*.whl ultralytics + bash scripts/cooldown.sh \ + uv pip install --no-cache --python /tmp/flow-venv dist/*.whl ultralytics # `cd /tmp` so an accidental relative import cannot reach the checkout: the # script is named by absolute path and runs with the repository *not* on @@ -443,7 +457,7 @@ jobs: enable-cache: true - name: Sync environment with the format groups - run: uv sync --group yolo --group coco + run: uv sync --locked --group yolo --group coco - name: The reference readers load the exports run: uv run pytest tests/formats/test_yolo_smoke.py tests/formats/test_coco_smoke.py -q @@ -509,7 +523,7 @@ jobs: enable-cache: true - name: Sync environment - run: uv sync + run: uv sync --locked - name: Regenerate openapi.json and fail on drift run: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index acc2de5d..b2efb309 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,6 +28,40 @@ Dev only; the release artifact is always the pip package, and these images are n it on the host stays faster, because a bind mount has to poll for file changes rather than being told about them. +## Adding a dependency: the three-day cool-down + +**This repository does not install a package version the ecosystem has not had three days to look +at.** A compromised release is most dangerous in the hours between publication and yanking, and +patience is the cheapest defence there is. The rule is the same everywhere; only the spelling +differs, because pnpm has a setting for it and uv does not. + +| Adding to | Type | The rule reaches you through | +| --- | --- | --- | +| the pnpm workspace | `pnpm add ` | `minimumReleaseAge` in `pnpm-workspace.yaml` — automatic, nothing to type | +| the Python distribution | `bash scripts/cooldown.sh uv add ` | the wrapper; a bare `uv add` waits for nothing | +| a build backend | — | `scripts/build_dist.sh` already wraps `uv build` | +| GitHub Actions, Docker images | Dependabot | `cooldown.default-days` in `.github/dependabot.yml` | + +Three days is **one number with four spellings**, and `tests/scripts/cooldown.test.mjs` holds them +to each other — `scripts/cooldown.sh` is the source, and moving it without moving the rest is a red +test rather than a quiet inconsistency. + +**It is a resolution-time rule, and it is inert on every install path.** `pnpm install +--frozen-lockfile` and `uv sync --locked` install exactly what the lockfile names, cool-down or no +— that is the point, not a gap. The lockfile is the reviewed artifact; the cool-down polices what +gets into it. This is also why CI uses `--locked` rather than a bare `uv sync`: a plain `uv sync` +under a cutoff *discards the lockfile and re-resolves*, which would mean CI silently testing a set +nobody chose. + +**When it fires.** `pnpm add ` with no version asks for `latest`, so a too-new release is +refused outright (`ERR_PNPM_NO_MATURE_MATCHING_VERSION`) rather than silently downgraded. Wait, or +name an older version. To take a young version deliberately, add it to `minimumReleaseAgeExclude` +in the same commit — an exception in the diff is one somebody can review. On the Python side, run +the bare command or set `VISIONSET_COOLDOWN_DAYS=0` for a single invocation. + +**Security fixes are never delayed.** Dependabot's security updates bypass its own cool-down by +design, and nothing here re-imposes one on them. + ## Checks that must stay green **Run them with `bash scripts/check.sh`** (or `pnpm check` — the same script). It is the diff --git a/docker/compose.yaml b/docker/compose.yaml index 1088e295..6f6f09ca 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -223,7 +223,14 @@ services: minio: profiles: ["minio"] - image: minio/minio:latest + # A named release rather than `:latest`, and it is the one image here that + # needed changing. `latest` is the single thing a cool-down structurally + # cannot cover: there is no version to be three days old, the tag is + # re-pointed by its publisher, and `docker compose pull` on the morning of a + # compromise fetches it with nothing in between. Every other service in this + # file already names a version. Dependabot's `docker-compose` entry now + # proposes the bumps, under the same three days as everything else. + image: minio/minio:RELEASE.2025-09-07T16-13-09Z command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: visionset diff --git a/docs/releasing.md b/docs/releasing.md index c09e5f6b..a2fe02c6 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -51,9 +51,14 @@ page will not show you a year from now, attached to the commit they came from. publishing a package that looks official: ```bash -npm org create visionset # or: npm login && npm access … +pnpm login --registry https://registry.npmjs.org # then create the org in the npm web UI ``` +pnpm is the only Node package manager this repository uses, and `pnpm login` writes the same +credential `pnpm publish` would later read. Creating the *organisation* is a registry +administration action with no client-side equivalent in any package manager — it is done on the +npmjs.com website, not from a terminal. + Creating the organisation reserves the scope. **No placeholder publishes** — an empty package on npm is a thing users find, file issues against, and depend on by accident, and un-publishing it later is a worse problem than the one it was meant to prevent. diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2d24760d..e31164ee 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,28 @@ packages: - "frontend/*" + +# The dependency cool-down, Node's half. See scripts/cooldown.sh for the rule and +# the argument; this is the same three days, in the units pnpm wants (minutes). +# +# Declarative rather than a wrapper, because pnpm has the setting uv does not: +# it applies to every `pnpm add` and `pnpm update` on a laptop, in +# docker/app.Dockerfile — which copies this file, so the image inherits it — and +# in CI, with nothing to remember and no flag to forget. +# +# It is a *resolution*-time rule and is inert under `--frozen-lockfile`, which is +# what every automated install here uses. That is deliberate: the lockfile is the +# reviewed artifact, and re-litigating it at install time would only mean CI +# stopped testing the pinned set. +# +# What it feels like when it fires: `pnpm add ` with no range asks for +# `latest`, so if the newest release is younger than this, pnpm refuses outright +# with ERR_PNPM_NO_MATURE_MATCHING_VERSION rather than quietly picking an older +# one. That is the intended answer — wait, or name an older version explicitly. +# To take a young version deliberately, add it to `minimumReleaseAgeExclude` +# below in the same commit, so the exception is reviewable. +minimumReleaseAge: 4320 + +# Packages allowed to skip the wait. Empty, and a new entry should say why in the +# commit that adds it — an exclusion with no stated reason is a cool-down quietly +# switched off for one package. +minimumReleaseAgeExclude: [] diff --git a/scripts/build_dist.sh b/scripts/build_dist.sh index 87fa7dd1..d59c86ac 100755 --- a/scripts/build_dist.sh +++ b/scripts/build_dist.sh @@ -40,9 +40,19 @@ if ! grep -q '"/app/assets/' "$index" && ! grep -q "'/app/assets/" "$index"; the exit 1 fi -echo "==> uv build" +# Under the cool-down, and this is the sharpest place in the repository for it. +# `[build-system] requires = ["hatchling"]` is not in uv.lock — build backends are +# resolved fresh, from PyPI, on every build — and a build backend is *executed*, +# so a compromised release of one runs arbitrary code with the credentials of +# whatever is building. Three days of patience costs a release nothing and is the +# only thing standing between that and here. See scripts/cooldown.sh. +# +# Wrapping it here rather than in the three callers is what makes it true for all +# of them at once: the `wheel` and `30-minute flow` CI jobs and the PyPI publish +# workflow all reach `uv build` through this script and nothing else does. +echo "==> uv build (dependency cool-down applied to the build backend)" rm -rf dist -uv build +bash "$root/scripts/cooldown.sh" uv build echo echo "built:" diff --git a/scripts/check.sh b/scripts/check.sh index 14a7a791..260597a9 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -156,15 +156,21 @@ run_generated() { # error. So `check.sh browser` on its own is a complete run, not a half of one. # # No `require_playwright_browsers` to match `require_node_modules`: Playwright's -# own error already names `npx playwright install` as the remedy, and a check -# that restates a message which is already good is a second place to keep -# current. +# own error already names the install command as the remedy, and a check that +# restates a message which is already good is a second place to keep current. +# +# `pnpm exec`, never `npx`. pnpm is the only Node package manager this repository +# uses — the rule the `nodejs-setup` skill states and these two lines were the +# last exception to. It is not only tidiness: `npx` will *fetch and run* a package +# that is not installed, which is a resolution nothing here reviewed, no lockfile +# names and no cool-down applies to. `pnpm exec` runs what the workspace already +# has and fails if it is not there, which is the answer this script wants anyway. browser_e2e() { - ( cd "$root/frontend/app" && CI=1 npx playwright test ) + ( cd "$root/frontend/app" && CI=1 pnpm exec playwright test ) } browser_cycle() { - ( cd "$root/frontend/app" && CI=1 npx playwright test -c playwright.cycle.config.ts ) + ( cd "$root/frontend/app" && CI=1 pnpm exec playwright test -c playwright.cycle.config.ts ) } run_browser() { diff --git a/scripts/cooldown.sh b/scripts/cooldown.sh new file mode 100755 index 00000000..e9846919 --- /dev/null +++ b/scripts/cooldown.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# The dependency cool-down: this repository does not install a package version +# the ecosystem has not had three days to look at. +# +# A compromised release is most dangerous in the hours between publication and +# yanking, and the cheapest defence available is patience. Dependabot has said +# this for a while (`cooldown.default-days` in .github/dependabot.yml); this +# script is the same rule for the versions a *person* or a CI step picks. +# +# --------------------------------------------------------------------------- +# A cool-down is a RESOLUTION-time control, never an install-time one. +# --------------------------------------------------------------------------- +# +# Both install-from-lock paths ignore it, and that is correct rather than a +# hole: `pnpm install --frozen-lockfile` installs a locked version that violates +# the cool-down without complaint, and `uv sync --frozen` audits and moves on. +# The lockfile is the artifact somebody reviewed — the cool-down's job is to +# police what gets *into* it, not to re-litigate it on every machine that +# installs it. So the rule applies to the commands that choose versions, and the +# install paths are pinned (`--locked`, `--frozen-lockfile`) so they cannot +# choose one behind its back. +# +# --------------------------------------------------------------------------- +# Node needs no help from this script; Python does. +# --------------------------------------------------------------------------- +# +# pnpm has a rolling cool-down of its own — `minimumReleaseAge`, in minutes, set +# in pnpm-workspace.yaml. It is declarative, so it covers every `pnpm add` and +# `pnpm update` on a laptop, in Docker and in CI with nothing to remember and no +# wrapper to forget. Measured on the pinned pnpm 10.30.2: a too-new version is +# refused with ERR_PNPM_NO_MATURE_MATCHING_VERSION. +# +# uv has no equivalent. `--exclude-newer` takes RFC 3339 timestamps and dates +# only — `3 days ago`, `3d` and `P3D` are all rejected — and there is no +# `--minimum-release-age`. A rolling cutoff therefore has to be computed at the +# moment of the call, which is the whole reason this file exists. +# +# Usage: +# +# bash scripts/cooldown.sh uv add httpx # any resolving command +# bash scripts/cooldown.sh uv lock --upgrade +# bash scripts/cooldown.sh uv pip install ultralytics +# +# bash scripts/cooldown.sh --days # 3 +# bash scripts/cooldown.sh --cutoff # 2026-08-04T09:00:00Z +# +# The two query forms exist so the gate in tests/scripts/cooldown.test.mjs and +# the docs can read the number from here rather than restating it. +# +# --------------------------------------------------------------------------- +# Overriding it +# --------------------------------------------------------------------------- +# +# A cool-down nobody can escape is a cool-down people turn off. Two deliberate +# exits, and both are meant to be visible in a diff or a transcript: +# +# * Run the bare command. `uv add x` still works and still waits for nothing — +# this script adds the rule, it does not enforce it globally. That is the +# right answer for installing a version this repository just published, or +# for reproducing a report against a specific new release. +# * VISIONSET_COOLDOWN_DAYS=0 turns the cutoff off for one invocation while +# leaving the call site — and the reason, in the shell history or the CI log +# — intact. +# +# Security updates are never delayed by any of this: Dependabot's security PRs +# bypass its own cool-down by design, and a fix for a known-exploited hole +# reaches the lockfile the day it lands. +set -euo pipefail + +# The number, and the only place it is written down. Everything else — the pnpm +# setting, the Dependabot entries, the prose in CONTRIBUTING.md — is held to +# this value by tests/scripts/cooldown.test.mjs. +COOLDOWN_DAYS="${VISIONSET_COOLDOWN_DAYS:-3}" + +# `date` is the one dependency, because it is the one thing guaranteed to be +# present wherever uv is: this runs on a developer's mac, on an ubuntu runner +# and inside a slim Debian image, and the Python that could compute it is not +# reliably on PATH in the third. GNU and BSD spell relative dates differently +# and neither accepts the other's flag, so both are tried — GNU first, since +# that is the CI and Docker case, with the mac falling through to the second. +cooldown_cutoff() { + local days="$1" + date -u -d "${days} days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \ + || date -u -v"-${days}d" +%Y-%m-%dT%H:%M:%SZ +} + +case "${1:-}" in + --days) + echo "$COOLDOWN_DAYS" + exit 0 + ;; + --cutoff) + cooldown_cutoff "$COOLDOWN_DAYS" + exit 0 + ;; + "" | --help | -h) + # To stdout and exit 0 for `--help`, to stderr and exit 2 for no arguments + # at all, which is a mistake rather than a question. + if [[ "${1:-}" == "" ]]; then + echo "usage: cooldown.sh [args...] | --days | --cutoff" >&2 + exit 2 + fi + sed -n '2,60p' "${BASH_SOURCE[0]}" + exit 0 + ;; +esac + +if [[ "$COOLDOWN_DAYS" == "0" ]]; then + echo "cooldown: disabled for this invocation (VISIONSET_COOLDOWN_DAYS=0)" >&2 + exec "$@" +fi + +cutoff="$(cooldown_cutoff "$COOLDOWN_DAYS")" + +# Announced rather than silent. A resolution that skipped a release is a fact +# somebody reading a CI log needs, otherwise "why did it not pick 2.1.0" has no +# answer anywhere. +echo "cooldown: ${COOLDOWN_DAYS} days — refusing anything published after ${cutoff}" >&2 + +# UV_EXCLUDE_NEWER rather than the flag, so this wraps every uv subcommand that +# resolves — `add`, `lock`, `sync`, `pip install`, `build` — without this script +# needing to know which spelling each of them accepts. +export UV_EXCLUDE_NEWER="$cutoff" +exec "$@" diff --git a/tests/scripts/cooldown.test.mjs b/tests/scripts/cooldown.test.mjs new file mode 100644 index 00000000..376cb975 --- /dev/null +++ b/tests/scripts/cooldown.test.mjs @@ -0,0 +1,226 @@ +// Run with: pnpm test:scripts (also part of the root `pnpm test`) +// +// The dependency cool-down — three days — is one rule with five spellings, in five +// files, in three languages, and no two of them can be checked by the same tool. +// `scripts/cooldown.sh` computes a cutoff for uv, `pnpm-workspace.yaml` states the +// same span in minutes for pnpm, and `.github/dependabot.yml` states it in days, +// once per ecosystem. Nothing makes them agree. Left alone, one moves and the +// others do not, and the result is not a failure — it is a repository that +// believes it has a policy and has four fifths of one. +// +// So this gate holds them **to each other**, with `scripts/cooldown.sh --days` as +// the source, because it is the only one of the five that is executable and +// therefore the only one that can be asked rather than parsed. +// +// It also holds the two structural halves the number cannot express: +// +// * a cool-down is a *resolution*-time rule, so every install path must be +// pinned — a bare `uv sync` under a cutoff discards the lockfile and +// re-resolves, which would mean CI testing a set nobody chose; +// * pnpm is the only Node package manager here, and `npx` in particular fetches +// and runs packages that no lockfile names and no cool-down covers. +// +// There is no YAML parser in this workspace, and this file deliberately does not +// add one: every assertion below is a narrow, anchored line match on a file whose +// shape is fixed by the gate itself. A dependency added to read the configuration +// that declares the dependency policy would be a poor trade. +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const read = (rel) => readFileSync(path.join(root, rel), "utf8"); + +/** The one authority. Everything else in this file is measured against it. */ +const DAYS = Number( + execFileSync("bash", [path.join(root, "scripts/cooldown.sh"), "--days"], { + encoding: "utf8", + }).trim(), +); + +test("the cool-down script reports a positive whole number of days", () => { + assert.ok(Number.isInteger(DAYS), `--days printed ${DAYS}`); + assert.ok(DAYS > 0, "a cool-down of zero days is not a cool-down"); +}); + +test("the cutoff is that many days in the past, as an RFC 3339 instant", () => { + // uv rejects every relative form — `3 days ago`, `3d`, `P3D` — so the script's + // whole job is producing an absolute timestamp. Both halves matter: the shape, + // because uv parses it, and the distance, because that is the policy. + const cutoff = execFileSync( + "bash", + [path.join(root, "scripts/cooldown.sh"), "--cutoff"], + { encoding: "utf8" }, + ).trim(); + + assert.match(cutoff, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/, cutoff); + + const agoMs = Date.now() - Date.parse(cutoff); + const dayMs = 24 * 60 * 60 * 1000; + // A minute of slack for the clock between the two calls; nothing wider, or a + // day-sized error would pass. + assert.ok( + Math.abs(agoMs - DAYS * dayMs) < 60_000, + `cutoff is ${(agoMs / dayMs).toFixed(3)} days ago, expected ${DAYS}`, + ); +}); + +test("pnpm-workspace.yaml states the same span, in the minutes pnpm wants", () => { + const line = read("pnpm-workspace.yaml").match(/^minimumReleaseAge:\s*(\d+)\s*$/m); + assert.ok(line, "pnpm-workspace.yaml declares no minimumReleaseAge"); + assert.equal( + Number(line[1]), + DAYS * 24 * 60, + `minimumReleaseAge is ${line[1]} minutes; ${DAYS} days is ${DAYS * 24 * 60}`, + ); +}); + +test("every Dependabot ecosystem declares the same cool-down", () => { + const config = read(".github/dependabot.yml"); + + const ecosystems = [...config.matchAll(/^\s*-\s*package-ecosystem:\s*"([^"]+)"/gm)].map( + (m) => m[1], + ); + // The roster is asserted, not just each entry's cool-down: the way this rots is + // a *new* ecosystem added with no cooldown block, which a per-entry check would + // pass by never looking at it. + assert.deepEqual( + [...ecosystems].sort(), + ["docker", "docker-compose", "github-actions", "npm", "uv"], + "an ecosystem was added or removed — give it a cooldown and update this roster", + ); + + const cooldowns = [...config.matchAll(/^\s*cooldown:\n\s*default-days:\s*(\d+)\s*$/gm)].map( + (m) => Number(m[1]), + ); + assert.equal( + cooldowns.length, + ecosystems.length, + `${ecosystems.length} ecosystems but ${cooldowns.length} cooldown blocks`, + ); + for (const days of cooldowns) assert.equal(days, DAYS); +}); + +test("CI never runs a bare `uv sync`, so a cutoff can never silently re-resolve", () => { + // Measured on uv 0.9.13: `UV_EXCLUDE_NEWER` on a plain `uv sync` answers + // "Ignoring existing lockfile due to addition of timestamp cutoff" and rewrites + // uv.lock. `--locked` refuses to resolve at all, which is what makes the + // lockfile the thing CI actually tests. + const ci = read(".github/workflows/ci.yml"); + const syncs = [...ci.matchAll(/^\s*run:\s*(uv sync.*)$/gm)].map((m) => m[1].trim()); + assert.ok(syncs.length > 0, "no `uv sync` found — did the workflow move?"); + for (const line of syncs) { + assert.ok( + line.includes("--locked") || line.includes("--frozen"), + `\`${line}\` in ci.yml resolves; it must be --locked`, + ); + } +}); + +test("the Docker build honours its lockfile too", () => { + const dockerfiles = ["docker/api.Dockerfile", "docker/app.Dockerfile"]; + for (const file of dockerfiles) { + const body = read(file); + for (const [, line] of body.matchAll(/^\s*(?:RUN\s+.*)?\b(uv sync[^\n&|]*)/gm)) { + assert.ok( + line.includes("--locked") || line.includes("--frozen"), + `\`${line.trim()}\` in ${file} resolves; it must be --frozen or --locked`, + ); + } + for (const [, line] of body.matchAll(/\b(pnpm install[^\n&|]*)/gm)) { + assert.ok( + line.includes("--frozen-lockfile"), + `\`${line.trim()}\` in ${file} resolves; it must be --frozen-lockfile`, + ); + } + } +}); + +test("no Docker image is pulled from a floating tag", () => { + // The one thing a cool-down structurally cannot cover: there is no version to be + // three days old, and the publisher can re-point the tag under us. + const compose = read("docker/compose.yaml"); + for (const [, image] of compose.matchAll(/^\s*image:\s*(\S+)\s*$/gm)) { + assert.ok(image.includes(":"), `${image} has no tag at all`); + assert.ok( + !image.endsWith(":latest"), + `${image} floats — pin a released version so Dependabot can cool it down`, + ); + } +}); + +test("pnpm is the only Node package manager invoked anywhere", () => { + // `npx` is the sharp one: it fetches and runs a package that is not installed, + // which is a resolution no lockfile names and no cool-down covers. `pnpm exec` + // runs what the workspace already has and fails if it is not there. + // + // **Two scans, because the word appears in two grammars and one pattern cannot + // serve both.** The first draft used a single command-position regex over every + // file type and fired on `* Convert a PEP 440 version to its npm semver + // equivalent.` and on a test *name* containing "publishes to npm semver" — the + // failure mode `annotator_boundary.test.mjs` already records, where a gate + // reports the prose explaining it. So: + // + // * in shell, YAML and Dockerfiles the manager is a **command**, so match it + // at a command position on a line that is not a `#` comment; + // * in `.mjs` the only way to reach one is to spawn it, so match the two + // shapes that does take — the command argument of a call, and a command + // string — and skip comment lines. + // + // The second scan was narrowed twice, and both narrowings are the same lesson. + // Matching any quoted occurrence flagged `"npm"` in this file's own Dependabot + // roster, where it is an *ecosystem name*; skipping only `#` comments left this + // file's `//` prose about `npx`. What survives is deliberately shape-based + // rather than word-based: a manager named as the first argument of a call, or + // opening a command string with arguments after it. + const listed = (...globs) => + execFileSync("git", ["ls-files", ...globs], { cwd: root, encoding: "utf8" }) + .split("\n") + .filter(Boolean); + + const offenders = []; + const flag = (file, i, line) => offenders.push(`${file}:${i + 1}: ${line.trim()}`); + + for (const file of listed("*.sh", "*.yml", "*.yaml", "Dockerfile*", "*.Dockerfile")) { + read(file) + .split("\n") + .forEach((line, i) => { + if (/^\s*#/.test(line)) return; + if (/(?:^|[\s;&|(])(?:npx|yarn|npm)\s+\S/.test(line)) flag(file, i, line); + }); + } + + const SPAWNED = /\(\s*["'`](?:npx|yarn|npm)["'`]/; // execFileSync("npx", […]) + const COMMAND_STRING = /["'`](?:npx|yarn|npm)\s+\S/; // exec(`npx playwright test`) + + for (const file of listed("*.mjs")) { + read(file) + .split("\n") + .forEach((line, i) => { + if (/^\s*(?:\/\/|\/?\*)/.test(line)) return; + // Trailing comments too, and the two lines above are why: they carry the + // illustrations `execFileSync("npx", …)` and `` `npx playwright test` ``, + // and the scan reported its own examples. An invocation never lives after + // a `//`, so nothing real is lost. + const code = line.replace(/\/\/.*$/, ""); + if (SPAWNED.test(code) || COMMAND_STRING.test(code)) flag(file, i, line); + }); + } + + assert.deepEqual(offenders, [], `use pnpm:\n${offenders.join("\n")}`); +}); + +test("the release build resolves its build backend under the cool-down", () => { + // `[build-system] requires` is not in uv.lock — build backends resolve fresh on + // every build — and a build backend is *executed*. It is the sharpest resolution + // site in the repository and the easiest to leave uncovered. + const build = read("scripts/build_dist.sh"); + assert.match( + build, + /cooldown\.sh"?\s+uv build/, + "scripts/build_dist.sh must run `uv build` through scripts/cooldown.sh", + ); +});