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
2 changes: 1 addition & 1 deletion api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -758,7 +758,7 @@ async function runUpdates(opts: UpdatesOptions): Promise<Output> {
fileData[relPath] = {absPath: file, content, fileType: "workflow", workflowLines};
const yamlPath: Array<{indent: number, key: string}> = [];

for (const [lineNumber, line] of content.split("\n").entries()) {
for (const [lineNumber, line] of content.split(/\r?\n/).entries()) {
if (actionsEnabled) {
const parsed = parseUsesLine(line);
const action = parsed && parseActionRef(parsed.value);
Expand Down
11 changes: 11 additions & 0 deletions cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,15 @@ test("recovers swallowed short option clusters", () => {
expect(args.update).toBe(true);
expect(args.greatest).toEqual(["react"]);
expect(positionals).toEqual(["package.json"]);

const ordered = parseCliArgs([
"-g", "-ulreact=*", "-l", "react=<19",
"-g", "-uT1000", "-T", "2000",
"-g", "-ufcluster.json", "-f", "explicit.json",
"package.json",
]);
expect(ordered.args.pin).toEqual(["react=*", "react=<19"]);
expect(ordered.args.timeout).toBe("2000");
expect(ordered.args.file).toEqual(["cluster.json", "explicit.json"]);
expect(ordered.positionals).toEqual(["package.json"]);
});
29 changes: 22 additions & 7 deletions cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,21 @@ export function parseCliArgs(argv?: Array<string>): {args: Record<string, Arg>,
...(argv !== undefined && {args: argv}),
});

const values = result.values as Record<string, Arg>;
const values = Object.create(null) as Record<string, Arg>;
const consumedPositionals = new Set<number>();
let positionalsSeen = 0;
for (const [index, token] of result.tokens.entries()) {
if (token.kind === "positional") positionalsSeen++;
if (token.kind !== "option" || token.inlineValue || !token.value?.startsWith("-")) continue;
if (token.kind !== "option") continue;
if (token.inlineValue || !token.value?.startsWith("-")) {
if (options[token.name]?.multiple) {
const list = (values[token.name] ??= []) as Array<string | boolean>;
list.push(token.value ?? true);
} else {
values[token.name] = token.value ?? true;
}
continue;
}
const longOption = token.value.startsWith("--");
const next = result.tokens[index + 1];
const nextPositional = next?.kind === "positional" ? next.value : undefined;
Expand Down Expand Up @@ -77,11 +86,17 @@ export function parseCliArgs(argv?: Array<string>): {args: Record<string, Arg>,
}
}
}
if (!recoveredOptions.length) continue;
const swallowed = values[token.name];
if (Array.isArray(swallowed)) {
const position = swallowed.indexOf(token.value);
if (position !== -1) swallowed.splice(position, 1);
if (!recoveredOptions.length) {
if (options[token.name]?.multiple) {
const list = (values[token.name] ??= []) as Array<string | boolean>;
list.push(token.value);
} else {
values[token.name] = token.value;
}
continue;
}
if (options[token.name]?.multiple) {
values[token.name] ??= [];
} else {
values[token.name] = true;
}
Expand Down
41 changes: 38 additions & 3 deletions index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ const pnpmWorkspaceFile = fileURLToPath(new URL("fixtures/pnpm-workspace/pnpm-wo

const testPkg = JSON.parse(readFileSync(testFile, "utf8"));
const testDir = mkdtempSync(join(tmpdir(), "updates-"));
const sourceScript = fileURLToPath(new URL("index.ts", import.meta.url));
const script = fileURLToPath(new URL("dist/index.js", import.meta.url));

// An awaiting handler holds the request open, which the client sees as a response that never comes.
Expand Down Expand Up @@ -457,6 +458,26 @@ test("version info fallback", async ({expect = globalExpect}: any = {}) => {
expect(noty.age).toBeTruthy();
});

test("version resolves the source and built package layouts", async ({expect = globalExpect}: any = {}) => {
const parentDir = join(testDir, "version-layouts");
const sourceDir = join(parentDir, "updates");
const distDir = join(sourceDir, "dist");
mkdirSync(distDir, {recursive: true});
const source = await readFile(sourceScript, "utf8");
await Promise.all([
writeFile(join(parentDir, "package.json"), JSON.stringify({version: "wrong"})),
writeFile(join(sourceDir, "package.json"), JSON.stringify({version: "1.2.3", type: "module"})),
writeFile(join(sourceDir, "index.ts"), source),
writeFile(join(distDir, "index.ts"), source),
]);

for (const entry of [join(sourceDir, "index.ts"), join(distDir, "index.ts")]) {
const {stdout, stderr} = await execFileAsync(execPath, [entry, "--version"]);
expect(stderr).toEqual("");
expect(stdout).toBe("1.2.3\n");
}
});

test("empty", async ({expect = globalExpect}: any = {}) => {
const {stdout, stderr} = await execFileAsync(execPath, [
script, "-n", ...apiArgs(), "-f", emptyFile,
Expand Down Expand Up @@ -560,6 +581,7 @@ const latestRows: Array<[string, string, string, ReturnType<typeof dep>]> = [
["npm", "dependencies", "prismjs", dep("https://github.com/LeaVerou/prism", "1.17.1", "1.0.0")],
["npm", "dependencies", "react", dep("https://github.com/facebook/react/tree/HEAD/packages/react", "18.2", "18.0")],
["npm", "dependencies", "styled-components", dep("https://github.com/styled-components/styled-components", "4.4.1", "2.5.0-1")],
["npm", "dependencies", "updates", dep("https://github.com/silverwind/updates", "537ccb7", "6941e05")],
["npm", "overrides", "noty", dep("https://github.com/needim/noty", "3.1.4", "3.1.0")],
["npm", "overrides", "prismjs:overrides.@babel/preset-env.prismjs", dep("https://github.com/LeaVerou/prism", "1.17.1", "1.0.0")],
["npm", "overrides", "prismjs:overrides.prismjs", dep("https://github.com/LeaVerou/prism", "1.17.1", "1.0.0")],
Expand All @@ -584,6 +606,7 @@ test("prerelease", async ({expect = globalExpect}: any = {}) => {
["npm", "dependencies", "prismjs", dep("https://github.com/LeaVerou/prism", "1.17.1", "1.0.0")],
["npm", "dependencies", "react", dep("https://github.com/facebook/react/tree/HEAD/packages/react", "18.3.0-next-fecc288b7-20221025", "18.0")],
["npm", "dependencies", "styled-components", dep("https://github.com/styled-components/styled-components", "5.0.0-regexrehydrate", "2.5.0-1")],
["npm", "dependencies", "updates", dep("https://github.com/silverwind/updates", "537ccb7", "6941e05")],
["npm", "overrides", "noty", dep("https://github.com/needim/noty", "3.2.0-beta", "3.1.0")],
["npm", "overrides", "prismjs:overrides.@babel/preset-env.prismjs", dep("https://github.com/LeaVerou/prism", "1.17.1", "1.0.0")],
["npm", "overrides", "prismjs:overrides.prismjs", dep("https://github.com/LeaVerou/prism", "1.17.1", "1.0.0")],
Expand Down Expand Up @@ -616,6 +639,7 @@ test("patch", async ({expect = globalExpect}: any = {}) => {
["npm", "dependencies", "gulp-sourcemaps", dep("https://github.com/floridoo/gulp-sourcemaps", "2.0.1", "2.0.0")],
["npm", "dependencies", "html-webpack-plugin", dep("https://github.com/jantimon/html-webpack-plugin", "4.0.0-beta.11", "4.0.0-alpha.2")],
["npm", "dependencies", "noty", dep("https://github.com/needim/noty", "3.1.4", "3.1.0")],
["npm", "dependencies", "updates", dep("https://github.com/silverwind/updates", "537ccb7", "6941e05")],
["npm", "overrides", "noty", dep("https://github.com/needim/noty", "3.1.4", "3.1.0")],
["npm", "packageManager", "npm", dep("https://github.com/npm/cli", "11.6.2", "11.6.0")],
["npm", "resolutions", "versions/updates", dep("https://github.com/silverwind/updates", "^1.0.6", "^1.0.0")],
Expand Down Expand Up @@ -1438,11 +1462,22 @@ test.each([
["workflow", dockerActionsDir, "ci.yaml", {
node: {old: "18", new: "22"}, postgres: {old: "15", new: "17"}, redis: {old: "7", new: "8"},
}],
])("docker %s basic", async (_name, file, suffix, expected, {expect = globalExpect}: any = {}) => {
])("docker %s basic", async (name, file, suffix, expected, {expect = globalExpect}: any = {}) => {
const {stdout, stderr} = await runCliExec(dockerArgs("-j", "-f", file));
expect(stderr).toEqual("");
const docker = JSON.parse(stdout).results.docker;
expect(docker[Object.keys(docker).find(key => key.endsWith(suffix))!]).toMatchObject(expected);
const dependencies = docker[Object.keys(docker).find(key => key.endsWith(suffix))!];
expect(dependencies).toMatchObject(expected);
if (name === "workflow") {
const crlfDir = join(testDir, "docker-actions-crlf", ".github", "workflows");
const crlfFile = join(crlfDir, "ci.yaml");
mkdirSync(crlfDir, {recursive: true});
await writeFile(crlfFile, (await readFile(join(file, suffix), "utf8")).replaceAll("\n", "\r\n"));
const crlfOutput = await runCliExec(dockerArgs("-j", "-f", crlfFile));
expect(crlfOutput.stderr).toEqual("");
const crlfDocker = JSON.parse(crlfOutput.stdout).results.docker;
expect(crlfDocker[Object.keys(crlfDocker).find(key => key.endsWith(suffix))!]).toEqual(dependencies);
}
});

test("docker allowedVersions compares floating tags with Docker semantics", async ({expect = globalExpect}: any = {}) => {
Expand Down Expand Up @@ -1612,7 +1647,7 @@ function configTest(config: string, args: string): Promise<{stdout: string, stde
test("config exit-code options", async ({expect = globalExpect}: any = {}) => {
for (const [config, args, output] of [
["{ errorOnOutdated: true }", "-j -i noty", "noty"],
["{ errorOnUnchanged: true }", "-j -i updates", "All dependencies are up to date."],
["{ errorOnUnchanged: true }", "-j -i svgstore", "All dependencies are up to date."],
]) {
try {
await configTest(config, args);
Expand Down
4 changes: 2 additions & 2 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,8 @@ async function main(): Promise<void> {

if (hasFlag(rawArgs, "version", "v")) {
let packageJson: string;
try { packageJson = readFileSync(new URL("../package.json", import.meta.url), "utf8"); } catch {
packageJson = readFileSync(new URL("package.json", import.meta.url), "utf8");
try { packageJson = readFileSync(new URL("package.json", import.meta.url), "utf8"); } catch {
packageJson = readFileSync(new URL("../package.json", import.meta.url), "utf8");
}
console.info(JSON.parse(packageJson).version);
await end();
Expand Down
6 changes: 4 additions & 2 deletions modes/cargo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ test.each([
`dependencies${fieldSep}serde`, {old: "1.0.0", new: "1.1.0"}, `[dependencies]\nserde = { version = "1.1.0", features = ["derive"] }\n`],
[`extended table [dependencies.name]`, `[dependencies.serde]\nversion = "1.0.0"\nfeatures = ["derive"]\n`,
`dependencies${fieldSep}serde`, {old: "1.0.0", new: "1.2.0"}, `[dependencies.serde]\nversion = "1.2.0"\nfeatures = ["derive"]\n`],
[`spaced dotted workspace table`, `[workspace . dependencies]\nserde = "1.0.0"\n`,
`workspace.dependencies${fieldSep}serde`, {old: "1.0.0", new: "1.0.1"}, `[workspace . dependencies]\nserde = "1.0.1"\n`],
[`extended table skips comments and multiline strings`, `[dependencies.serde]\n# version = "1.0.0" was old\nnote = """\nversion = "1.0.0"\n"""\nversion = "1.0.0"\n`,
`dependencies${fieldSep}serde`, {old: "1.0.0", new: "1.1.0"}, `[dependencies.serde]\n# version = "1.0.0" was old\nnote = """\nversion = "1.0.0"\n"""\nversion = "1.1.0"\n`],
[`extended table beside a same-named dev entry`, `[dependencies.serde]\nversion = "1.0.0"\n\n[dev-dependencies]\nserde = "1.0.0"\n`,
Expand Down Expand Up @@ -170,7 +172,7 @@ test("target sections", () => {
`[target.'cfg(feature = "foo.bar")'.dependencies]`,
`libc = "0.2.0"`,
``,
`[target.x86_64-pc-windows-msvc.dependencies.winapi]`,
`[target . x86_64-pc-windows-msvc . dependencies . winapi]`,
`version = "0.3.0"`,
``,
`[target.'cfg(windows)'.build-dependencies.cc]`,
Expand All @@ -190,7 +192,7 @@ test("target sections", () => {
};
const result = updateCargoToml(input, deps);
expect(result).toContain(`libc = "0.2.1"`);
expect(result).toContain(`[target.x86_64-pc-windows-msvc.dependencies.winapi]\nversion = "0.3.9"`);
expect(result).toContain(`[target . x86_64-pc-windows-msvc . dependencies . winapi]\nversion = "0.3.9"`);
expect(result).toContain(`[target.'cfg(windows)'.build-dependencies.cc]\nversion = "1.1.0"`);
expect(result).toContain(`[dev-dependencies]\nlibc = "0.2.0"`);
});
Expand Down
5 changes: 3 additions & 2 deletions modes/cargo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,9 @@ export function updateCargoToml(pkgStr: string, deps: Deps): string {
const oldEsc = esc(oldValue);
const typePath: Array<string> = typeKey.startsWith("[") ? JSON.parse(jsonStringArrayRe.exec(typeKey)![0]) :
typeKey.split("|", 1)[0].split(".");
const sectionEsc = typePath.map(tomlKey).join("\\.");
const ownRe = new RegExp(`^${sectionEsc}\\.${nameEsc}$`);
const dottedSeparator = "[ \\t]*\\.[ \\t]*";
const sectionEsc = typePath.map(tomlKey).join(dottedSeparator);
const ownRe = new RegExp(`^${sectionEsc}${dottedSeparator}${nameEsc}$`);
const sectionRe = new RegExp(`^${sectionEsc}$`);
const ownSpan = spans.find(entry => ownRe.test(entry.path));
const span = ownSpan ?? spans.find(entry => sectionRe.test(entry.path));
Expand Down
7 changes: 4 additions & 3 deletions modes/docker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ test.each([
["docker.io", "docker.io/library/node:18", {registry: null, namespace: "library", repo: "node", tag: "18", fullImage: "docker.io/library/node"}],
["index.docker.io", "index.docker.io/myorg/myapp:1.0.0", {registry: null, namespace: "myorg", repo: "myapp", tag: "1.0.0", fullImage: "index.docker.io/myorg/myapp"}],
["registry-1.docker.io", "registry-1.docker.io/node:18", {registry: null, namespace: "library", repo: "node", tag: "18", fullImage: "registry-1.docker.io/node"}],
["a deep Hub path", "org/team/image:1.2.3", {registry: null, namespace: "org/team", repo: "image", tag: "1.2.3", fullImage: "org/team/image"}],
["a registry without a domain suffix", "org/team/image:1.2.3", {registry: "org", namespace: "team", repo: "image", tag: "1.2.3", fullImage: "org/team/image"}],
["localhost registry", "localhost/owner/image:1.2.3", {registry: "localhost", namespace: "owner", repo: "image", tag: "1.2.3", fullImage: "localhost/owner/image"}],
["a tag with suffix", "node:18-alpine", {registry: null, namespace: "library", repo: "node", tag: "18-alpine", fullImage: "node"}],
["full semver with suffix", "node:18.19.1-bookworm", {registry: null, namespace: "library", repo: "node", tag: "18.19.1-bookworm", fullImage: "node"}],
Expand All @@ -41,6 +41,7 @@ test("dockerImageNames", () => {
expect(dockerImageNames("index.docker.io/library/mysql")).toEqual(["index.docker.io/library/mysql", ...hub]);
expect(dockerImageNames("grafana/grafana")).toEqual(["grafana/grafana", "docker.io/grafana/grafana"]);
expect(dockerImageNames("ghcr.io/foo/bar")).toEqual(["ghcr.io/foo/bar"]);
expect(dockerImageNames("REGISTRY/team/image")).toEqual(["REGISTRY/team/image"]);
});

test.each([
Expand Down Expand Up @@ -178,6 +179,7 @@ test("findDockerVersion ignores tags from another versioning scheme", () => {
expect(findDockerVersion(tagMap, "3", allSemvers)).toBeNull();
expect(findDockerVersion(tagMap, "20251224", allSemvers)).toEqual({newTag: "20260127", date: "2026-01-28"});
expect(findDockerVersion({"9": "2020-01-01", "10": "2020-06-01"}, "9", allSemvers)).toEqual({newTag: "10", date: "2020-06-01"});
expect(findDockerVersion({"20260127": "2026-01-27", "9999999999999999999": "2026-02-01"}, "20260127", allSemvers)).toBeNull();
});

test("findDockerVersion cooldown needs a timestamp", () => {
Expand Down Expand Up @@ -358,8 +360,7 @@ test("fetchDockerTagDigest returns the registry digest and reports failures", as
await expect(fetchDockerTagDigest("library", "node", "20", hubCtx(() => Promise.resolve({
ok: false, status: 429, statusText: "Too Many Requests",
})))).rejects.toThrow("Received 429 Too Many Requests");
await expect(fetchDockerTagDigest("library", "node", "20", hubCtx(hubBody({}))))
.rejects.toThrow("Malformed Docker Hub tag response");
await expect(fetchDockerTagDigest("library", "node", "20", hubCtx(hubBody({})))).resolves.toBe(null);
});

test("fetchDockerInfo library image", async () => {
Expand Down
11 changes: 6 additions & 5 deletions modes/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ const hubRegistryRe = /^(?:(?:index|registry-1)\.)?docker\.io$/;
function parseImageParts(imagePart: string): {registry: string | null, namespace: string, repo: string} {
const parts = imagePart.split("/");
if (parts.length > 1 && hubRegistryRe.test(parts[0])) parts.shift();
const registry = parts.length > 1 && (parts[0] === "localhost" || parts[0].includes(".") || parts[0].includes(":")) ?
const registry = parts.length > 2 ||
parts.length > 1 && (parts[0] === "localhost" || parts[0].includes(".") || parts[0].includes(":")) ?
parts.shift()! : null;
return {registry, namespace: parts.length === 1 ? "library" : parts.slice(0, -1).join("/"), repo: parts.at(-1)!};
}
Expand Down Expand Up @@ -199,9 +200,8 @@ export async function fetchDockerTagDigest(
const result = await fetchWithEtag(url, ctx, {headers: {"accept-encoding": "gzip, deflate, br"}},
reduceJson(data => ({digest: data.digest})));
if ("body" in result) {
const data = JSON.parse(result.body);
if (typeof data?.digest !== "string") throw new Error(`Malformed Docker Hub tag response: ${namespace}/${repo}:${tag}`);
return data.digest;
const digest = JSON.parse(result.body)?.digest; // absent on tags pushed before Docker Hub recorded manifest digests
return typeof digest === "string" ? digest : null;
}
if (!noTagsStatus.has(result.res?.status as number)) throwFetchError(result.res, url, `${namespace}/${repo}:${tag}`, ctx.dockerApiUrl);
return null;
Expand Down Expand Up @@ -310,7 +310,8 @@ export function findDockerVersion(

const coerced = coerceDockerVersion(parsed.version);
if (!coerced) continue;
const candidate = parse(dockerSemver(coerced, parsed.prerelease))!;
const candidate = parse(dockerSemver(coerced, parsed.prerelease));
if (!candidate) continue;
if (parsed.prerelease && skipsPrerelease(candidate)) continue;
if (pinnedRange && !satisfies(coerced, pinnedRange)) continue;

Expand Down
29 changes: 19 additions & 10 deletions modes/go.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,10 @@ test.each([
goMod("require foo v1.0.0", "exclude foo v1.0.0", "replace other => foo v1.0.0"),
{[`deps${fieldSep}foo`]: {old: "1.0.0", new: "1.1.0"}},
goMod("require foo v1.1.0", "exclude foo v1.0.0", "replace other => foo v1.0.0"), {}],
["a directive in a file with mixed line endings",
"module x\n\nrequire example.com/dep v1.0.0\r\n",
{[`deps${fieldSep}example.com/dep`]: {old: "1.0.0", new: "1.1.0"}},
"module x\n\nrequire example.com/dep v1.1.0\r\n", {}],
])("updateGoMod rewrites %s", (_name, content, deps, expected, expectedRewrites) => {
const [result, rewrites] = updateGoMod(content, deps);
expect(result).toBe(expected);
Expand Down Expand Up @@ -402,25 +406,30 @@ test.each([
expect(parseGoWork(lines.join("\n"))).toEqual(expected);
});

test("resolveGoWorkModule contains members after resolving symlinks", () => {
test("resolveGoWorkModule resolves an out-of-tree member", () => {
const parent = mkdtempSync(resolve(tmpdir(), "updates-go-work-"));
try {
const root = resolve(parent, "project");
const member = resolve(root, "member");
const outside = resolve(parent, "trusted");
mkdirSync(member, {recursive: true});
const outside = resolve(parent, "shared");
mkdirSync(root);
mkdirSync(outside);
writeFileSync(resolve(member, "go.mod"), "module example.com/member\n");
writeFileSync(resolve(outside, "go.mod"), "module example.com/trusted\n");
symlinkSync(outside, resolve(root, "linked"));
expect(resolveGoWorkModule(root, "member")).toBe(realpathSync(resolve(member, "go.mod")));
expect(resolveGoWorkModule(root, "../trusted")).toBeNull();
expect(resolveGoWorkModule(root, "linked")).toBeNull();
writeFileSync(resolve(outside, "go.mod"), "module example.com/shared\n");
expect(resolveGoWorkModule(root, "../shared")).toBe(realpathSync(resolve(outside, "go.mod")));
} finally {
rmSync(parent, {recursive: true});
}
});

test("resolveGoWorkModule skips a member with a resolution error", () => {
const root = mkdtempSync(resolve(tmpdir(), "updates-go-work-"));
try {
symlinkSync("loop", resolve(root, "loop"));
expect(resolveGoWorkModule(root, "loop")).toBeNull();
} finally {
rmSync(root, {recursive: true});
}
});

test("rewriteGoImports rewrites matching imports and skips empty work", () => {
rewriteGoImports(resolve("fixtures/go"), {}, () => { throw new Error("unexpected write"); });
rewriteGoImports(resolve("fixtures/cargo"), {"github.com/old": "github.com/new"}, () => { throw new Error("unexpected write"); });
Expand Down
Loading