From c78d536b818632996a5aeb9e986fc2ded1293443 Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 21 May 2026 04:39:47 +0200 Subject: [PATCH 1/2] add per-package overrides config Adds an `overrides` config array that applies cooldown, greatest, prerelease, release, patch, minor and allowDowngrade to dependencies matched by name via include/exclude patterns, with the last matching override winning. Enables e.g. a longer cooldown for a noisy publisher or no cooldown for an internal scope, which previously required a single global value. Co-Authored-By: Claude (Opus 4.7) --- README.md | 20 +++++++++++ api.ts | 94 +++++++++++++++++++++++++++++++++++++-------------- config.ts | 24 +++++++++++++ index.test.ts | 23 +++++++++++++ 4 files changed, 135 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 935f8c1a..05d99352 100644 --- a/README.md +++ b/README.md @@ -87,10 +87,30 @@ export default { - `patch` *boolean | Array\*: Consider only up to semver-patch - `minor` *boolean | Array\*: Consider only up to semver-minor - `allowDowngrade` *boolean | Array\*: Allow version downgrades +- `overrides` *Array\*: Per-package option overrides matched by name (see [Overrides](#overrides)) - `inherit` *object*: Opt-in to inheriting select fields from other tools' configs (see [Renovate config](#renovate-config)) CLI arguments have precedence over options in the config file. `include`, `exclude`, and `pin` options are merged. +### Overrides + +`overrides` applies options to a subset of dependencies, matched by name. Each override has `include` and/or `exclude` patterns (glob or `RegExp`, omit `include` to match all) plus any of these options: `cooldown`, `greatest`, `prerelease`, `release`, `patch`, `minor`, `allowDowngrade`. A matching override takes precedence over the corresponding top-level option, and when several overrides match the same dependency, the last one wins. + +```ts +import type {Config} from "updates"; + +export default { + cooldown: "7d", + overrides: [ + {include: ["@myorg/*"], cooldown: 0}, // no cooldown for your own scope + {include: [/^@aws-sdk/], cooldown: "14d"}, // longer cooldown for a noisy publisher + {exclude: ["typescript"], greatest: true}, // greatest for everything but typescript + ], +} satisfies Config; +``` + +A `cooldown` of `0` in an override disables a global cooldown for the matched dependencies. `patch` takes precedence over `minor`, so an override that sets `minor` has no effect while `patch` is enabled for that dependency. `pin` is not an override option since it is already per-package via [`pin`](#config-options). + ### Renovate config If a [Renovate](https://docs.renovatebot.com/) config is found, `ignoreDeps` and simple `packageRules` are inherited as `exclude`/`pin`. `minimumReleaseAge` is *not* inherited as `cooldown` by default — opt in via: diff --git a/api.ts b/api.ts index 8dcbc30f..d08824f2 100755 --- a/api.ts +++ b/api.ts @@ -15,7 +15,7 @@ import { passesCooldown, stripv, hashRe as npmHashRe, } from "./modes/shared.ts"; import {loadConfig, configMixedToRegexes, patternsToRegexSet} from "./config.ts"; -import type {Config} from "./config.ts"; +import type {Config, Override} from "./config.ts"; import { fetchNpmInfo, fetchNpmVersionInfo, fetchJsrInfo, isJsr, isLocalDep, parseJsrDependency, getNpmrc, updatePackageJson, updateVersionRange, normalizeRange, checkUrlDep, @@ -43,7 +43,7 @@ import { import {fetchCratesIoInfo, updateCargoToml, updateCargoRange, parseCargoLock, findLockedVersion} from "./modes/cargo.ts"; import {baseType, filterDepsForMember, resolveWorkspaceMembers, parsePnpmWorkspace, type WorkspaceMember} from "./utils/workspace.ts"; -export type {Config, Dep, Deps, DepsByMode, Output}; +export type {Config, Override, Dep, Deps, DepsByMode, Output}; const modeByFileName: Record = { "pnpm-workspace.yaml": "npm", @@ -298,26 +298,59 @@ export async function updates(opts: UpdatesOptions = {}): Promise { const allowDowngrade = configMixedToRegexes(config.allowDowngrade); const enabledModes = config.modes?.length ? new Set(config.modes) : defaultModes; + type CompiledOverride = { + include?: Set, exclude?: Set, + greatest?: boolean, prerelease?: boolean, release?: boolean, + patch?: boolean, minor?: boolean, allowDowngrade?: boolean, cooldownDays?: number, + }; + const compiledOverrides: Array = (config.overrides ?? []).map(o => ({ + include: o.include?.length ? patternsToRegexSet(o.include) : undefined, + exclude: o.exclude?.length ? patternsToRegexSet(o.exclude) : undefined, + greatest: o.greatest, prerelease: o.prerelease, release: o.release, + patch: o.patch, minor: o.minor, allowDowngrade: o.allowDowngrade, + cooldownDays: o.cooldown !== undefined ? parseDuration(String(o.cooldown)) : undefined, + })); + const overrideMatches = (o: CompiledOverride, name: string): boolean => { + if (o.include && !matchesAny(name, o.include)) return false; + if (o.exclude && matchesAny(name, o.exclude)) return false; + return true; + }; + const overridesHaveCooldown = compiledOverrides.some(o => Boolean(o.cooldownDays)); + // Kick off `gh auth token` early so the first forge request isn't blocked on a subprocess. if (enabledModes.has("actions")) getGithubTokens(); - function getSemvers(name: string): Set { - if (patch === true || matchesAny(name, patch)) return new Set(["patch"]); - if (minor === true || matchesAny(name, minor)) return new Set(["patch", "minor"]); - return new Set(["patch", "minor", "major"]); - } - - const versionOptsCache = new Map}>(); + const versionOptsCache = new Map, allowDowngrade: boolean, cooldownOverride: number | undefined}>(); + // Resolve per-dependency options: start from the global flags, then apply + // every matching override in order so the last matching one wins. cooldown is + // returned as an override (undefined = no override) since its base differs per + // mode. patch wins over minor, matching the global precedence. function getVersionOpts(name: string) { let entry = versionOptsCache.get(name); if (!entry) { - entry = { - useGreatest: typeof greatest === "boolean" ? greatest : matchesAny(name, greatest), - usePre: typeof prerelease === "boolean" ? prerelease : matchesAny(name, prerelease), - useRel: typeof release === "boolean" ? release : matchesAny(name, release), - semvers: getSemvers(name), - }; + let useGreatest = typeof greatest === "boolean" ? greatest : matchesAny(name, greatest); + let usePre = typeof prerelease === "boolean" ? prerelease : matchesAny(name, prerelease); + let useRel = typeof release === "boolean" ? release : matchesAny(name, release); + let usePatch = typeof patch === "boolean" ? patch : matchesAny(name, patch); + let useMinor = typeof minor === "boolean" ? minor : matchesAny(name, minor); + let allowDown = typeof allowDowngrade === "boolean" ? allowDowngrade : matchesAny(name, allowDowngrade); + let cooldownOverride: number | undefined; + + for (const o of compiledOverrides) { + if (!overrideMatches(o, name)) continue; + if (o.greatest !== undefined) useGreatest = o.greatest; + if (o.prerelease !== undefined) usePre = o.prerelease; + if (o.release !== undefined) useRel = o.release; + if (o.patch !== undefined) usePatch = o.patch; + if (o.minor !== undefined) useMinor = o.minor; + if (o.allowDowngrade !== undefined) allowDown = o.allowDowngrade; + if (o.cooldownDays !== undefined) cooldownOverride = o.cooldownDays; + } + + const semvers = new Set(usePatch ? ["patch"] : useMinor ? ["patch", "minor"] : ["patch", "minor", "major"]); + + entry = {useGreatest, usePre, useRel, semvers, allowDowngrade: allowDown, cooldownOverride}; versionOptsCache.set(name, entry); } return entry; @@ -735,9 +768,12 @@ export async function updates(opts: UpdatesOptions = {}): Promise { // follow-ups). findNewVersion's per-version cooldown filter handles the // common case; this catches the rest. const dropIfTooNew = (modeDeps: Deps) => { - if (!modeCooldownDays) return; + if (!modeCooldownDays && !overridesHaveCooldown) return; for (const [k, {date}] of Object.entries(modeDeps)) { - if (date && !passesCooldown(date, modeCooldownDays, now)) delete modeDeps[k]; + if (!date) continue; + const [, name] = k.split(fieldSep); + const cd = getVersionOpts(name).cooldownOverride ?? modeCooldownDays; + if (cd && !passesCooldown(date, cd, now)) delete modeDeps[k]; } }; @@ -772,14 +808,15 @@ export async function updates(opts: UpdatesOptions = {}): Promise { const [data, , registry] = info; if (data?.error) throw new Error(data.error); - const {useGreatest, usePre, useRel, semvers} = getVersionOpts(data.name); + const {useGreatest, usePre, useRel, semvers, allowDowngrade: allowDown, cooldownOverride} = getVersionOpts(data.name); const oldRange = dep.old; const oldOrig = dep.oldOrig; const pinnedRange = pin[name]; + const depCooldownDays = cooldownOverride ?? modeCooldownDays; const newVersion = findNewVersion(data, { usePre, useRel, useGreatest, semvers, range: oldRange, mode, pinnedRange, - cooldownDays: modeCooldownDays || undefined, now: modeCooldownDays ? now : undefined, - }, {allowDowngrade, matchesAny, isGoPseudoVersion}); + cooldownDays: depCooldownDays || undefined, now: depCooldownDays ? now : undefined, + }, {allowDowngrade: allowDown, matchesAny, isGoPseudoVersion}); let newRange = ""; if (["go", "pypi"].includes(mode) && newVersion) { @@ -905,9 +942,9 @@ export async function updates(opts: UpdatesOptions = {}): Promise { const tag = tagByStripped.get(picked); if (!tag) { denylist.add(picked); continue; } const commitSha = entryByName.get(tag)?.commitSha || ""; - if (!cooldownDays) return {version: picked, tag, commitSha, date: ""}; + if (!opts.cooldownDays) return {version: picked, tag, commitSha, date: ""}; const date = commitSha ? await getDate(commitSha) : ""; - if (passesCooldown(date, cooldownDays, now)) return {version: picked, tag, commitSha, date}; + if (passesCooldown(date, opts.cooldownDays, opts.now)) return {version: picked, tag, commitSha, date}; denylist.add(picked); } return null; @@ -919,10 +956,12 @@ export async function updates(opts: UpdatesOptions = {}): Promise { const actionPin = globalPin[actionName] ?? filePin[actionName]; if (isHash) { - const {usePre, useRel} = getVersionOpts(actionName); + const {usePre, useRel, cooldownOverride} = getVersionOpts(actionName); + const actionCooldownDays = cooldownOverride ?? cooldownDays; const result = await pickVersion({ range: "0.0.0", semvers: new Set(["patch", "minor", "major"]), usePre, useRel, useGreatest: true, pinnedRange: actionPin, + cooldownDays: actionCooldownDays || undefined, now: actionCooldownDays ? now : undefined, }); if (!result) { delete deps.actions[key]; return; } @@ -947,10 +986,12 @@ export async function updates(opts: UpdatesOptions = {}): Promise { const coerced = coerceToVersion(stripv(ref)); if (!coerced) { delete deps.actions[key]; return; } - const {usePre, useRel, semvers} = getVersionOpts(actionName); + const {usePre, useRel, semvers, cooldownOverride} = getVersionOpts(actionName); + const actionCooldownDays = cooldownOverride ?? cooldownDays; const result = await pickVersion({ range: coerced, semvers, usePre, useRel, useGreatest: true, pinnedRange: actionPin, + cooldownDays: actionCooldownDays || undefined, now: actionCooldownDays ? now : undefined, }); if (!result) { delete deps.actions[key]; return; } @@ -995,11 +1036,12 @@ export async function updates(opts: UpdatesOptions = {}): Promise { for (const info of infos) { const dep = deps.docker[info.key]; const oldTag = dep.oldOrig || dep.old; - const {semvers} = getVersionOpts(info.fullImage); + const {semvers, cooldownOverride} = getVersionOpts(info.fullImage); const pinnedRange = globalPin[info.fullImage] ?? info.filePin[info.fullImage]; + const dockerCooldownDays = cooldownOverride ?? cooldownDays; const result = findDockerVersion( data.tags, oldTag, semvers, - cooldownDays || undefined, cooldownDays ? now : undefined, + dockerCooldownDays || undefined, dockerCooldownDays ? now : undefined, pinnedRange, ); if (!result) { delete deps.docker[info.key]; continue; } diff --git a/config.ts b/config.ts index 67f29be5..f584ee8e 100644 --- a/config.ts +++ b/config.ts @@ -56,6 +56,8 @@ export type Config = { minor?: boolean | Array; /** Allow version downgrades when using latest version */ allowDowngrade?: boolean | Array; + /** Per-package option overrides, matched by name; last matching override wins */ + overrides?: Array; /** Opt-in to inheriting select fields from other tools' configs */ inherit?: { renovate?: { @@ -65,6 +67,28 @@ export type Config = { }; }; +/** Options applied to dependencies whose name matches an override's patterns. */ +export type Override = { + /** Name patterns this override applies to (glob or RegExp). Omit to match all. */ + include?: Array; + /** Name patterns excluded from this override */ + exclude?: Array; + /** Minimum dependency age, e.g. 7 (days), "1w", "2d", "6h"; 0 disables a global cooldown */ + cooldown?: number | string; + /** Prefer greatest over latest version */ + greatest?: boolean; + /** Consider prerelease versions */ + prerelease?: boolean; + /** Only use release versions, may downgrade */ + release?: boolean; + /** Consider only up to semver-patch */ + patch?: boolean; + /** Consider only up to semver-minor */ + minor?: boolean; + /** Allow version downgrades when using latest version */ + allowDowngrade?: boolean; +}; + export type Arg = string | boolean | Array | undefined; export const options: ParseArgsOptionsConfig = { diff --git a/index.test.ts b/index.test.ts index 032bed9c..0b178ce8 100644 --- a/index.test.ts +++ b/index.test.ts @@ -2149,6 +2149,29 @@ test("api cooldown string", async ({expect = globalExpect}: any = {}) => { expect(output.message).toBe("All dependencies are up to date."); }); +test("api overrides target a package", async ({expect = globalExpect}: any = {}) => { + const output = await updates(apiOpts({include: ["gulp-sourcemaps", "noty"], overrides: [{include: ["gulp-sourcemaps"], greatest: true}]})); + expect(output.results.npm.dependencies["gulp-sourcemaps"].new).toBe("2.6.5"); + expect(output.results.npm.dependencies.noty.new).toBe("3.1.4"); +}); + +test("api overrides per-package cooldown", async ({expect = globalExpect}: any = {}) => { + const output = await updates(apiOpts({include: ["noty", "updates"], cooldown: "999999d", overrides: [{include: ["noty"], cooldown: 0}]})); + expect(output.results.npm.dependencies.noty.new).toBe("3.1.4"); + expect(output.results.npm.dependencies.updates).toBeUndefined(); +}); + +test("api overrides exclude within a rule", async ({expect = globalExpect}: any = {}) => { + const output = await updates(apiOpts({include: ["gulp-sourcemaps", "noty"], overrides: [{exclude: ["noty"], greatest: true}]})); + expect(output.results.npm.dependencies["gulp-sourcemaps"].new).toBe("2.6.5"); + expect(output.results.npm.dependencies.noty.new).toBe("3.1.4"); +}); + +test("api overrides last match wins", async ({expect = globalExpect}: any = {}) => { + const output = await updates(apiOpts({include: ["noty"], cooldown: "999999d", overrides: [{include: ["noty"], cooldown: "999999d"}, {include: ["noty"], cooldown: 0}]})); + expect(output.results.npm.dependencies.noty.new).toBe("3.1.4"); +}); + test("api modes filter", async ({expect = globalExpect}: any = {}) => { const output = await updates(apiOpts({include: ["noty"], modes: ["pypi"]})); expect(output.message).toBe("No dependencies found, nothing to do."); From e90870662b9bca145afeed07cc6892a8250787e0 Mon Sep 17 00:00:00 2001 From: silverwind Date: Thu, 21 May 2026 04:41:15 +0200 Subject: [PATCH 2/2] Apply suggestion from @silverwind --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 05d99352..03e6f381 100644 --- a/README.md +++ b/README.md @@ -102,9 +102,9 @@ import type {Config} from "updates"; export default { cooldown: "7d", overrides: [ - {include: ["@myorg/*"], cooldown: 0}, // no cooldown for your own scope - {include: [/^@aws-sdk/], cooldown: "14d"}, // longer cooldown for a noisy publisher - {exclude: ["typescript"], greatest: true}, // greatest for everything but typescript + {include: ["@myorg/*"], cooldown: 0}, // no cooldown for your own scope + {include: [/^@aws-sdk/], cooldown: "14d"}, // longer cooldown for a noisy publisher + {exclude: ["typescript"], greatest: true}, // greatest for everything but typescript ], } satisfies Config; ```