Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,30 @@ export default {
- `patch` *boolean | Array\<string | RegExp>*: Consider only up to semver-patch
- `minor` *boolean | Array\<string | RegExp>*: Consider only up to semver-minor
- `allowDowngrade` *boolean | Array\<string | RegExp>*: Allow version downgrades
- `overrides` *Array\<Override>*: 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:
Expand Down
94 changes: 68 additions & 26 deletions api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string> = {
"pnpm-workspace.yaml": "npm",
Expand Down Expand Up @@ -298,26 +298,59 @@ export async function updates(opts: UpdatesOptions = {}): Promise<Output> {
const allowDowngrade = configMixedToRegexes(config.allowDowngrade);
const enabledModes = config.modes?.length ? new Set(config.modes) : defaultModes;

type CompiledOverride = {
include?: Set<RegExp>, exclude?: Set<RegExp>,
greatest?: boolean, prerelease?: boolean, release?: boolean,
patch?: boolean, minor?: boolean, allowDowngrade?: boolean, cooldownDays?: number,
};
const compiledOverrides: Array<CompiledOverride> = (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<string> {
if (patch === true || matchesAny(name, patch)) return new Set<string>(["patch"]);
if (minor === true || matchesAny(name, minor)) return new Set<string>(["patch", "minor"]);
return new Set<string>(["patch", "minor", "major"]);
}

const versionOptsCache = new Map<string, {useGreatest: boolean, usePre: boolean, useRel: boolean, semvers: Set<string>}>();
const versionOptsCache = new Map<string, {useGreatest: boolean, usePre: boolean, useRel: boolean, semvers: Set<string>, 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<string>(usePatch ? ["patch"] : useMinor ? ["patch", "minor"] : ["patch", "minor", "major"]);

entry = {useGreatest, usePre, useRel, semvers, allowDowngrade: allowDown, cooldownOverride};
versionOptsCache.set(name, entry);
}
return entry;
Expand Down Expand Up @@ -735,9 +768,12 @@ export async function updates(opts: UpdatesOptions = {}): Promise<Output> {
// 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];
}
};

Expand Down Expand Up @@ -772,14 +808,15 @@ export async function updates(opts: UpdatesOptions = {}): Promise<Output> {
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) {
Expand Down Expand Up @@ -905,9 +942,9 @@ export async function updates(opts: UpdatesOptions = {}): Promise<Output> {
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;
Expand All @@ -919,10 +956,12 @@ export async function updates(opts: UpdatesOptions = {}): Promise<Output> {
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; }

Expand All @@ -947,10 +986,12 @@ export async function updates(opts: UpdatesOptions = {}): Promise<Output> {
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; }

Expand Down Expand Up @@ -995,11 +1036,12 @@ export async function updates(opts: UpdatesOptions = {}): Promise<Output> {
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; }
Expand Down
24 changes: 24 additions & 0 deletions config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export type Config = {
minor?: boolean | Array<string | RegExp>;
/** Allow version downgrades when using latest version */
allowDowngrade?: boolean | Array<string | RegExp>;
/** Per-package option overrides, matched by name; last matching override wins */
overrides?: Array<Override>;
/** Opt-in to inheriting select fields from other tools' configs */
inherit?: {
renovate?: {
Expand All @@ -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<string | RegExp>;
/** Name patterns excluded from this override */
exclude?: Array<string | RegExp>;
/** 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<string | boolean> | undefined;

export const options: ParseArgsOptionsConfig = {
Expand Down
23 changes: 23 additions & 0 deletions index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
Loading