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
48 changes: 30 additions & 18 deletions api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ export async function updates(opts: UpdatesOptions = {}): Promise<Output> {

const include = patternsToRegexSet(config.include ?? []);
const exclude = patternsToRegexSet(config.exclude ?? []);
const configPin: Record<string, string> = config.pin ?? {};
const globalPin: Record<string, string> = config.pin ?? {};

const deps: DepsByMode = {};
const maybeUrlDeps: Deps = {};
Expand Down Expand Up @@ -362,18 +362,18 @@ export async function updates(opts: UpdatesOptions = {}): Promise<Output> {
const pnpmMemberFiles: WorkspaceMember[] = [];
let pnpmWorkspaceActive = false;

type ActionDepInfo = ActionRef & {key: string, apiUrl: string};
type ActionDepInfo = ActionRef & {key: string, apiUrl: string, filePin: Record<string, string>};
const actionDepInfos: Array<ActionDepInfo> = [];
type DockerDepInfo = {key: string, fullImage: string, ref: DockerImageRef};
type DockerDepInfo = {key: string, fullImage: string, ref: DockerImageRef, filePin: Record<string, string>};
const dockerDepInfos: Array<DockerDepInfo> = [];
type ModeCtx = {modeConfig: Config, projectDir: string, pin: Record<string, string>};
const modeConfigs: Record<string, ModeCtx> = {};

async function resolveModeFilters(projectDir: string) {
const modeConfig = projectDir === cwd() ? config : await loadConfig(projectDir);
const modeInclude = modeConfig !== config && modeConfig?.include ? patternsToRegexSet([...(config.include ?? []), ...modeConfig.include]) : include;
const modeExclude = modeConfig !== config && modeConfig?.exclude ? patternsToRegexSet([...(config.exclude ?? []), ...modeConfig.exclude]) : exclude;
const pin: Record<string, string> = {...modeConfig?.pin, ...configPin};
const modeConfig = await loadConfig(projectDir);
const modeInclude = modeConfig.include?.length ? patternsToRegexSet([...(config.include ?? []), ...modeConfig.include]) : include;
const modeExclude = modeConfig.exclude?.length ? patternsToRegexSet([...(config.exclude ?? []), ...modeConfig.exclude]) : exclude;
const pin: Record<string, string> = {...modeConfig.pin, ...globalPin};
return {modeConfig, modeInclude, modeExclude, pin};
}

Expand All @@ -387,21 +387,30 @@ export async function updates(opts: UpdatesOptions = {}): Promise<Output> {
return [];
}

function collectDockerRefs(content: string, relPath: string, regexes: Array<RegExp>): void {
type FileFilters = {include: Set<RegExp>, exclude: Set<RegExp>, pin: Record<string, string>};

function collectDockerRefs(content: string, relPath: string, regexes: Array<RegExp>, filters: FileFilters): void {
deps.docker ??= {};
for (const regex of regexes) {
for (const {ref} of extractDockerRefs(content, regex)) {
if (!canInclude(ref.fullImage, "docker", include, exclude, "docker")) continue;
if (!canInclude(ref.fullImage, "docker", filters.include, filters.exclude, "docker")) continue;
const key = `${relPath}${fieldSep}${ref.fullImage}`;
if (deps.docker[key]) continue;
const parsed = parseDockerTag(ref.tag);
if (!parsed) continue;
deps.docker[key] = {old: parsed.version, oldOrig: ref.tag} as Dep;
dockerDepInfos.push({key, fullImage: ref.fullImage, ref});
dockerDepInfos.push({key, fullImage: ref.fullImage, ref, filePin: filters.pin});
}
}
}

async function resolveFileConfig(fileDir: string): Promise<FileFilters> {
const cfg = await loadConfig(fileDir);
const inc = cfg.include?.length ? patternsToRegexSet([...(config.include ?? []), ...cfg.include]) : include;
const exc = cfg.exclude?.length ? patternsToRegexSet([...(config.exclude ?? []), ...cfg.exclude]) : exclude;
return {include: inc, exclude: exc, pin: cfg.pin ?? {}};
}

for (const file of files) {
if (isWorkflowFile(file)) {
const actionsEnabled = enabledModes.has("actions");
Expand All @@ -410,23 +419,24 @@ export async function updates(opts: UpdatesOptions = {}): Promise<Output> {

const content = fileContents.get(file)!;
const relPath = toRelPath(file);
const filters = await resolveFileConfig(dirname(file));
wfData[relPath] = {absPath: file, content};

if (actionsEnabled) {
deps.actions ??= {};
const actions = Array.from(content.matchAll(actionsUsesRe), m => parseActionRef(m[1])).filter(a => a !== null);
for (const action of actions) {
if (!canInclude(action.name, "actions", include, exclude, "actions")) continue;
if (!canInclude(action.name, "actions", filters.include, filters.exclude, "actions")) continue;
const key = `${relPath}${fieldSep}${action.name}`;
if (deps.actions[key]) continue;
deps.actions[key] = {old: action.ref} as Dep;
actionDepInfos.push({...action, key, apiUrl: getForgeApiBaseUrl(action.host, forgeApiUrl)});
actionDepInfos.push({...action, key, apiUrl: getForgeApiBaseUrl(action.host, forgeApiUrl), filePin: filters.pin});
}
}

if (dockerEnabled) {
dockerFileData[relPath] = {absPath: file, content, fileType: "workflow"};
collectDockerRefs(content, relPath, [composeImageRe, workflowContainerRe, workflowDockerUsesRe]);
collectDockerRefs(content, relPath, [composeImageRe, workflowContainerRe, workflowDockerUsesRe], filters);
}
continue;
}
Expand All @@ -438,8 +448,9 @@ export async function updates(opts: UpdatesOptions = {}): Promise<Output> {
const content = fileContents.get(file)!;
const relPath = toRelPath(file);
const fileType = isDockerfile(filename) ? "dockerfile" : "compose";
const filters = await resolveFileConfig(dirname(file));
dockerFileData[relPath] = {absPath: file, content, fileType};
collectDockerRefs(content, relPath, [getExtractionRegex(filename)]);
collectDockerRefs(content, relPath, [getExtractionRegex(filename)], filters);
continue;
}

Expand Down Expand Up @@ -901,15 +912,16 @@ export async function updates(opts: UpdatesOptions = {}): Promise<Output> {
return null;
}

await pMap(infos, async ({key, host, ref, name: actionName, isHash}) => {
await pMap(infos, async ({key, host, ref, name: actionName, isHash, filePin}) => {
const dep = deps.actions[key];
const infoUrl = `https://${host || "github.com"}/${owner}/${repo}`;
const actionPin = globalPin[actionName] ?? filePin[actionName];

if (isHash) {
const {usePre, useRel} = getVersionOpts(actionName);
const result = await pickVersion({
range: "0.0.0", semvers: new Set(["patch", "minor", "major"]), usePre, useRel,
useGreatest: true, pinnedRange: configPin[actionName],
useGreatest: true, pinnedRange: actionPin,
});
if (!result) { delete deps.actions[key]; return; }

Expand Down Expand Up @@ -937,7 +949,7 @@ export async function updates(opts: UpdatesOptions = {}): Promise<Output> {
const {usePre, useRel, semvers} = getVersionOpts(actionName);
const result = await pickVersion({
range: coerced, semvers, usePre, useRel,
useGreatest: true, pinnedRange: configPin[actionName],
useGreatest: true, pinnedRange: actionPin,
});
if (!result) { delete deps.actions[key]; return; }

Expand Down Expand Up @@ -983,7 +995,7 @@ export async function updates(opts: UpdatesOptions = {}): Promise<Output> {
const dep = deps.docker[info.key];
const oldTag = dep.oldOrig || dep.old;
const {semvers} = getVersionOpts(info.fullImage);
const pinnedRange = configPin[info.fullImage];
const pinnedRange = globalPin[info.fullImage] ?? info.filePin[info.fullImage];
const result = findDockerVersion(
data.tags, oldTag, semvers,
cooldownDays || undefined, cooldownDays ? now : undefined,
Expand Down
68 changes: 34 additions & 34 deletions config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {pathToFileURL} from "node:url";
import {access} from "node:fs/promises";
import type {ParseArgsOptionsConfig} from "node:util";
import {validRange} from "./utils/semver.ts";
import {commaSeparatedToArray, esc} from "./utils/utils.ts";
import {commaSeparatedToArray, esc, walkUp, memoizeAsync} from "./utils/utils.ts";

export type Config = {
/** Array of dependencies to include */
Expand Down Expand Up @@ -171,40 +171,40 @@ export function configMixedToRegexes(val: boolean | Array<string | RegExp> | und
return ret;
}

export async function loadConfig(rootDir: string): Promise<Config> {
const filenames: Array<string> = [];
for (const ext of ["js", "ts", "mjs", "mts"]) {
filenames.push(`updates.config.${ext}`);
}
let config: Config = {};

try {
({default: config} = await Promise.any(filenames.map(async (filename) => {
const fullPath = join(rootDir, ...filename.split("/"));
const fileUrl = pathToFileURL(fullPath);

try {
await access(fullPath);
} catch {
throw new Error(`File not found: ${filename}`);
}

try {
return await import(fileUrl.href);
} catch (err: any) {
throw new Error(`Unable to parse config file ${filename}: ${err.message}`);
}
})));
} catch (err) {
if (err instanceof AggregateError) {
const parseErrors = err.errors.filter((e: Error) => e.message.startsWith("Unable to parse"));
if (parseErrors.length > 0) {
throw parseErrors[0];
}
type FoundConfig = {configDir: string, filename: string, default: Config};

// Try to load any updates.config.* in dir. Returns the first that imports
// successfully. If none imports but at least one parsed-and-failed, throws
// the first parse error so a broken sibling next to a valid one does not
// block the valid one.
async function tryLoadInDir(dir: string): Promise<FoundConfig | null> {
const exts = ["js", "ts", "mjs", "mts"];
const results = await Promise.all(exts.map(async (ext): Promise<FoundConfig | Error | null> => {
const filename = `updates.config.${ext}`;
const fullPath = join(dir, filename);
try {
await access(fullPath);
} catch {
return null;
}
}
try {
const mod = await import(pathToFileURL(fullPath).href);
return {configDir: dir, filename, default: mod.default ?? {}};
} catch (err: any) {
return new Error(`Unable to parse config file ${filename}: ${err?.message ?? err}`);
}
}));
for (const r of results) if (r && !(r instanceof Error)) return r;
for (const r of results) if (r instanceof Error) throw r;
return null;
}

const findConfigUp = memoizeAsync((startDir: string) => walkUp(startDir, tryLoadInDir));

export async function loadConfig(startDir: string): Promise<Config> {
const found = await findConfigUp(startDir);
const raw: Config = found?.default ?? {};
const {loadRenovateConfig} = await import("./utils/renovate.ts");
const renovateConfig = await loadRenovateConfig(rootDir, config.inherit?.renovate);
return {...renovateConfig, ...config};
const renovateConfig = await loadRenovateConfig(found?.configDir ?? startDir, raw.inherit?.renovate);
return {...renovateConfig, ...raw};
}
19 changes: 16 additions & 3 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/usr/bin/env node
import {argv, cwd, stdout, stderr, exit, platform, versions} from "node:process";
import {stripVTControlCharacters, styleText, parseArgs} from "node:util";
import {dirname, isAbsolute, resolve} from "node:path";
import {statSync} from "node:fs";
import {updates} from "./api.ts";
import {options, parseMixedArg, getOptionKey, parseArgList, parsePinArg, loadConfig} from "./config.ts";
import {packageVersion, fetchTimeout} from "./modes/shared.ts";
Expand Down Expand Up @@ -48,6 +50,14 @@ function argToConfigMixed(arg: Arg): boolean | Array<string | RegExp> | undefine
let red: (text: string | number) => string = String;
let green: (text: string | number) => string = String;

function deriveStartDir(first: string | undefined): string {
if (!first) return cwd();
const abs = isAbsolute(first) ? first : resolve(cwd(), first);
let isDir = false;
try { isDir = statSync(abs).isDirectory(); } catch {}
return isDir ? abs : dirname(abs);
}

function resolveColor(fileConfig: UpdatesOptions): boolean {
if (args["no-color"] === true) return false;
if (args.color === true) return true;
Expand Down Expand Up @@ -134,14 +144,19 @@ async function main(): Promise<void> {
await end();
}

const fileConfig = await loadConfig(cwd());
const fileSet = parseMixedArg(args.file);
const filesList = [...(fileSet instanceof Set ? fileSet : []), ...positionals];
const startDir = deriveStartDir(filesList[0]);

const fileConfig = await loadConfig(startDir);
const useColor = resolveColor(fileConfig);
if (useColor) {
red = (text: string | number) => styleText("red", String(text));
green = (text: string | number) => styleText("green", String(text));
}

const config: UpdatesOptions = {...fileConfig};
config.pin = undefined;
if (args.json) config.json = true;
if (args.verbose) config.verbose = true;
if (args["no-cache"]) config.noCache = true;
Expand Down Expand Up @@ -175,8 +190,6 @@ async function main(): Promise<void> {
const allowDowngrade = argToConfigMixed(args["allow-downgrade"]);
if (allowDowngrade !== undefined) config.allowDowngrade = allowDowngrade;

const fileSet = parseMixedArg(args.file);
const filesList = [...(fileSet instanceof Set ? fileSet : []), ...positionals];
if (filesList.length) config.files = filesList;

for (const key of ["forgeapi", "pypiapi", "jsrapi", "goproxy", "cargoapi", "dockerapi"] as const) {
Expand Down
19 changes: 14 additions & 5 deletions utils/renovate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {join} from "node:path";
import {readFile} from "node:fs/promises";
import {parseJsonish} from "./json5.ts";
import {validRange} from "./semver.ts";
import {walkUp, memoizeAsync} from "./utils.ts";
import type {Config} from "../config.ts";

const forgeDirs = [".github", ".gitea", ".forgejo", ".gitlab"];
Expand Down Expand Up @@ -136,15 +137,23 @@ function normalize(raw: RenovateConfig, opts: RenovateImportOptions): Partial<Co
return out;
}

export async function loadRenovateConfig(rootDir: string, opts: RenovateImportOptions = {}): Promise<Partial<Config>> {
const found = await readFirstExisting(rootDir);
if (!found) return {};
type RenovateRaw = {parsed: RenovateConfig, path: string};

const findRenovateUp = memoizeAsync((startDir: string) => walkUp(startDir, async (dir): Promise<RenovateRaw | null> => {
const found = await readFirstExisting(dir);
if (!found) return null;
let raw: unknown;
try {
raw = parseJsonish(found.text);
} catch (err: any) {
throw new Error(`Unable to parse renovate config ${found.path}: ${err.message}`);
}
if (!raw || typeof raw !== "object") return {};
return normalize(raw as RenovateConfig, opts);
if (!raw || typeof raw !== "object") return null;
return {parsed: raw as RenovateConfig, path: found.path};
}));

export async function loadRenovateConfig(rootDir: string, opts: RenovateImportOptions = {}): Promise<Partial<Config>> {
const found = await findRenovateUp(rootDir);
if (!found) return {};
return normalize(found.parsed, opts);
}
25 changes: 25 additions & 0 deletions utils/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {dirname} from "node:path";

export function highlightDiff(a: string, b: string, colorFn: (str: string) => string): string {
if (a === b) return a;
let i = 0;
Expand Down Expand Up @@ -155,3 +157,26 @@ export async function pMap<T, R>(iterable: Iterable<T>, mapper: (item: T) => Pro
}

export const esc = (str: string) => str.replace(/[|\\{}()[\]^$+*?.-]/g, "\\$&");

export async function walkUp<T>(startDir: string, probe: (dir: string) => Promise<T | null>): Promise<T | null> {
let dir = startDir;
while (true) {
const found = await probe(dir);
if (found) return found;
const parent = dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}

export function memoizeAsync<K, V>(fn: (k: K) => Promise<V>): (k: K) => Promise<V> {
const cache = new Map<K, Promise<V>>();
return (k) => {
let p = cache.get(k);
if (!p) {
p = fn(k);
cache.set(k, p);
}
return p;
};
}