From 094da2f7ea729bc964b4432dc9add577fc486a63 Mon Sep 17 00:00:00 2001 From: Pierre Cavin Date: Wed, 26 Mar 2025 10:00:27 +0100 Subject: [PATCH] feat(mix): implement lockFileMaintenance (#33326) Co-authored-by: Rhys Arkins Co-authored-by: Michael Kriese --- .../mix/__snapshots__/artifacts.spec.ts.snap | 6 +- lib/modules/manager/mix/artifacts.spec.ts | 266 ++++++++++++++++-- lib/modules/manager/mix/artifacts.ts | 97 ++++++- lib/modules/manager/mix/index.ts | 1 + lib/modules/manager/mix/readme.md | 9 + 5 files changed, 339 insertions(+), 40 deletions(-) diff --git a/lib/modules/manager/mix/__snapshots__/artifacts.spec.ts.snap b/lib/modules/manager/mix/__snapshots__/artifacts.spec.ts.snap index cbc96381f6..ea4194f05a 100644 --- a/lib/modules/manager/mix/__snapshots__/artifacts.spec.ts.snap +++ b/lib/modules/manager/mix/__snapshots__/artifacts.spec.ts.snap @@ -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", diff --git a/lib/modules/manager/mix/artifacts.spec.ts b/lib/modules/manager/mix/artifacts.spec.ts index bb57e0f07a..2ec1d2d3b7 100644 --- a/lib/modules/manager/mix/artifacts.spec.ts +++ b/lib/modules/manager/mix/artifacts.spec.ts @@ -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 = { + 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'); + }); }); diff --git a/lib/modules/manager/mix/artifacts.ts b/lib/modules/manager/mix/artifacts.ts index 21868fff67..11331f31f7 100644 --- a/lib/modules/manager/mix/artifacts.ts +++ b/lib/modules/manager/mix/artifacts.ts @@ -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 { 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 { + if (await localPathExists(lockFileName)) { + return [ + { + artifactError: { + lockFile: lockFileName, + stderr: `Error reading ${lockFileName}`, + }, + }, + ]; + } + return null; +} diff --git a/lib/modules/manager/mix/index.ts b/lib/modules/manager/mix/index.ts index df1fa52ec6..0fbbdfdec2 100644 --- a/lib/modules/manager/mix/index.ts +++ b/lib/modules/manager/mix/index.ts @@ -14,6 +14,7 @@ export const defaultConfig = { fileMatch: ['(^|/)mix\\.exs$'], }; +export const supportsLockFileMaintenance = true; export const supportedDatasources = [ GithubTagsDatasource.id, GitTagsDatasource.id, diff --git a/lib/modules/manager/mix/readme.md b/lib/modules/manager/mix/readme.md index 7924c9704c..5528410828 100644 --- a/lib/modules/manager/mix/readme.md +++ b/lib/modules/manager/mix/readme.md @@ -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"`.