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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
- `Cargo.toml` - rust dependencies, including workspaces
- `.{github,gitea,forgejo}/{workflows,actions}`, `workflow-templates`, `action.{yml,yaml}` - actions and docker images
- `{Docker,Container}file*`, `*.{Docker,Container}file`, `compose*.{yml,yaml}`, `docker-*.{yml,yaml}` - docker images
- `Makefile`, `*.mk` - go tool versions in `go install` paths and docker image tags
- `Makefile`, `*.mk`, `*.sh` - go tool versions in `go install` paths and docker image tags

## Usage

Expand All @@ -33,7 +33,7 @@ pnpm dlx updates -u && pnpm i
|`-f, --file <path,...>`|File or directory to use, defaults to current directory|
|`-N, --include-paths <glob,...>`|Only use paths matching the globs|
|`-X, --exclude-paths <glob,...>`|Skip paths matching the globs|
|`-M, --modes <mode,...>`|Which modes to enable. Either `npm`, `pypi`, `go`, `cargo`, `actions`, `docker`, `make`. Default: all|
|`-M, --modes <mode,...>`|Which modes to enable. Either `npm`, `pypi`, `go`, `cargo`, `actions`, `docker`, `make`, `shell`. Default: all|
|`-i, --include <dep,...>`|Include only given dependencies|
|`-e, --exclude <dep,...>`|Exclude given dependencies|
|`-l, --pin <dep=range>`|Pin dependency to given semver range|
Expand Down
62 changes: 36 additions & 26 deletions api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,10 @@ import {
} from "./modes/docker.ts";
import {
type MakeDockerImage,
isMakeFileName, makeExactFileNames, parseMakeGoInstalls, parseMakeDockerImages,
isMakeFileName, makeExactFileNames, makeAssignmentValues, parseGoInstalls, parseImages,
resolveGoModuleRoot, formatMakeImageSpec, updateMakefile,
} from "./modes/make.ts";
import {isShellFileName, shellAssignmentValues} from "./modes/shell.ts";
import {fetchCratesIoInfo, updateCargoToml, updateCargoRange, cargoToNpmRange, parseCargoLock, findLockedVersion} from "./modes/cargo.ts";
import {
baseType, filterDepsForMember, resolveWorkspaceMembers, parsePnpmWorkspace, pnpmCatalogEntries,
Expand Down Expand Up @@ -78,7 +79,7 @@ export type Output = ModeOutput & {errors?: Array<DepError>};
export type {Config, Override, Dep, Deps, DepsByMode};
export {cliBaseConfig as cliConfigBaseDir, defaultExcludePaths};

const modeOrder = [...new Set(Object.values(modeByFileName)), "actions", "docker", "make"];
const modeOrder = [...new Set(Object.values(modeByFileName)), "actions", "docker", "make", "shell"];
const defaultModes = new Set(modeOrder);

const semversByPrecision = {
Expand Down Expand Up @@ -531,6 +532,7 @@ async function runUpdates(opts: UpdatesOptions): Promise<Output> {
const filename = basename(file);
if (isDockerFileName(filename)) return enabledModes.has("docker");
if (isMakeFileName(filename)) return enabledModes.has("make");
if (isShellFileName(filename)) return enabledModes.has("shell");
return enabledModes.has(modeByFileName[filename]);
}), async (file): Promise<[string, string]> => {
try {
Expand All @@ -541,7 +543,7 @@ async function runUpdates(opts: UpdatesOptions): Promise<Output> {
}, {concurrency}));

const fileData: Record<string, {
absPath: string, content: string, fileType?: "dockerfile" | "compose" | "workflow" | "make", workflowLines?: Set<number>,
absPath: string, content: string, fileType?: "dockerfile" | "compose" | "workflow", workflowLines?: Set<number>,
}> = {};

const goModFiles: WorkspaceMember[] = [];
Expand All @@ -553,7 +555,7 @@ async function runUpdates(opts: UpdatesOptions): Promise<Output> {

const actionDepInfos: Array<ActionRef & {key: string, apiUrl: string, filters: FileFilters, comment: string}> = [];
const dockerDepInfos: Array<{key: string, ref: DockerImageRef, filters: FileFilters}> = [];
type MakeDepInfo = {key: string, name: string, oldSpec: string, filters: FileFilters, newSpec?: string} & (
type MakeDepInfo = {mode: "make" | "shell", key: string, name: string, oldSpec: string, filters: FileFilters, newSpec?: string} & (
{kind: "go", installPath: string, version: string} |
{kind: "docker", image: MakeDockerImage}
);
Expand Down Expand Up @@ -733,27 +735,34 @@ async function runUpdates(opts: UpdatesOptions): Promise<Output> {
continue;
}

if (isMakeFileName(filename)) {
const specMode = isMakeFileName(filename) ? "make" : isShellFileName(filename) ? "shell" : null;
if (specMode) {
const values = specMode === "make" ? makeAssignmentValues(content) : shellAssignmentValues(content);
const goInstalls = parseGoInstalls(values);
const images = parseImages(values);
if (!goInstalls.length && !images.length) continue;
const relPath = toRelPath(file);
const filters = await resolveDirConfig(dirname(file));
fileData[relPath] = {absPath: file, content, fileType: "make"};
deps.make ??= {};
for (const {installPath, version} of parseMakeGoInstalls(content)) {
if (!canInclude(installPath, "make", filters, "make", "go")) continue;
fileData[relPath] = {absPath: file, content};
const modeDeps = deps[specMode] ??= {};
for (const {installPath, version} of goInstalls) {
if (!canInclude(installPath, specMode, filters, specMode, "go")) continue;
const key = dependencyKey(relPath, installPath, version);
if (deps.make[key]) continue;
deps.make[key] = {old: stripv(version), oldOrig: version} as Dep;
makeDepInfos.push({kind: "go", key, name: installPath, oldSpec: `${installPath}@${version}`, installPath, version, filters});
if (modeDeps[key]) continue;
modeDeps[key] = {old: stripv(version), oldOrig: version} as Dep;
makeDepInfos.push({
mode: specMode, kind: "go", key, name: installPath, oldSpec: `${installPath}@${version}`, installPath, version, filters,
});
}
for (const image of parseMakeDockerImages(content)) {
if (!canInclude(image.writtenImage, "make", filters, "make", "docker")) continue;
for (const image of images) {
if (!canInclude(image.writtenImage, specMode, filters, specMode, "docker")) continue;
const key = dependencyKey(relPath, image.writtenImage, image.ref.tag);
if (deps.make[key]) continue;
if (modeDeps[key]) continue;
const parsed = parseDockerTag(image.ref.tag);
if (!parsed) continue;
const oldSpec = formatMakeImageSpec(image.writtenImage, image.ref.tag, image.digest);
deps.make[key] = {old: parsed.version, oldOrig: image.ref.tag} as Dep;
makeDepInfos.push({kind: "docker", key, name: image.writtenImage, oldSpec, image, filters});
modeDeps[key] = {old: parsed.version, oldOrig: image.ref.tag} as Dep;
makeDepInfos.push({mode: specMode, kind: "docker", key, name: image.writtenImage, oldSpec, image, filters});
}
continue;
}
Expand Down Expand Up @@ -1190,20 +1199,21 @@ async function runUpdates(opts: UpdatesOptions): Promise<Output> {

const makeTask = pMap(makeDepInfos, async (info) => {
const opts = resolveVersionOpts(info.filters, info.kind, info.name, info.name);
const dep = deps.make[info.key];
const modeDeps = deps[info.mode];
const dep = modeDeps[info.key];
try {
if (info.kind === "go") {
const {projectDir} = info.filters;
const modulePath = await resolveGoModuleRoot(info.installPath, projectDir, ctx, goNoProxy);
if (!modulePath) { delete deps.make[info.key]; return; }
if (!modulePath) { delete modeDeps[info.key]; return; }
const [rawData] = await fetchGoProxyInfo(modulePath, "tool", stripv(info.version), projectDir, ctx, goNoProxy);
const data = filterVersionData(rawData, "go", opts.allowedVersions);
const newVersion = findNewVersion(data, {...opts, mode: "go", range: stripv(info.version)});
if (!newVersion) { delete deps.make[info.key]; return; }
if (!newVersion) { delete modeDeps[info.key]; return; }
const newModulePath = data.newPath ?? goModulePathForVersion(modulePath, newVersion);
const newInstallPath = `${newModulePath}${info.installPath.slice(modulePath.length)}`;
const formattedVersion = formatVersionPrecision(newVersion, info.version);
if (newInstallPath === info.installPath && formattedVersion === info.version) { delete deps.make[info.key]; return; }
if (newInstallPath === info.installPath && formattedVersion === info.version) { delete modeDeps[info.key]; return; }
info.newSpec = `${newInstallPath}@${formattedVersion}`;
dep.new = formattedVersion;
dep.info = getGoInfoUrl(newModulePath);
Expand All @@ -1214,17 +1224,17 @@ async function runUpdates(opts: UpdatesOptions): Promise<Output> {
filterVersionData(data, "docker", opts.allowedVersions).tags, info.image.ref.tag, opts.semvers, opts.cooldownDays,
opts.now, opts.pinnedRange, opts.usePre, opts.useRel,
);
if (!dockerUpdate) { delete deps.make[info.key]; return; }
if (!dockerUpdate) { delete modeDeps[info.key]; return; }
const newDigest = info.image.digest ?
await resolveDockerTagDigest(info.image.ref.namespace, info.image.ref.repo, dockerUpdate.newTag) : null;
if (info.image.digest && !newDigest) { delete deps.make[info.key]; return; }
if (info.image.digest && !newDigest) { delete modeDeps[info.key]; return; }
info.newSpec = formatMakeImageSpec(info.image.writtenImage, dockerUpdate.newTag, newDigest);
dep.new = dockerUpdate.newTag;
dep.info = getDockerInfoUrl(info.image.ref);
setDepAge(dep, dockerUpdate.date);
}
} catch (err) {
rejectDep("make", info.key, err);
rejectDep(info.mode, info.key, err);
}
}, {concurrency});

Expand Down Expand Up @@ -1288,8 +1298,8 @@ async function runUpdates(opts: UpdatesOptions): Promise<Output> {
workflowLines!.has(lineNumber) ? updateWorkflowDockerImages(line, workflowDeps) : line).join("\n");
write(absPath, updateFn(content, Object.fromEntries(entries)));
}
} else if (mode === "make") {
const makeUpdates = makeDepInfos.filter(info => info.newSpec && deps.make[info.key]);
} else if (mode === "make" || mode === "shell") {
const makeUpdates = makeDepInfos.filter(info => info.newSpec && deps[mode][info.key]);
for (const [relPath, infos] of Map.groupBy(makeUpdates, info => info.key.split(fieldSep)[0])) {
const {absPath, content} = fileData[relPath];
write(absPath, updateMakefile(content, infos.map(info => ({oldSpec: info.oldSpec, newSpec: info.newSpec!}))));
Expand Down
3 changes: 2 additions & 1 deletion cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ export async function runCli(rawArgs: Array<string>, io: CliIo, prewarm = true):
-f, --file <path,...> File or directory to use, defaults to current directory
-N, --include-paths <glob,...> Only use paths matching the globs
-X, --exclude-paths <glob,...> Skip paths matching the globs
-M, --modes <mode,...> Which modes to enable. Default: npm,pypi,go,cargo,actions,docker,make
-M, --modes <mode,...> Which modes to enable. Default: npm,pypi,go,cargo,actions,docker,make,shell
-i, --include <dep,...> Include only given dependencies
-e, --exclude <dep,...> Exclude given dependencies
-l, --pin <dep=range> Pin dependency to given semver range
Expand Down Expand Up @@ -229,6 +229,7 @@ export async function runCli(rawArgs: Array<string>, io: CliIo, prewarm = true):
$ updates -f Dockerfile
$ updates -f docker-compose.yml
$ updates -f Makefile
$ updates -f build.sh
`);
return 0;
}
Expand Down
35 changes: 35 additions & 0 deletions index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ beforeAll(async () => {
["/docker/v2/repositories/library/redis/tags", fixtureText("docker/redis-tags.json")],
["/docker/v2/repositories/koalaman/shellcheck/tags", dockerTags(["v0.11.0", "2025-01-01T00:00:00Z"], ["v0.12.0", "2025-06-01T00:00:00Z"])],
["/docker/v2/repositories/koalaman/shellcheck/tags/v0.12.0", JSON.stringify({digest: "sha256:list-new"})],
["/docker/v2/repositories/example/image/tags", dockerTags(["1.0", "2025-01-01T00:00:00Z"], ["1.1", "2025-06-01T00:00:00Z"])],
["/docker/v2/repositories/example/makeallowed/tags",
dockerTags(["1.0", "2025-01-01T00:00:00Z"], ["1.1", "2025-03-01T00:00:00Z"], ["2.0", "2025-06-01T00:00:00Z"])],
["/cargo/se/rd/serde", fixtureText("cargo/serde-index.ndjson")],
Expand Down Expand Up @@ -157,6 +158,7 @@ beforeAll(async () => {
const gz = await promisify(gzip)(await body, gzipOptions);
routes.set(path, (_, res) => res.send(gz));
}));
routes.set("/docker/v2/repositories/example/broken/tags", (_, res) => { res.writeHead(500).end(); });
routes.set("/github/user", (req, res) => {
res.writeHead(req.headers.authorization === "Bearer tok" ? 200 : 401).end(JSON.stringify({login: "someone"}));
});
Expand Down Expand Up @@ -541,6 +543,39 @@ test("make mode bumps docker image tags and re-resolves digests in Makefiles", a
));
});

test("make and shell modes bump discovered files alike, leaving current, unresolvable, excluded and failed values untouched", async () => {
const digest = `sha256:${"a".repeat(64)}`;
const tools = [
"github.com/example/testpkg/v2@v2.0.0", "github.com/example/pseudoupd@v1", "github.com/example/missing@v1.0.0",
"github.com/example/excluded@v1.0.0",
].join(" ");
const images = `example/makeallowed:1.0@${digest} example/broken:1.0`;
const dir = writeTree("shell", {
"Makefile": lines("TOOL ?= github.com/example/testpkg@v1.0.0", "IMAGE := example/image:1.0"),
"build.sh": lines(
`TOOL="\${TOOL:-github.com/example/testpkg@v1.0.0}"`,
"export IMAGE=example/image:1.0",
`TOOLS=(github.com/example/testpkg@v1.0.0 ${tools})`,
`IMAGES=(example/image:1.0 ${images})`,
),
"noop.sh": "echo done\n",
});
const {results, errors} = await updates({
files: [dir], modes: ["make", "shell"], exclude: ["github.com/example/excluded"], goproxy: goProxyUrl, dockerapi: dockerUrl,
update: true, color: false, noCache: true,
});
expect(Object.fromEntries(Object.entries(results).map(([mode, files]) => [mode, Object.keys(files).map(file => basename(file))])))
.toEqual({make: ["Makefile"], shell: ["build.sh"]});
expect(errors!.map(({mode, name}) => [mode, name])).toEqual([["shell", "example/broken"]]);
expect(read(dir, "Makefile")).toBe(lines("TOOL ?= github.com/example/testpkg/v2@v2.0.0", "IMAGE := example/image:1.1"));
expect(read(dir, "build.sh")).toBe(lines(
`TOOL="\${TOOL:-github.com/example/testpkg/v2@v2.0.0}"`,
"export IMAGE=example/image:1.1",
`TOOLS=(github.com/example/testpkg/v2@v2.0.0 ${tools})`,
`IMAGES=(example/image:1.1 ${images})`,
));
});

test("make allowedVersions falls back to the highest allowed candidate", async () => {
const dir = writeTree("make-allowed", {
"Makefile": lines("TOOL := github.com/example/makeallowed/cmd/tool@v1.0.0", "IMAGE := example/makeallowed:1.0"),
Expand Down
17 changes: 11 additions & 6 deletions modes/make.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import {
isMakeFileName,
parseMakeGoInstalls,
makeAssignmentValues,
parseGoInstalls,
parseImages,
parseMakeImageValue,
parseMakeDockerImages,
resolveGoModuleRoot,
updateMakefile,
} from "./make.ts";
Expand All @@ -29,8 +30,8 @@ test("isMakeFileName matches make filenames", () => {
.toEqual([true, true, true, true, false, false]);
});

test("parseMakeGoInstalls extracts go install specs across assignment operators", () => {
expect(parseMakeGoInstalls(sample)).toEqual([
test("parseGoInstalls extracts go install specs across make assignment operators", () => {
expect(parseGoInstalls(makeAssignmentValues(sample))).toEqual([
{installPath: "github.com/golangci/golangci-lint/v2/cmd/golangci-lint", version: "v2.12.2"},
{installPath: "github.com/air-verse/air", version: "v1.65.1"},
{installPath: "github.com/go-delve/delve/cmd/dlv", version: "v1"},
Expand Down Expand Up @@ -84,6 +85,10 @@ test.each([
["does not match inside a registry prefix", "PREFIXED := docker.io/koalaman/shellcheck:v0.11.0\n",
[{oldSpec: "koalaman/shellcheck:v0.11.0", newSpec: "koalaman/shellcheck:v0.12.0"}],
"PREFIXED := docker.io/koalaman/shellcheck:v0.11.0\n"],
["rewrites specs delimited by shell syntax",
`A="\${A:-org/app:1.0}"\nB=(org/app:1.0)\nC=org/app:1.0;\n`,
[{oldSpec: "org/app:1.0", newSpec: "org/app:1.1"}],
`A="\${A:-org/app:1.1}"\nB=(org/app:1.1)\nC=org/app:1.1;\n`],
["rewrites tag and digest", "SHELLCHECK_IMAGE ?= docker.io/koalaman/shellcheck:v0.11.0@sha256:aaa # renovate: datasource=docker\n",
[{oldSpec: "docker.io/koalaman/shellcheck:v0.11.0@sha256:aaa", newSpec: "docker.io/koalaman/shellcheck:v0.12.0@sha256:bbb"}],
"SHELLCHECK_IMAGE ?= docker.io/koalaman/shellcheck:v0.12.0@sha256:bbb # renovate: datasource=docker\n"],
Expand Down Expand Up @@ -238,7 +243,7 @@ test("parseMakeImageValue parses a Hub image with registry prefix and digest", (
expect(["mysql:3306", "golang:1.21", "ghcr.io/foo/bar:1.2.3", "plain-no-tag"].map(parseMakeImageValue)).toEqual([null, null, null, null]);
});

test("parseMakeDockerImages extracts only namespaced Hub images, skipping comments", () => {
test("parseImages extracts only namespaced Hub images from make assignments, skipping comments", () => {
const content = [
`SHELLCHECK_IMAGE ?= docker.io/koalaman/shellcheck:v0.11.0@${digestA} # renovate: datasource=docker`,
"PLAIN := koalaman/shellcheck:0.9.0",
Expand All @@ -247,7 +252,7 @@ test("parseMakeDockerImages extracts only namespaced Hub images, skipping commen
"MYSQL_HOST ?= mysql:3306",
`# DISABLED := koalaman/shellcheck:0.1.0@${digestB}`,
].join("\n");
expect(parseMakeDockerImages(content).map(i => ({image: i.writtenImage, tag: i.ref.tag, digest: i.digest}))).toEqual([
expect(parseImages(makeAssignmentValues(content)).map(i => ({image: i.writtenImage, tag: i.ref.tag, digest: i.digest}))).toEqual([
{image: "docker.io/koalaman/shellcheck", tag: "v0.11.0", digest: digestA},
{image: "koalaman/shellcheck", tag: "0.9.0", digest: null},
{image: "koalaman/shellcheck", tag: "0.10.0", digest: null},
Expand Down
Loading
Loading