feat(mix): implement lockFileMaintenance (#33326)

Co-authored-by: Rhys Arkins <rhys@arkins.net>
Co-authored-by: Michael Kriese <michael.kriese@gmx.de>
This commit is contained in:
Pierre Cavin
2025-03-26 10:00:27 +01:00
committed by GitHub
parent 89d8193f9e
commit 094da2f7ea
5 changed files with 339 additions and 40 deletions
@@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`modules/manager/mix/artifacts > authenticates to private repositories in updated dependecies 1`] = `
exports[`modules/manager/mix/artifacts > authenticates to private repositories in updated dependencies 1`] = `
[
{
"file": {
@@ -12,7 +12,7 @@ exports[`modules/manager/mix/artifacts > authenticates to private repositories i
]
`;
exports[`modules/manager/mix/artifacts > authenticates to private repositories in updated dependecies 2`] = `
exports[`modules/manager/mix/artifacts > authenticates to private repositories in updated dependencies 2`] = `
[
{
"cmd": "docker ps --filter name=renovate_sidecar -aq",
@@ -80,7 +80,7 @@ exports[`modules/manager/mix/artifacts > returns updated mix.lock 1`] = `
},
},
{
"cmd": "docker run --rm --name=renovate_sidecar --label=renovate_child -v "/tmp/github/some/repo":"/tmp/github/some/repo" -v "/tmp/cache":"/tmp/cache" -e CONTAINERBASE_CACHE_DIR -w "/tmp/github/some/repo" ghcr.io/containerbase/sidecar bash -l -c "install-tool erlang 25.0.0.0 && install-tool elixir 1.13.4 && mix deps.update plug"",
"cmd": "docker run --rm --name=renovate_sidecar --label=renovate_child -v "/tmp/github/some/repo":"/tmp/github/some/repo" -v "/tmp/cache":"/tmp/cache" -e CONTAINERBASE_CACHE_DIR -w "/tmp/github/some/repo" ghcr.io/containerbase/sidecar bash -l -c "install-tool erlang 25.0.0.0 && install-tool elixir v1.13.4 && mix deps.update plug"",
"options": {
"cwd": "/tmp/github/some/repo",
"encoding": "utf-8",
+243 -23
View File
@@ -2,7 +2,6 @@ import { join } from 'upath';
import { mockDeep } from 'vitest-mock-extended';
import { GlobalConfig } from '../../../config/global';
import type { RepoGlobalConfig } from '../../../config/types';
import * as docker from '../../../util/exec/docker';
import { getPkgReleases as _getPkgReleases } from '../../datasource';
import type { UpdateArtifactsConfig } from '../types';
import { updateArtifacts } from '.';
@@ -27,6 +26,10 @@ const adminConfig: RepoGlobalConfig = {
process.env.CONTAINERBASE = 'true';
const config: UpdateArtifactsConfig = {};
const constraints: Record<string, string> = {
erlang: '25.0.0.0',
elixir: 'v1.13.4',
};
describe('modules/manager/mix/artifacts', () => {
beforeEach(() => {
@@ -74,9 +77,10 @@ describe('modules/manager/mix/artifacts', () => {
});
it('returns null if unchanged', async () => {
fs.getSiblingFileName.mockReturnValueOnce('mix.lock');
fs.readLocalFile.mockResolvedValueOnce('Current mix.lock');
fs.readLocalFile.mockResolvedValueOnce('Current mix.lock');
const execSnapshots = mockExecAll();
fs.readLocalFile.mockResolvedValueOnce('Current mix.lock');
expect(
await updateArtifacts({
packageFileName: 'mix.exs',
@@ -88,15 +92,61 @@ describe('modules/manager/mix/artifacts', () => {
expect(execSnapshots).toMatchSnapshot();
});
it('returns null when trying to use lockFileMaintenance with no mix.lock file', async () => {
fs.getSiblingFileName.mockReturnValueOnce('mix.lock');
fs.readLocalFile.mockResolvedValueOnce(null);
fs.localPathExists.mockResolvedValueOnce(false);
fs.findLocalSiblingOrParent.mockResolvedValueOnce(null);
fs.writeLocalFile.mockClear(); // Clear the mock to ensure we can check if it's called
expect(
await updateArtifacts({
packageFileName: 'mix.exs',
updatedDeps: [],
newPackageFileContent: '{}',
config: { ...config, isLockFileMaintenance: true },
}),
).toBeNull();
expect(fs.writeLocalFile).not.toHaveBeenCalled();
});
it('returns null if no updatedDeps and no lockFileMaintenance', async () => {
expect(
await updateArtifacts({
packageFileName: 'mix.exs',
updatedDeps: [],
newPackageFileContent: '{}',
config,
}),
).toBeNull();
});
it('returns null if using lockFileMaintenance in umbrella project', async () => {
fs.getSiblingFileName.mockReturnValueOnce('apps/foo/mix.lock');
fs.findLocalSiblingOrParent.mockResolvedValueOnce('mix.lock');
fs.readLocalFile.mockResolvedValueOnce(null);
fs.readLocalFile.mockResolvedValueOnce('Old mix.lock');
fs.readLocalFile.mockResolvedValueOnce('New mix.lock');
expect(
await updateArtifacts({
packageFileName: 'apps/foo/mix.exs',
updatedDeps: [],
newPackageFileContent: '{}',
config: { ...config, isLockFileMaintenance: true },
}),
).toBeNull();
});
it('returns updated mix.lock', async () => {
vi.spyOn(docker, 'removeDanglingContainers').mockResolvedValueOnce();
GlobalConfig.set({
...adminConfig,
binarySource: 'docker',
dockerSidecarImage: 'ghcr.io/containerbase/sidecar',
});
fs.readLocalFile.mockResolvedValueOnce('Old mix.lock');
fs.findLocalSiblingOrParent.mockResolvedValueOnce('mix.lock');
fs.getSiblingFileName.mockReturnValueOnce('mix.lock');
const execSnapshots = mockExecAll();
fs.readLocalFile.mockResolvedValueOnce('New mix.lock');
@@ -113,9 +163,9 @@ describe('modules/manager/mix/artifacts', () => {
// elixir
getPkgReleases.mockResolvedValueOnce({
releases: [
{ version: '1.8.2' },
{ version: '1.13.3' },
{ version: '1.13.4' },
{ version: 'v1.8.2' },
{ version: 'v1.13.3' },
{ version: 'v1.13.4' },
],
});
@@ -137,7 +187,7 @@ describe('modules/manager/mix/artifacts', () => {
it('uses constraints on install mode', async () => {
GlobalConfig.set({ ...adminConfig, binarySource: 'install' });
fs.readLocalFile.mockResolvedValueOnce('Old mix.lock');
fs.findLocalSiblingOrParent.mockResolvedValueOnce('mix.lock');
fs.getSiblingFileName.mockReturnValueOnce('mix.lock');
const execSnapshots = mockExecAll();
fs.readLocalFile.mockResolvedValueOnce('New mix.lock');
@@ -163,15 +213,14 @@ describe('modules/manager/mix/artifacts', () => {
]);
});
it('authenticates to private repositories in updated dependecies', async () => {
vi.spyOn(docker, 'removeDanglingContainers').mockResolvedValueOnce();
it('authenticates to private repositories in updated dependencies', async () => {
GlobalConfig.set({
...adminConfig,
binarySource: 'docker',
dockerSidecarImage: 'ghcr.io/containerbase/sidecar',
});
fs.readLocalFile.mockResolvedValueOnce('Old mix.lock');
fs.findLocalSiblingOrParent.mockResolvedValueOnce('mix.lock');
fs.getSiblingFileName.mockReturnValueOnce('mix.lock');
const execSnapshots = mockExecAll();
fs.readLocalFile.mockResolvedValueOnce('New mix.lock');
hostRules.find.mockReturnValueOnce({ token: 'valid_test_token' });
@@ -231,7 +280,7 @@ describe('modules/manager/mix/artifacts', () => {
it('authenticates to private repositories configured in hostRules', async () => {
GlobalConfig.set({ ...adminConfig, binarySource: 'install' });
fs.readLocalFile.mockResolvedValueOnce('Old mix.lock');
fs.findLocalSiblingOrParent.mockResolvedValueOnce('mix.lock');
fs.getSiblingFileName.mockReturnValueOnce('mix.lock');
const execSnapshots = mockExecAll();
fs.readLocalFile.mockResolvedValueOnce('New mix.lock');
hostRules.getAll.mockReturnValueOnce([
@@ -292,27 +341,132 @@ describe('modules/manager/mix/artifacts', () => {
});
it('returns updated mix.lock in subdir', async () => {
GlobalConfig.set({
...adminConfig,
binarySource: 'docker',
dockerSidecarImage: 'ghcr.io/containerbase/sidecar',
});
fs.findLocalSiblingOrParent.mockResolvedValueOnce('subdir/mix.lock');
mockExecAll();
GlobalConfig.set({ ...adminConfig, binarySource: 'install' });
fs.getSiblingFileName.mockReturnValueOnce('subdir/mix.lock');
fs.readLocalFile.mockResolvedValueOnce('Old mix.lock');
fs.readLocalFile.mockResolvedValueOnce('New mix.lock');
const execSnapshots = mockExecAll();
expect(
await updateArtifacts({
packageFileName: 'subdir/mix.exs',
updatedDeps: [{ depName: 'plug' }],
newPackageFileContent: '{}',
config,
config: { ...config, constraints },
}),
).toEqual([
{
file: {
path: 'subdir/mix.lock',
type: 'addition',
contents: 'New mix.lock',
},
},
]);
expect(execSnapshots).toMatchObject([
{ cmd: 'install-tool erlang 25.0.0.0' },
{ cmd: 'install-tool elixir v1.13.4' },
{
cmd: 'mix deps.update plug',
options: { cwd: '/tmp/github/some/repo/subdir' },
},
]);
});
it('returns updated mix.lock in umbrella project', async () => {
GlobalConfig.set({ ...adminConfig, binarySource: 'install' });
fs.getSiblingFileName.mockReturnValueOnce('apps/foo/mix.lock');
fs.findLocalSiblingOrParent.mockResolvedValueOnce('mix.lock');
fs.readLocalFile.mockResolvedValueOnce(null);
fs.readLocalFile.mockResolvedValueOnce('Old mix.lock');
fs.readLocalFile.mockResolvedValueOnce('New mix.lock');
const execSnapshots = mockExecAll();
expect(
await updateArtifacts({
packageFileName: 'apps/foo/mix.exs',
updatedDeps: [{ depName: 'plug' }],
newPackageFileContent: '{}',
config: { ...config, constraints },
}),
).toEqual([
{
file: {
path: 'mix.lock',
type: 'addition',
contents: 'New mix.lock',
},
},
]);
expect(execSnapshots).toMatchObject([
{ cmd: 'install-tool erlang 25.0.0.0' },
{ cmd: 'install-tool elixir v1.13.4' },
{
cmd: 'mix deps.update plug',
options: { cwd: '/tmp/github/some/repo/apps/foo' },
},
]);
});
it('supports lockFileMaintenance', async () => {
GlobalConfig.set({ ...adminConfig, binarySource: 'install' });
fs.getSiblingFileName.mockReturnValueOnce('mix.lock');
fs.readLocalFile.mockResolvedValueOnce('Old mix.lock');
fs.readLocalFile.mockResolvedValueOnce('New mix.lock');
const execSnapshots = mockExecAll();
expect(
await updateArtifacts({
packageFileName: 'mix.exs',
updatedDeps: [],
newPackageFileContent: '{}',
config: {
...config,
constraints,
isLockFileMaintenance: true,
},
}),
).toEqual([
{
file: {
path: 'mix.lock',
type: 'addition',
contents: 'New mix.lock',
},
},
]);
expect(execSnapshots).toMatchObject([
{ cmd: 'install-tool erlang 25.0.0.0' },
{ cmd: 'install-tool elixir v1.13.4' },
{
cmd: 'mix deps.get',
options: { cwd: '/tmp/github/some/repo' },
},
]);
});
it('lockFileMaintenance returns null if unchanged', async () => {
fs.getSiblingFileName.mockReturnValueOnce('mix.lock');
fs.readLocalFile.mockResolvedValueOnce('Old mix.lock');
fs.readLocalFile.mockResolvedValueOnce('Old mix.lock');
mockExecAll();
expect(
await updateArtifacts({
packageFileName: 'mix.exs',
updatedDeps: [],
newPackageFileContent: '{}',
config: { ...config, isLockFileMaintenance: true },
}),
).toBeNull();
expect(fs.readLocalFile).toHaveBeenCalledWith('subdir/mix.lock', 'utf8');
});
it('catches write errors', async () => {
fs.readLocalFile.mockResolvedValueOnce('Current mix.lock');
fs.findLocalSiblingOrParent.mockResolvedValueOnce('mix.lock');
fs.getSiblingFileName.mockReturnValueOnce('mix.lock');
fs.writeLocalFile.mockImplementationOnce(() => {
throw new Error('not found');
});
@@ -330,7 +484,7 @@ describe('modules/manager/mix/artifacts', () => {
it('catches exec errors', async () => {
fs.readLocalFile.mockResolvedValueOnce('Current mix.lock');
fs.findLocalSiblingOrParent.mockResolvedValueOnce('mix.lock');
fs.getSiblingFileName.mockReturnValueOnce('mix.lock');
mockExecAll(new Error('exec-error'));
expect(
await updateArtifacts({
@@ -343,4 +497,70 @@ describe('modules/manager/mix/artifacts', () => {
{ artifactError: { lockFile: 'mix.lock', stderr: 'exec-error' } },
]);
});
it('detects read errors', async () => {
fs.getSiblingFileName.mockReturnValueOnce('mix.lock');
fs.readLocalFile.mockResolvedValueOnce(null);
fs.localPathExists.mockResolvedValueOnce(true);
expect(
await updateArtifacts({
packageFileName: 'mix.exs',
updatedDeps: [{ depName: 'plug' }],
newPackageFileContent: '{}',
config,
}),
).toEqual([
{
artifactError: {
lockFile: 'mix.lock',
stderr: 'Error reading mix.lock',
},
},
]);
});
it('detects read errors (umbrella)', async () => {
fs.getSiblingFileName.mockReturnValueOnce('apps/foo/mix.lock');
fs.readLocalFile.mockResolvedValueOnce(null);
fs.localPathExists.mockResolvedValueOnce(false);
fs.findLocalSiblingOrParent.mockResolvedValueOnce('mix.lock');
fs.readLocalFile.mockResolvedValueOnce(null);
fs.localPathExists.mockResolvedValueOnce(true);
expect(
await updateArtifacts({
packageFileName: 'apps/foo/mix.exs',
updatedDeps: [{ depName: 'plug' }],
newPackageFileContent: '{}',
config,
}),
).toEqual([
{
artifactError: {
lockFile: 'mix.lock',
stderr: 'Error reading mix.lock',
},
},
]);
});
it("handles updates and doesn't try to create mix.lock file if it doesn't exist", async () => {
fs.getSiblingFileName.mockReturnValueOnce('mix.lock');
fs.readLocalFile.mockResolvedValueOnce(null);
fs.localPathExists.mockResolvedValueOnce(false);
fs.findLocalSiblingOrParent.mockResolvedValueOnce(null);
const execSnapshots = mockExecAll();
expect(
await updateArtifacts({
packageFileName: 'mix.exs',
updatedDeps: [{ depName: 'plug' }],
newPackageFileContent: 'New mix.exs',
config,
}),
).toBeNull();
expect(execSnapshots).toHaveLength(0);
expect(fs.writeLocalFile).toHaveBeenCalledWith('mix.exs', 'New mix.exs');
});
});
+83 -14
View File
@@ -5,7 +5,10 @@ import { logger } from '../../../logger';
import { exec } from '../../../util/exec';
import type { ExecOptions } from '../../../util/exec/types';
import {
deleteLocalFile,
findLocalSiblingOrParent,
getSiblingFileName,
localPathExists,
readLocalFile,
writeLocalFile,
} from '../../../util/fs';
@@ -26,15 +29,60 @@ export async function updateArtifacts({
config,
}: UpdateArtifact): Promise<UpdateArtifactsResult[] | null> {
logger.debug(`mix.getArtifacts(${packageFileName})`);
if (updatedDeps.length < 1) {
logger.debug('No updated mix deps - returning null');
const { isLockFileMaintenance } = config;
if (is.emptyArray(updatedDeps) && !isLockFileMaintenance) {
logger.debug('No updated mix deps');
return null;
}
let lockFileName = getSiblingFileName(packageFileName, 'mix.lock');
let isUmbrella = false;
let existingLockFileContent = await readLocalFile(lockFileName, 'utf8');
if (!existingLockFileContent) {
const lockFileError = await checkLockFileReadError(lockFileName);
if (lockFileError) {
return lockFileError;
}
const parentLockFileName = await findLocalSiblingOrParent(
packageFileName,
'mix.lock',
);
existingLockFileContent =
parentLockFileName && (await readLocalFile(parentLockFileName, 'utf8'));
if (parentLockFileName && existingLockFileContent) {
lockFileName = parentLockFileName;
isUmbrella = true;
} else if (parentLockFileName) {
const lockFileError = await checkLockFileReadError(parentLockFileName);
if (lockFileError) {
return lockFileError;
}
}
}
if (isLockFileMaintenance && isUmbrella) {
logger.debug(
'Cannot use lockFileMaintenance in an umbrella project, see https://docs.renovatebot.com/modules/manager/mix/#lockFileMaintenance',
);
return null;
}
if (isLockFileMaintenance && !existingLockFileContent) {
logger.debug(
'Cannot use lockFileMaintenance when no mix.lock file is present',
);
return null;
}
const lockFileName =
(await findLocalSiblingOrParent(packageFileName, 'mix.lock')) ?? 'mix.lock';
try {
await writeLocalFile(packageFileName, newPackageFileContent);
if (isLockFileMaintenance) {
await deleteLocalFile(lockFileName);
}
} catch (err) {
logger.warn({ err }, 'mix.exs could not be written');
return [
@@ -47,7 +95,6 @@ export async function updateArtifacts({
];
}
const existingLockFileContent = await readLocalFile(lockFileName, 'utf8');
if (!existingLockFileContent) {
logger.debug('No mix.lock found');
return null;
@@ -113,19 +160,25 @@ export async function updateArtifacts({
],
preCommands,
};
const command = [
'mix',
'deps.update',
...updatedDeps
.map((dep) => dep.depName)
.filter(is.string)
.map((dep) => quote(dep)),
].join(' ');
let command: string;
if (isLockFileMaintenance) {
command = 'mix deps.get';
} else {
command = [
'mix',
'deps.update',
...updatedDeps
.map((dep) => dep.depName)
.filter(is.string)
.map((dep) => quote(dep)),
].join(' ');
}
try {
await exec(command, execOptions);
} catch (err) {
// istanbul ignore if
/* v8 ignore next 3 */
if (err.message === TEMPORARY_ERROR) {
throw err;
}
@@ -161,3 +214,19 @@ export async function updateArtifacts({
},
];
}
async function checkLockFileReadError(
lockFileName: string,
): Promise<UpdateArtifactsResult[] | null> {
if (await localPathExists(lockFileName)) {
return [
{
artifactError: {
lockFile: lockFileName,
stderr: `Error reading ${lockFileName}`,
},
},
];
}
return null;
}
+1
View File
@@ -14,6 +14,7 @@ export const defaultConfig = {
fileMatch: ['(^|/)mix\\.exs$'],
};
export const supportsLockFileMaintenance = true;
export const supportedDatasources = [
GithubTagsDatasource.id,
GitTagsDatasource.id,
+9
View File
@@ -7,6 +7,15 @@ The following `depTypes` are currently supported by the `mix` manager :
- `prod`: all dependencies by default
- `dev`: dependencies with [`:only` option](https://hexdocs.pm/mix/Mix.Tasks.Deps.html#module-dependency-definition-options) not containing `:prod`
### `lockFileMaintenance`
We recommend you use [`lockFileMaintenance`](../../../configuration-options.md#lockfilemaintenance) for the `mix` manager.
`lockFileMaintenance=true` periodically refreshes your `mix.lock` files, ensuring all indirect dependencies are updated to their latest allowed versions.
This option will be skipped in [umbrella projects](https://hexdocs.pm/elixir/dependencies-and-umbrella-projects.html#umbrella-projects), as they share a single
`mix.lock` file for all applications `mix.exs` files.
### Default `rangeStrategy=auto` behavior
Renovate's default [`rangeStrategy`](../../../configuration-options.md#rangestrategy) is `"auto"`.