From c79fc1c4b49aab765f67254a4576045ab06cbf48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Fri, 13 Mar 2026 19:48:30 +0100 Subject: [PATCH 01/16] feat(pep621): add minimumReleaseAge to UpdateArtifactsConfig type --- lib/modules/manager/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/modules/manager/types.ts b/lib/modules/manager/types.ts index 0be4c0775f8..b0556204ad1 100644 --- a/lib/modules/manager/types.ts +++ b/lib/modules/manager/types.ts @@ -50,6 +50,7 @@ export interface UpdateArtifactsConfig { skipArtifactsUpdate?: boolean; lockFiles?: string[]; toolSettings?: ToolSettingsOptions; + minimumReleaseAge?: string | null; } export interface RangeConfig> extends ManagerData { From 1444d7795b95cbfd622b4c82bc4006925ee9d476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Fri, 13 Mar 2026 19:51:25 +0100 Subject: [PATCH 02/16] feat(pep621): append --exclude-newer to uv lock when minimumReleaseAge is set --- lib/modules/manager/pep621/processors/uv.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/lib/modules/manager/pep621/processors/uv.ts b/lib/modules/manager/pep621/processors/uv.ts index fe023092a53..3b6f2b13832 100644 --- a/lib/modules/manager/pep621/processors/uv.ts +++ b/lib/modules/manager/pep621/processors/uv.ts @@ -14,6 +14,7 @@ import { } from '../../../../util/fs/index.ts'; import { getGitEnvironmentVariables } from '../../../../util/git/auth.ts'; import { find } from '../../../../util/host-rules.ts'; +import { toMs } from '../../../../util/pretty-time.ts'; import { Result } from '../../../../util/result.ts'; import { parseUrl } from '../../../../util/url.ts'; import { PypiDatasource } from '../../../datasource/pypi/index.ts'; @@ -215,6 +216,20 @@ export class UvProcessor extends BasePyProjectProcessor { } else { cmd = generateCMD(updatedDeps); } + + if (config.minimumReleaseAge) { + const ms = toMs(config.minimumReleaseAge); + if (ms !== null && ms !== undefined) { + const excludeNewer = new Date(Date.now() - ms).toISOString(); + cmd += ` --exclude-newer=${excludeNewer}`; + } else { + logger.debug( + { minimumReleaseAge: config.minimumReleaseAge }, + 'Invalid minimumReleaseAge value, skipping --exclude-newer for uv lock', + ); + } + } + await exec(cmd, execOptions); // check for changes From a2f416b41afd348f30efb88e2fbfff83de55146c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Fri, 13 Mar 2026 19:59:19 +0100 Subject: [PATCH 03/16] test(pep621): add tests for --exclude-newer flag in uv lock commands --- .../manager/pep621/processors/uv.spec.ts | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) diff --git a/lib/modules/manager/pep621/processors/uv.spec.ts b/lib/modules/manager/pep621/processors/uv.spec.ts index 90b9452989a..b4a6004b91e 100644 --- a/lib/modules/manager/pep621/processors/uv.spec.ts +++ b/lib/modules/manager/pep621/processors/uv.spec.ts @@ -891,5 +891,213 @@ describe('modules/manager/pep621/processors/uv', () => { }, ]); }); + + describe('--exclude-newer with minimumReleaseAge', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('adds --exclude-newer on lockfile maintenance with minimumReleaseAge', async () => { + vi.spyOn(Date, 'now').mockReturnValue( + new Date('2026-03-13T00:00:00.000Z').getTime(), + ); + const execSnapshots = mockExecAll(); + GlobalConfig.set(adminConfig); + fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); + fs.readLocalFile.mockResolvedValueOnce('test content'); + fs.readLocalFile.mockResolvedValueOnce('changed test content'); + // python + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '3.11.1' }], + }); + // uv + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '0.2.35' }], + }); + + const result = await processor.updateArtifacts( + { + packageFileName: 'folder/pyproject.toml', + newPackageFileContent: '', + config: { + isLockFileMaintenance: true, + minimumReleaseAge: '3 days', + }, + updatedDeps: [], + }, + parsePyProject('')!, + ); + expect(result).toEqual([ + { + file: { + contents: 'changed test content', + path: 'uv.lock', + type: 'addition', + }, + }, + ]); + expect(execSnapshots).toMatchObject([ + { + cmd: 'uv lock --upgrade --exclude-newer=2026-03-10T00:00:00.000Z', + }, + ]); + }); + + it('adds --exclude-newer on per-package update with minimumReleaseAge', async () => { + vi.spyOn(Date, 'now').mockReturnValue( + new Date('2026-03-13T00:00:00.000Z').getTime(), + ); + const execSnapshots = mockExecAll(); + GlobalConfig.set(adminConfig); + fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); + fs.readLocalFile.mockResolvedValueOnce('test content'); + fs.readLocalFile.mockResolvedValueOnce('changed test content'); + // python + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '3.11.1' }], + }); + // uv + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '0.2.35' }], + }); + + const updatedDeps = [ + { packageName: 'dep1', depType: depTypes.dependencies }, + ]; + const result = await processor.updateArtifacts( + { + packageFileName: 'folder/pyproject.toml', + newPackageFileContent: '', + config: { + minimumReleaseAge: '7 days', + }, + updatedDeps, + }, + parsePyProject('')!, + ); + expect(result).toEqual([ + { + file: { + contents: 'changed test content', + path: 'uv.lock', + type: 'addition', + }, + }, + ]); + expect(execSnapshots).toMatchObject([ + { + cmd: 'uv lock --upgrade-package dep1 --exclude-newer=2026-03-06T00:00:00.000Z', + }, + ]); + }); + + it('does not add --exclude-newer when no minimumReleaseAge', async () => { + const execSnapshots = mockExecAll(); + GlobalConfig.set(adminConfig); + fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); + fs.readLocalFile.mockResolvedValueOnce('test content'); + fs.readLocalFile.mockResolvedValueOnce('changed test content'); + // python + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '3.11.1' }], + }); + // uv + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '0.2.35' }], + }); + + await processor.updateArtifacts( + { + packageFileName: 'folder/pyproject.toml', + newPackageFileContent: '', + config: { + isLockFileMaintenance: true, + }, + updatedDeps: [], + }, + parsePyProject('')!, + ); + expect(execSnapshots).toMatchObject([ + { + cmd: 'uv lock --upgrade', + }, + ]); + expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); + }); + + it('skips --exclude-newer on invalid minimumReleaseAge', async () => { + const execSnapshots = mockExecAll(); + GlobalConfig.set(adminConfig); + fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); + fs.readLocalFile.mockResolvedValueOnce('test content'); + fs.readLocalFile.mockResolvedValueOnce('changed test content'); + // python + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '3.11.1' }], + }); + // uv + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '0.2.35' }], + }); + + await processor.updateArtifacts( + { + packageFileName: 'folder/pyproject.toml', + newPackageFileContent: '', + config: { + isLockFileMaintenance: true, + minimumReleaseAge: 'invalid garbage', + }, + updatedDeps: [], + }, + parsePyProject('')!, + ); + expect(execSnapshots).toMatchObject([ + { + cmd: 'uv lock --upgrade', + }, + ]); + expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); + expect(logger.logger.debug).toHaveBeenCalledWith( + { minimumReleaseAge: 'invalid garbage' }, + 'Invalid minimumReleaseAge value, skipping --exclude-newer for uv lock', + ); + }); + + it('skips --exclude-newer on empty string minimumReleaseAge', async () => { + const execSnapshots = mockExecAll(); + GlobalConfig.set(adminConfig); + fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); + fs.readLocalFile.mockResolvedValueOnce('test content'); + fs.readLocalFile.mockResolvedValueOnce('changed test content'); + // python + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '3.11.1' }], + }); + // uv + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '0.2.35' }], + }); + + await processor.updateArtifacts( + { + packageFileName: 'folder/pyproject.toml', + newPackageFileContent: '', + config: { + isLockFileMaintenance: true, + minimumReleaseAge: '', + }, + updatedDeps: [], + }, + parsePyProject('')!, + ); + expect(execSnapshots).toMatchObject([ + { + cmd: 'uv lock --upgrade', + }, + ]); + expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); + }); + }); }); }); From 0b4d5b39166a32d64a8d60f0368ddb7f0af3b05b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Sun, 15 Mar 2026 09:46:45 +0100 Subject: [PATCH 04/16] chore: CR --- lib/modules/manager/pep621/processors/uv.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/modules/manager/pep621/processors/uv.ts b/lib/modules/manager/pep621/processors/uv.ts index 3b6f2b13832..3e2fc9c651c 100644 --- a/lib/modules/manager/pep621/processors/uv.ts +++ b/lib/modules/manager/pep621/processors/uv.ts @@ -1,4 +1,5 @@ -import { isString } from '@sindresorhus/is'; +import { isNullOrUndefined, isString } from '@sindresorhus/is'; +import { DateTime } from 'luxon'; import { quote } from 'shlex'; import { TEMPORARY_ERROR } from '../../../../constants/error-messages.ts'; import { logger } from '../../../../logger/index.ts'; @@ -219,14 +220,14 @@ export class UvProcessor extends BasePyProjectProcessor { if (config.minimumReleaseAge) { const ms = toMs(config.minimumReleaseAge); - if (ms !== null && ms !== undefined) { - const excludeNewer = new Date(Date.now() - ms).toISOString(); - cmd += ` --exclude-newer=${excludeNewer}`; - } else { + if (isNullOrUndefined(ms)) { logger.debug( { minimumReleaseAge: config.minimumReleaseAge }, 'Invalid minimumReleaseAge value, skipping --exclude-newer for uv lock', ); + } else { + const excludeNewer = DateTime.now().minus(ms).toUTC().toISO(); + cmd += ` --exclude-newer=${excludeNewer}`; } } From 2fc7070feb8c307ece39a242dac02af49db8142b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Sun, 15 Mar 2026 10:15:05 +0100 Subject: [PATCH 05/16] refactor(pep621): use UV_EXCLUDE_NEWER env var with pyproject min-date logic --- .../manager/pep621/processors/uv.spec.ts | 226 ++++++++++++++++-- lib/modules/manager/pep621/processors/uv.ts | 25 +- lib/modules/manager/pep621/schema.ts | 3 + 3 files changed, 236 insertions(+), 18 deletions(-) diff --git a/lib/modules/manager/pep621/processors/uv.spec.ts b/lib/modules/manager/pep621/processors/uv.spec.ts index b4a6004b91e..a7064c755fe 100644 --- a/lib/modules/manager/pep621/processors/uv.spec.ts +++ b/lib/modules/manager/pep621/processors/uv.spec.ts @@ -892,12 +892,12 @@ describe('modules/manager/pep621/processors/uv', () => { ]); }); - describe('--exclude-newer with minimumReleaseAge', () => { + describe('UV_EXCLUDE_NEWER with minimumReleaseAge', () => { beforeEach(() => { vi.restoreAllMocks(); }); - it('adds --exclude-newer on lockfile maintenance with minimumReleaseAge', async () => { + it('sets UV_EXCLUDE_NEWER env var on lockfile maintenance with minimumReleaseAge', async () => { vi.spyOn(Date, 'now').mockReturnValue( new Date('2026-03-13T00:00:00.000Z').getTime(), ); @@ -936,14 +936,14 @@ describe('modules/manager/pep621/processors/uv', () => { }, }, ]); - expect(execSnapshots).toMatchObject([ - { - cmd: 'uv lock --upgrade --exclude-newer=2026-03-10T00:00:00.000Z', - }, - ]); + expect(execSnapshots).toMatchObject([{ cmd: 'uv lock --upgrade' }]); + expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); + expect(execSnapshots[0].options?.env).toMatchObject({ + UV_EXCLUDE_NEWER: '2026-03-10T00:00:00.000Z', + }); }); - it('adds --exclude-newer on per-package update with minimumReleaseAge', async () => { + it('sets UV_EXCLUDE_NEWER env var on per-package update with minimumReleaseAge', async () => { vi.spyOn(Date, 'now').mockReturnValue( new Date('2026-03-13T00:00:00.000Z').getTime(), ); @@ -985,13 +985,15 @@ describe('modules/manager/pep621/processors/uv', () => { }, ]); expect(execSnapshots).toMatchObject([ - { - cmd: 'uv lock --upgrade-package dep1 --exclude-newer=2026-03-06T00:00:00.000Z', - }, + { cmd: 'uv lock --upgrade-package dep1' }, ]); + expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); + expect(execSnapshots[0].options?.env).toMatchObject({ + UV_EXCLUDE_NEWER: '2026-03-06T00:00:00.000Z', + }); }); - it('does not add --exclude-newer when no minimumReleaseAge', async () => { + it('does not set UV_EXCLUDE_NEWER when no minimumReleaseAge', async () => { const execSnapshots = mockExecAll(); GlobalConfig.set(adminConfig); fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); @@ -1023,9 +1025,10 @@ describe('modules/manager/pep621/processors/uv', () => { }, ]); expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); + expect(execSnapshots[0].options?.env?.UV_EXCLUDE_NEWER).toBeUndefined(); }); - it('skips --exclude-newer on invalid minimumReleaseAge', async () => { + it('does not set UV_EXCLUDE_NEWER on invalid minimumReleaseAge', async () => { const execSnapshots = mockExecAll(); GlobalConfig.set(adminConfig); fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); @@ -1058,13 +1061,14 @@ describe('modules/manager/pep621/processors/uv', () => { }, ]); expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); + expect(execSnapshots[0].options?.env?.UV_EXCLUDE_NEWER).toBeUndefined(); expect(logger.logger.debug).toHaveBeenCalledWith( { minimumReleaseAge: 'invalid garbage' }, - 'Invalid minimumReleaseAge value, skipping --exclude-newer for uv lock', + 'Invalid minimumReleaseAge value, skipping UV_EXCLUDE_NEWER for uv lock', ); }); - it('skips --exclude-newer on empty string minimumReleaseAge', async () => { + it('does not set UV_EXCLUDE_NEWER on empty string minimumReleaseAge', async () => { const execSnapshots = mockExecAll(); GlobalConfig.set(adminConfig); fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); @@ -1097,6 +1101,198 @@ describe('modules/manager/pep621/processors/uv', () => { }, ]); expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); + expect(execSnapshots[0].options?.env?.UV_EXCLUDE_NEWER).toBeUndefined(); + }); + + it('uses pyproject exclude-newer when it is more restrictive than minimumReleaseAge', async () => { + vi.spyOn(Date, 'now').mockReturnValue( + new Date('2026-03-13T00:00:00.000Z').getTime(), + ); + const execSnapshots = mockExecAll(); + GlobalConfig.set(adminConfig); + fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); + fs.readLocalFile.mockResolvedValueOnce('test content'); + fs.readLocalFile.mockResolvedValueOnce('changed test content'); + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '3.11.1' }], + }); + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '0.2.35' }], + }); + + const pyproject = parsePyProject(codeBlock` + [tool.uv] + exclude-newer = "2026-03-05T00:00:00.000Z" + `)!; + + await processor.updateArtifacts( + { + packageFileName: 'folder/pyproject.toml', + newPackageFileContent: '', + config: { + isLockFileMaintenance: true, + minimumReleaseAge: '3 days', + }, + updatedDeps: [], + }, + pyproject, + ); + expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); + expect(execSnapshots[0].options?.env).toMatchObject({ + UV_EXCLUDE_NEWER: '2026-03-05T00:00:00.000Z', + }); + }); + + it('uses minimumReleaseAge date when it is more restrictive than pyproject exclude-newer', async () => { + vi.spyOn(Date, 'now').mockReturnValue( + new Date('2026-03-13T00:00:00.000Z').getTime(), + ); + const execSnapshots = mockExecAll(); + GlobalConfig.set(adminConfig); + fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); + fs.readLocalFile.mockResolvedValueOnce('test content'); + fs.readLocalFile.mockResolvedValueOnce('changed test content'); + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '3.11.1' }], + }); + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '0.2.35' }], + }); + + const pyproject = parsePyProject(codeBlock` + [tool.uv] + exclude-newer = "2026-03-12T00:00:00.000Z" + `)!; + + await processor.updateArtifacts( + { + packageFileName: 'folder/pyproject.toml', + newPackageFileContent: '', + config: { + isLockFileMaintenance: true, + minimumReleaseAge: '3 days', + }, + updatedDeps: [], + }, + pyproject, + ); + expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); + expect(execSnapshots[0].options?.env).toMatchObject({ + UV_EXCLUDE_NEWER: '2026-03-10T00:00:00.000Z', + }); + }); + + it('ignores invalid exclude-newer in pyproject.toml and uses minimumReleaseAge', async () => { + vi.spyOn(Date, 'now').mockReturnValue( + new Date('2026-03-13T00:00:00.000Z').getTime(), + ); + const execSnapshots = mockExecAll(); + GlobalConfig.set(adminConfig); + fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); + fs.readLocalFile.mockResolvedValueOnce('test content'); + fs.readLocalFile.mockResolvedValueOnce('changed test content'); + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '3.11.1' }], + }); + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '0.2.35' }], + }); + + const pyproject = parsePyProject(codeBlock` + [tool.uv] + exclude-newer = "not-a-date" + `)!; + + await processor.updateArtifacts( + { + packageFileName: 'folder/pyproject.toml', + newPackageFileContent: '', + config: { + isLockFileMaintenance: true, + minimumReleaseAge: '3 days', + }, + updatedDeps: [], + }, + pyproject, + ); + expect(execSnapshots[0].options?.env).toMatchObject({ + UV_EXCLUDE_NEWER: '2026-03-10T00:00:00.000Z', + }); + expect(logger.logger.debug).toHaveBeenCalledWith( + { excludeNewer: 'not-a-date' }, + 'Invalid exclude-newer value in pyproject.toml, ignoring', + ); + }); + + it('does not set UV_EXCLUDE_NEWER when no minimumReleaseAge even if pyproject has exclude-newer', async () => { + const execSnapshots = mockExecAll(); + GlobalConfig.set(adminConfig); + fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); + fs.readLocalFile.mockResolvedValueOnce('test content'); + fs.readLocalFile.mockResolvedValueOnce('changed test content'); + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '3.11.1' }], + }); + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '0.2.35' }], + }); + + const pyproject = parsePyProject(codeBlock` + [tool.uv] + exclude-newer = "2026-03-05T00:00:00.000Z" + `)!; + + await processor.updateArtifacts( + { + packageFileName: 'folder/pyproject.toml', + newPackageFileContent: '', + config: { + isLockFileMaintenance: true, + }, + updatedDeps: [], + }, + pyproject, + ); + expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); + expect(execSnapshots[0].options?.env?.UV_EXCLUDE_NEWER).toBeUndefined(); + }); + + it('handles pyproject exclude-newer as local date without time component', async () => { + vi.spyOn(Date, 'now').mockReturnValue( + new Date('2026-03-13T00:00:00.000Z').getTime(), + ); + const execSnapshots = mockExecAll(); + GlobalConfig.set(adminConfig); + fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); + fs.readLocalFile.mockResolvedValueOnce('test content'); + fs.readLocalFile.mockResolvedValueOnce('changed test content'); + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '3.11.1' }], + }); + getPkgReleases.mockResolvedValueOnce({ + releases: [{ version: '0.2.35' }], + }); + + const pyproject = parsePyProject(codeBlock` + [tool.uv] + exclude-newer = "2026-03-05" + `)!; + + await processor.updateArtifacts( + { + packageFileName: 'folder/pyproject.toml', + newPackageFileContent: '', + config: { + isLockFileMaintenance: true, + minimumReleaseAge: '3 days', + }, + updatedDeps: [], + }, + pyproject, + ); + expect(execSnapshots[0].options?.env).toMatchObject({ + UV_EXCLUDE_NEWER: '2026-03-05T00:00:00.000Z', + }); }); }); }); diff --git a/lib/modules/manager/pep621/processors/uv.ts b/lib/modules/manager/pep621/processors/uv.ts index 3e2fc9c651c..f15bdb9fd7a 100644 --- a/lib/modules/manager/pep621/processors/uv.ts +++ b/lib/modules/manager/pep621/processors/uv.ts @@ -218,16 +218,35 @@ export class UvProcessor extends BasePyProjectProcessor { cmd = generateCMD(updatedDeps); } + // See https://docs.astral.sh/uv/guides/integration/dependency-bots/#dependency-cooldown if (config.minimumReleaseAge) { const ms = toMs(config.minimumReleaseAge); if (isNullOrUndefined(ms)) { logger.debug( { minimumReleaseAge: config.minimumReleaseAge }, - 'Invalid minimumReleaseAge value, skipping --exclude-newer for uv lock', + 'Invalid minimumReleaseAge value, skipping UV_EXCLUDE_NEWER for uv lock', ); } else { - const excludeNewer = DateTime.now().minus(ms).toUTC().toISO(); - cmd += ` --exclude-newer=${excludeNewer}`; + let excludeNewerDate = DateTime.now().minus(ms).toUTC(); + + const pyprojectExcludeNewer = project.tool?.uv?.['exclude-newer']; + if (pyprojectExcludeNewer) { + const pyprojectDate = DateTime.fromISO(pyprojectExcludeNewer, { + zone: 'utc', + }); + if (pyprojectDate.isValid) { + if (pyprojectDate < excludeNewerDate) { + excludeNewerDate = pyprojectDate; + } + } else { + logger.debug( + { excludeNewer: pyprojectExcludeNewer }, + 'Invalid exclude-newer value in pyproject.toml, ignoring', + ); + } + } + + extraEnv.UV_EXCLUDE_NEWER = excludeNewerDate.toISO()!; } } diff --git a/lib/modules/manager/pep621/schema.ts b/lib/modules/manager/pep621/schema.ts index d9e345fe64f..0b3a4503fe5 100644 --- a/lib/modules/manager/pep621/schema.ts +++ b/lib/modules/manager/pep621/schema.ts @@ -152,6 +152,9 @@ const UvConfig = z.object({ 'dev-dependencies': LooseArray( Pep508Dependency(depTypes.uvDevDependencies), ).catch([]), + 'exclude-newer': z + .union([z.string(), z.date().transform((d) => d.toISOString())]) + .optional(), 'required-version': z.string().optional(), sources: LooseRecord( // uv applies the same normalization as for Python dependencies on sources From 335a5d9f9b32a258c278799f4572d903f2735414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Sun, 15 Mar 2026 13:19:09 +0100 Subject: [PATCH 06/16] docs(pep621): add uv exclude-newer note to minimum-release-age docs --- docs/usage/key-concepts/minimum-release-age.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/usage/key-concepts/minimum-release-age.md b/docs/usage/key-concepts/minimum-release-age.md index e7b524c2cc6..ed26055b14d 100644 --- a/docs/usage/key-concepts/minimum-release-age.md +++ b/docs/usage/key-concepts/minimum-release-age.md @@ -44,6 +44,19 @@ To protect against this, it's recommended to ensure that your package manager co There is ongoing work to [integrate more closely with package manager checks](https://github.com/renovatebot/renovate/issues/41652) to make sure that Renovate's minimum release age configuration is specified when calling package managers that support it. If you have a package manager you'd like supported, please raise a [Suggest an Idea Discussion](https://github.com/renovatebot/renovate/discussions/new?category=suggest-an-idea). +#### npm + +When `minimumReleaseAge` is configured, Renovate passes `--before=` to npm commands during lock file generation. +This ensures that npm only resolves package versions that were available before the cooldown threshold, protecting against newly published (and potentially malicious) transitive dependencies. + +The `--before` date is calculated as `now - minimumReleaseAge`. +If a `before=` or `min-release-age=` setting already exists in the project's `.npmrc`, Renovate uses the stricter (older) of the two dates. + + +!!! note + For uv, Renovate reads `[tool.uv] exclude-newer` from `pyproject.toml` and uses the more restrictive (older) date between it and `minimumReleaseAge`. + See uv's [dependency bot integration guide](https://docs.astral.sh/uv/guides/integration/dependency-bots/#dependency-cooldown) for details on configuring `exclude-newer`. + ### What happens if the datasource and/or registry does not provide a release timestamp, when using `minimumReleaseAge`? From 341376e0ec02cda58c7d2ed2c7a2afcd5cca53e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Sun, 15 Mar 2026 15:05:48 +0100 Subject: [PATCH 07/16] test(pep621): cover z.date().transform() branch in exclude-newer schema --- lib/modules/manager/pep621/processors/uv.spec.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/modules/manager/pep621/processors/uv.spec.ts b/lib/modules/manager/pep621/processors/uv.spec.ts index a7064c755fe..fcfedf3f9ae 100644 --- a/lib/modules/manager/pep621/processors/uv.spec.ts +++ b/lib/modules/manager/pep621/processors/uv.spec.ts @@ -14,6 +14,7 @@ import { getPkgReleases as _getPkgReleases } from '../../../datasource/index.ts' import { PypiDatasource } from '../../../datasource/pypi/index.ts'; import type { UpdateArtifact, UpdateArtifactsConfig } from '../../types.ts'; import { parsePyProject } from '../extract.ts'; +import { PyProject } from '../schema.ts'; import { depTypes } from '../utils.ts'; import { UvProcessor } from './uv.ts'; @@ -1294,6 +1295,17 @@ describe('modules/manager/pep621/processors/uv', () => { UV_EXCLUDE_NEWER: '2026-03-05T00:00:00.000Z', }); }); + + it('handles exclude-newer as Date object in schema', () => { + const result = PyProject.parse({ + tool: { + uv: { 'exclude-newer': new Date('2026-03-05T00:00:00.000Z') }, + }, + }); + expect(result.tool?.uv?.['exclude-newer']).toBe( + '2026-03-05T00:00:00.000Z', + ); + }); }); }); }); From f2bd5e3d8ada6446adcb902a9148311225617efe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Mon, 23 Mar 2026 13:48:33 +0100 Subject: [PATCH 08/16] fix(pep621): address review comments on UV_EXCLUDE_NEWER --- .../usage/key-concepts/minimum-release-age.md | 1 + .../manager/pep621/processors/uv.spec.ts | 289 ++---------------- lib/modules/manager/pep621/processors/uv.ts | 14 +- lib/modules/manager/types.ts | 2 +- 4 files changed, 37 insertions(+), 269 deletions(-) diff --git a/docs/usage/key-concepts/minimum-release-age.md b/docs/usage/key-concepts/minimum-release-age.md index ed26055b14d..17c88d6b79e 100644 --- a/docs/usage/key-concepts/minimum-release-age.md +++ b/docs/usage/key-concepts/minimum-release-age.md @@ -55,6 +55,7 @@ If a `before=` or `min-release-age=` setting already exists in the p !!! note For uv, Renovate reads `[tool.uv] exclude-newer` from `pyproject.toml` and uses the more restrictive (older) date between it and `minimumReleaseAge`. + The `exclude-newer` value can be an RFC 3339 timestamp (e.g. `2025-01-01T00:00:00Z`) or a friendly duration (e.g. `2 weeks`), but ISO 8601 durations (e.g. `P14D`) are not supported. See uv's [dependency bot integration guide](https://docs.astral.sh/uv/guides/integration/dependency-bots/#dependency-cooldown) for details on configuring `exclude-newer`. ### What happens if the datasource and/or registry does not provide a release timestamp, when using `minimumReleaseAge`? diff --git a/lib/modules/manager/pep621/processors/uv.spec.ts b/lib/modules/manager/pep621/processors/uv.spec.ts index fcfedf3f9ae..290f11a1ff7 100644 --- a/lib/modules/manager/pep621/processors/uv.spec.ts +++ b/lib/modules/manager/pep621/processors/uv.spec.ts @@ -894,29 +894,31 @@ describe('modules/manager/pep621/processors/uv', () => { }); describe('UV_EXCLUDE_NEWER with minimumReleaseAge', () => { - beforeEach(() => { - vi.restoreAllMocks(); - }); + let execSnapshots: ReturnType; - it('sets UV_EXCLUDE_NEWER env var on lockfile maintenance with minimumReleaseAge', async () => { + beforeEach(() => { vi.spyOn(Date, 'now').mockReturnValue( new Date('2026-03-13T00:00:00.000Z').getTime(), ); - const execSnapshots = mockExecAll(); + execSnapshots = mockExecAll(); GlobalConfig.set(adminConfig); fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); fs.readLocalFile.mockResolvedValueOnce('test content'); fs.readLocalFile.mockResolvedValueOnce('changed test content'); - // python getPkgReleases.mockResolvedValueOnce({ releases: [{ version: '3.11.1' }], }); - // uv getPkgReleases.mockResolvedValueOnce({ releases: [{ version: '0.2.35' }], }); + }); - const result = await processor.updateArtifacts( + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('sets UV_EXCLUDE_NEWER from minimumReleaseAge', async () => { + await processor.updateArtifacts( { packageFileName: 'folder/pyproject.toml', newPackageFileContent: '', @@ -928,122 +930,25 @@ describe('modules/manager/pep621/processors/uv', () => { }, parsePyProject('')!, ); - expect(result).toEqual([ - { - file: { - contents: 'changed test content', - path: 'uv.lock', - type: 'addition', - }, - }, - ]); - expect(execSnapshots).toMatchObject([{ cmd: 'uv lock --upgrade' }]); - expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); expect(execSnapshots[0].options?.env).toMatchObject({ UV_EXCLUDE_NEWER: '2026-03-10T00:00:00.000Z', }); }); - it('sets UV_EXCLUDE_NEWER env var on per-package update with minimumReleaseAge', async () => { - vi.spyOn(Date, 'now').mockReturnValue( - new Date('2026-03-13T00:00:00.000Z').getTime(), - ); - const execSnapshots = mockExecAll(); - GlobalConfig.set(adminConfig); - fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); - fs.readLocalFile.mockResolvedValueOnce('test content'); - fs.readLocalFile.mockResolvedValueOnce('changed test content'); - // python - getPkgReleases.mockResolvedValueOnce({ - releases: [{ version: '3.11.1' }], - }); - // uv - getPkgReleases.mockResolvedValueOnce({ - releases: [{ version: '0.2.35' }], - }); - - const updatedDeps = [ - { packageName: 'dep1', depType: depTypes.dependencies }, - ]; - const result = await processor.updateArtifacts( - { - packageFileName: 'folder/pyproject.toml', - newPackageFileContent: '', - config: { - minimumReleaseAge: '7 days', - }, - updatedDeps, - }, - parsePyProject('')!, - ); - expect(result).toEqual([ - { - file: { - contents: 'changed test content', - path: 'uv.lock', - type: 'addition', - }, - }, - ]); - expect(execSnapshots).toMatchObject([ - { cmd: 'uv lock --upgrade-package dep1' }, - ]); - expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); - expect(execSnapshots[0].options?.env).toMatchObject({ - UV_EXCLUDE_NEWER: '2026-03-06T00:00:00.000Z', - }); - }); - - it('does not set UV_EXCLUDE_NEWER when no minimumReleaseAge', async () => { - const execSnapshots = mockExecAll(); - GlobalConfig.set(adminConfig); - fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); - fs.readLocalFile.mockResolvedValueOnce('test content'); - fs.readLocalFile.mockResolvedValueOnce('changed test content'); - // python - getPkgReleases.mockResolvedValueOnce({ - releases: [{ version: '3.11.1' }], - }); - // uv - getPkgReleases.mockResolvedValueOnce({ - releases: [{ version: '0.2.35' }], - }); - + it('skips UV_EXCLUDE_NEWER when minimumReleaseAge absent', async () => { await processor.updateArtifacts( { packageFileName: 'folder/pyproject.toml', newPackageFileContent: '', - config: { - isLockFileMaintenance: true, - }, + config: { isLockFileMaintenance: true }, updatedDeps: [], }, parsePyProject('')!, ); - expect(execSnapshots).toMatchObject([ - { - cmd: 'uv lock --upgrade', - }, - ]); - expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); expect(execSnapshots[0].options?.env?.UV_EXCLUDE_NEWER).toBeUndefined(); }); - it('does not set UV_EXCLUDE_NEWER on invalid minimumReleaseAge', async () => { - const execSnapshots = mockExecAll(); - GlobalConfig.set(adminConfig); - fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); - fs.readLocalFile.mockResolvedValueOnce('test content'); - fs.readLocalFile.mockResolvedValueOnce('changed test content'); - // python - getPkgReleases.mockResolvedValueOnce({ - releases: [{ version: '3.11.1' }], - }); - // uv - getPkgReleases.mockResolvedValueOnce({ - releases: [{ version: '0.2.35' }], - }); - + it('skips UV_EXCLUDE_NEWER on unparseable minimumReleaseAge', async () => { await processor.updateArtifacts( { packageFileName: 'folder/pyproject.toml', @@ -1056,76 +961,17 @@ describe('modules/manager/pep621/processors/uv', () => { }, parsePyProject('')!, ); - expect(execSnapshots).toMatchObject([ - { - cmd: 'uv lock --upgrade', - }, - ]); - expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); expect(execSnapshots[0].options?.env?.UV_EXCLUDE_NEWER).toBeUndefined(); expect(logger.logger.debug).toHaveBeenCalledWith( - { minimumReleaseAge: 'invalid garbage' }, - 'Invalid minimumReleaseAge value, skipping UV_EXCLUDE_NEWER for uv lock', + "Invalid minimumReleaseAge value 'invalid garbage', skipping UV_EXCLUDE_NEWER for uv lock", ); }); - it('does not set UV_EXCLUDE_NEWER on empty string minimumReleaseAge', async () => { - const execSnapshots = mockExecAll(); - GlobalConfig.set(adminConfig); - fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); - fs.readLocalFile.mockResolvedValueOnce('test content'); - fs.readLocalFile.mockResolvedValueOnce('changed test content'); - // python - getPkgReleases.mockResolvedValueOnce({ - releases: [{ version: '3.11.1' }], - }); - // uv - getPkgReleases.mockResolvedValueOnce({ - releases: [{ version: '0.2.35' }], - }); - - await processor.updateArtifacts( - { - packageFileName: 'folder/pyproject.toml', - newPackageFileContent: '', - config: { - isLockFileMaintenance: true, - minimumReleaseAge: '', - }, - updatedDeps: [], - }, - parsePyProject('')!, - ); - expect(execSnapshots).toMatchObject([ - { - cmd: 'uv lock --upgrade', - }, - ]); - expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); - expect(execSnapshots[0].options?.env?.UV_EXCLUDE_NEWER).toBeUndefined(); - }); - - it('uses pyproject exclude-newer when it is more restrictive than minimumReleaseAge', async () => { - vi.spyOn(Date, 'now').mockReturnValue( - new Date('2026-03-13T00:00:00.000Z').getTime(), - ); - const execSnapshots = mockExecAll(); - GlobalConfig.set(adminConfig); - fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); - fs.readLocalFile.mockResolvedValueOnce('test content'); - fs.readLocalFile.mockResolvedValueOnce('changed test content'); - getPkgReleases.mockResolvedValueOnce({ - releases: [{ version: '3.11.1' }], - }); - getPkgReleases.mockResolvedValueOnce({ - releases: [{ version: '0.2.35' }], - }); - + it('uses pyproject ISO date when more restrictive', async () => { const pyproject = parsePyProject(codeBlock` [tool.uv] exclude-newer = "2026-03-05T00:00:00.000Z" `)!; - await processor.updateArtifacts( { packageFileName: 'folder/pyproject.toml', @@ -1138,33 +984,16 @@ describe('modules/manager/pep621/processors/uv', () => { }, pyproject, ); - expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); expect(execSnapshots[0].options?.env).toMatchObject({ UV_EXCLUDE_NEWER: '2026-03-05T00:00:00.000Z', }); }); - it('uses minimumReleaseAge date when it is more restrictive than pyproject exclude-newer', async () => { - vi.spyOn(Date, 'now').mockReturnValue( - new Date('2026-03-13T00:00:00.000Z').getTime(), - ); - const execSnapshots = mockExecAll(); - GlobalConfig.set(adminConfig); - fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); - fs.readLocalFile.mockResolvedValueOnce('test content'); - fs.readLocalFile.mockResolvedValueOnce('changed test content'); - getPkgReleases.mockResolvedValueOnce({ - releases: [{ version: '3.11.1' }], - }); - getPkgReleases.mockResolvedValueOnce({ - releases: [{ version: '0.2.35' }], - }); - + it('uses minimumReleaseAge when pyproject less restrictive', async () => { const pyproject = parsePyProject(codeBlock` [tool.uv] exclude-newer = "2026-03-12T00:00:00.000Z" `)!; - await processor.updateArtifacts( { packageFileName: 'folder/pyproject.toml', @@ -1177,33 +1006,16 @@ describe('modules/manager/pep621/processors/uv', () => { }, pyproject, ); - expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); expect(execSnapshots[0].options?.env).toMatchObject({ UV_EXCLUDE_NEWER: '2026-03-10T00:00:00.000Z', }); }); - it('ignores invalid exclude-newer in pyproject.toml and uses minimumReleaseAge', async () => { - vi.spyOn(Date, 'now').mockReturnValue( - new Date('2026-03-13T00:00:00.000Z').getTime(), - ); - const execSnapshots = mockExecAll(); - GlobalConfig.set(adminConfig); - fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); - fs.readLocalFile.mockResolvedValueOnce('test content'); - fs.readLocalFile.mockResolvedValueOnce('changed test content'); - getPkgReleases.mockResolvedValueOnce({ - releases: [{ version: '3.11.1' }], - }); - getPkgReleases.mockResolvedValueOnce({ - releases: [{ version: '0.2.35' }], - }); - + it('uses pyproject relative duration when more restrictive', async () => { const pyproject = parsePyProject(codeBlock` [tool.uv] - exclude-newer = "not-a-date" + exclude-newer = "2 weeks" `)!; - await processor.updateArtifacts( { packageFileName: 'folder/pyproject.toml', @@ -1216,69 +1028,17 @@ describe('modules/manager/pep621/processors/uv', () => { }, pyproject, ); + // 2 weeks = 14 days ago = 2026-02-27, which is older than 3 days ago = 2026-03-10 expect(execSnapshots[0].options?.env).toMatchObject({ - UV_EXCLUDE_NEWER: '2026-03-10T00:00:00.000Z', - }); - expect(logger.logger.debug).toHaveBeenCalledWith( - { excludeNewer: 'not-a-date' }, - 'Invalid exclude-newer value in pyproject.toml, ignoring', - ); - }); - - it('does not set UV_EXCLUDE_NEWER when no minimumReleaseAge even if pyproject has exclude-newer', async () => { - const execSnapshots = mockExecAll(); - GlobalConfig.set(adminConfig); - fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); - fs.readLocalFile.mockResolvedValueOnce('test content'); - fs.readLocalFile.mockResolvedValueOnce('changed test content'); - getPkgReleases.mockResolvedValueOnce({ - releases: [{ version: '3.11.1' }], - }); - getPkgReleases.mockResolvedValueOnce({ - releases: [{ version: '0.2.35' }], + UV_EXCLUDE_NEWER: '2026-02-27T00:00:00.000Z', }); - - const pyproject = parsePyProject(codeBlock` - [tool.uv] - exclude-newer = "2026-03-05T00:00:00.000Z" - `)!; - - await processor.updateArtifacts( - { - packageFileName: 'folder/pyproject.toml', - newPackageFileContent: '', - config: { - isLockFileMaintenance: true, - }, - updatedDeps: [], - }, - pyproject, - ); - expect(execSnapshots[0].cmd).not.toContain('--exclude-newer'); - expect(execSnapshots[0].options?.env?.UV_EXCLUDE_NEWER).toBeUndefined(); }); - it('handles pyproject exclude-newer as local date without time component', async () => { - vi.spyOn(Date, 'now').mockReturnValue( - new Date('2026-03-13T00:00:00.000Z').getTime(), - ); - const execSnapshots = mockExecAll(); - GlobalConfig.set(adminConfig); - fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); - fs.readLocalFile.mockResolvedValueOnce('test content'); - fs.readLocalFile.mockResolvedValueOnce('changed test content'); - getPkgReleases.mockResolvedValueOnce({ - releases: [{ version: '3.11.1' }], - }); - getPkgReleases.mockResolvedValueOnce({ - releases: [{ version: '0.2.35' }], - }); - + it('ignores unparseable pyproject exclude-newer and uses minimumReleaseAge', async () => { const pyproject = parsePyProject(codeBlock` [tool.uv] - exclude-newer = "2026-03-05" + exclude-newer = "not-a-date" `)!; - await processor.updateArtifacts( { packageFileName: 'folder/pyproject.toml', @@ -1292,8 +1052,11 @@ describe('modules/manager/pep621/processors/uv', () => { pyproject, ); expect(execSnapshots[0].options?.env).toMatchObject({ - UV_EXCLUDE_NEWER: '2026-03-05T00:00:00.000Z', + UV_EXCLUDE_NEWER: '2026-03-10T00:00:00.000Z', }); + expect(logger.logger.debug).toHaveBeenCalledWith( + "Invalid exclude-newer value 'not-a-date' in pyproject.toml, ignoring", + ); }); it('handles exclude-newer as Date object in schema', () => { diff --git a/lib/modules/manager/pep621/processors/uv.ts b/lib/modules/manager/pep621/processors/uv.ts index f15bdb9fd7a..7fa8a6afc49 100644 --- a/lib/modules/manager/pep621/processors/uv.ts +++ b/lib/modules/manager/pep621/processors/uv.ts @@ -223,25 +223,29 @@ export class UvProcessor extends BasePyProjectProcessor { const ms = toMs(config.minimumReleaseAge); if (isNullOrUndefined(ms)) { logger.debug( - { minimumReleaseAge: config.minimumReleaseAge }, - 'Invalid minimumReleaseAge value, skipping UV_EXCLUDE_NEWER for uv lock', + `Invalid minimumReleaseAge value '${config.minimumReleaseAge}', skipping UV_EXCLUDE_NEWER for uv lock`, ); } else { let excludeNewerDate = DateTime.now().minus(ms).toUTC(); const pyprojectExcludeNewer = project.tool?.uv?.['exclude-newer']; if (pyprojectExcludeNewer) { - const pyprojectDate = DateTime.fromISO(pyprojectExcludeNewer, { + let pyprojectDate = DateTime.fromISO(pyprojectExcludeNewer, { zone: 'utc', }); + if (!pyprojectDate.isValid) { + const durationMs = toMs(pyprojectExcludeNewer); + if (!isNullOrUndefined(durationMs)) { + pyprojectDate = DateTime.now().minus(durationMs).toUTC(); + } + } if (pyprojectDate.isValid) { if (pyprojectDate < excludeNewerDate) { excludeNewerDate = pyprojectDate; } } else { logger.debug( - { excludeNewer: pyprojectExcludeNewer }, - 'Invalid exclude-newer value in pyproject.toml, ignoring', + `Invalid exclude-newer value '${pyprojectExcludeNewer}' in pyproject.toml, ignoring`, ); } } diff --git a/lib/modules/manager/types.ts b/lib/modules/manager/types.ts index b0556204ad1..1df5be21e8c 100644 --- a/lib/modules/manager/types.ts +++ b/lib/modules/manager/types.ts @@ -50,7 +50,7 @@ export interface UpdateArtifactsConfig { skipArtifactsUpdate?: boolean; lockFiles?: string[]; toolSettings?: ToolSettingsOptions; - minimumReleaseAge?: string | null; + minimumReleaseAge?: string; } export interface RangeConfig> extends ManagerData { From 3c1e7a9ebeb4969efd56aa9862983c337d689323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Mon, 23 Mar 2026 14:18:26 +0100 Subject: [PATCH 09/16] fix(pep621): use Nullish for minimumReleaseAge to match config type --- lib/modules/manager/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/modules/manager/types.ts b/lib/modules/manager/types.ts index 1df5be21e8c..1f649be06c5 100644 --- a/lib/modules/manager/types.ts +++ b/lib/modules/manager/types.ts @@ -50,7 +50,7 @@ export interface UpdateArtifactsConfig { skipArtifactsUpdate?: boolean; lockFiles?: string[]; toolSettings?: ToolSettingsOptions; - minimumReleaseAge?: string; + minimumReleaseAge?: Nullish; } export interface RangeConfig> extends ManagerData { From 9fb85e98bd18162e1c9116ed75af6ded10693de0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Fri, 27 Mar 2026 08:09:26 +0100 Subject: [PATCH 10/16] chore: CR suggestions Co-authored-by: Johannes Feichtner <343448+Churro@users.noreply.github.com> Co-authored-by: RahulGautamSingh --- lib/modules/manager/pep621/processors/uv.spec.ts | 6 +++--- lib/modules/manager/pep621/processors/uv.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/modules/manager/pep621/processors/uv.spec.ts b/lib/modules/manager/pep621/processors/uv.spec.ts index 290f11a1ff7..b08a511da55 100644 --- a/lib/modules/manager/pep621/processors/uv.spec.ts +++ b/lib/modules/manager/pep621/processors/uv.spec.ts @@ -897,9 +897,8 @@ describe('modules/manager/pep621/processors/uv', () => { let execSnapshots: ReturnType; beforeEach(() => { - vi.spyOn(Date, 'now').mockReturnValue( - new Date('2026-03-13T00:00:00.000Z').getTime(), - ); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-13T00:00:00.000Z')); execSnapshots = mockExecAll(); GlobalConfig.set(adminConfig); fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); @@ -915,6 +914,7 @@ describe('modules/manager/pep621/processors/uv', () => { afterEach(() => { vi.restoreAllMocks(); + vi.useRealTimers(); }); it('sets UV_EXCLUDE_NEWER from minimumReleaseAge', async () => { diff --git a/lib/modules/manager/pep621/processors/uv.ts b/lib/modules/manager/pep621/processors/uv.ts index 7fa8a6afc49..20f91bf5288 100644 --- a/lib/modules/manager/pep621/processors/uv.ts +++ b/lib/modules/manager/pep621/processors/uv.ts @@ -250,7 +250,7 @@ export class UvProcessor extends BasePyProjectProcessor { } } - extraEnv.UV_EXCLUDE_NEWER = excludeNewerDate.toISO()!; + extraEnv.UV_EXCLUDE_NEWER = excludeNewerDate.toISO(); } } From a6c6c9089c01258e726a031e006307feaf184c5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Fri, 27 Mar 2026 12:12:56 +0100 Subject: [PATCH 11/16] refactor(pep621): move schema test to schema.spec.ts, use uv section in docs --- docs/usage/key-concepts/minimum-release-age.md | 9 +++++---- lib/modules/manager/pep621/processors/uv.spec.ts | 12 ------------ lib/modules/manager/pep621/schema.spec.ts | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 16 deletions(-) create mode 100644 lib/modules/manager/pep621/schema.spec.ts diff --git a/docs/usage/key-concepts/minimum-release-age.md b/docs/usage/key-concepts/minimum-release-age.md index 17c88d6b79e..2b0fad26ab9 100644 --- a/docs/usage/key-concepts/minimum-release-age.md +++ b/docs/usage/key-concepts/minimum-release-age.md @@ -52,11 +52,12 @@ This ensures that npm only resolves package versions that were available before The `--before` date is calculated as `now - minimumReleaseAge`. If a `before=` or `min-release-age=` setting already exists in the project's `.npmrc`, Renovate uses the stricter (older) of the two dates. +### uv + -!!! note - For uv, Renovate reads `[tool.uv] exclude-newer` from `pyproject.toml` and uses the more restrictive (older) date between it and `minimumReleaseAge`. - The `exclude-newer` value can be an RFC 3339 timestamp (e.g. `2025-01-01T00:00:00Z`) or a friendly duration (e.g. `2 weeks`), but ISO 8601 durations (e.g. `P14D`) are not supported. - See uv's [dependency bot integration guide](https://docs.astral.sh/uv/guides/integration/dependency-bots/#dependency-cooldown) for details on configuring `exclude-newer`. +For uv, Renovate reads `[tool.uv] exclude-newer` from `pyproject.toml` and uses the more restrictive (older) date between it and `minimumReleaseAge`. +The `exclude-newer` value can be an RFC 3339 timestamp (e.g. `2025-01-01T00:00:00Z`) or a friendly duration (e.g. `2 weeks`), but ISO 8601 durations (e.g. `P14D`) are not supported. +See uv's [dependency bot integration guide](https://docs.astral.sh/uv/guides/integration/dependency-bots/#dependency-cooldown) for details on configuring `exclude-newer`. ### What happens if the datasource and/or registry does not provide a release timestamp, when using `minimumReleaseAge`? diff --git a/lib/modules/manager/pep621/processors/uv.spec.ts b/lib/modules/manager/pep621/processors/uv.spec.ts index b08a511da55..990fcce9ca7 100644 --- a/lib/modules/manager/pep621/processors/uv.spec.ts +++ b/lib/modules/manager/pep621/processors/uv.spec.ts @@ -14,7 +14,6 @@ import { getPkgReleases as _getPkgReleases } from '../../../datasource/index.ts' import { PypiDatasource } from '../../../datasource/pypi/index.ts'; import type { UpdateArtifact, UpdateArtifactsConfig } from '../../types.ts'; import { parsePyProject } from '../extract.ts'; -import { PyProject } from '../schema.ts'; import { depTypes } from '../utils.ts'; import { UvProcessor } from './uv.ts'; @@ -1058,17 +1057,6 @@ describe('modules/manager/pep621/processors/uv', () => { "Invalid exclude-newer value 'not-a-date' in pyproject.toml, ignoring", ); }); - - it('handles exclude-newer as Date object in schema', () => { - const result = PyProject.parse({ - tool: { - uv: { 'exclude-newer': new Date('2026-03-05T00:00:00.000Z') }, - }, - }); - expect(result.tool?.uv?.['exclude-newer']).toBe( - '2026-03-05T00:00:00.000Z', - ); - }); }); }); }); diff --git a/lib/modules/manager/pep621/schema.spec.ts b/lib/modules/manager/pep621/schema.spec.ts new file mode 100644 index 00000000000..966ddce8c26 --- /dev/null +++ b/lib/modules/manager/pep621/schema.spec.ts @@ -0,0 +1,16 @@ +import { PyProject } from './schema.ts'; + +describe('modules/manager/pep621/schema', () => { + describe('UvConfig', () => { + it('handles exclude-newer as Date object', () => { + const result = PyProject.parse({ + tool: { + uv: { 'exclude-newer': new Date('2026-03-05T00:00:00.000Z') }, + }, + }); + expect(result.tool?.uv?.['exclude-newer']).toBe( + '2026-03-05T00:00:00.000Z', + ); + }); + }); +}); From 9c3d61461625bd107f336e7a949e068209b3f1ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Fri, 27 Mar 2026 12:57:12 +0100 Subject: [PATCH 12/16] fix(pep621): fix prettier formatting in uv.spec.ts --- lib/modules/manager/pep621/processors/uv.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/modules/manager/pep621/processors/uv.spec.ts b/lib/modules/manager/pep621/processors/uv.spec.ts index 990fcce9ca7..391dcae7f22 100644 --- a/lib/modules/manager/pep621/processors/uv.spec.ts +++ b/lib/modules/manager/pep621/processors/uv.spec.ts @@ -896,8 +896,8 @@ describe('modules/manager/pep621/processors/uv', () => { let execSnapshots: ReturnType; beforeEach(() => { - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-03-13T00:00:00.000Z')); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-13T00:00:00.000Z')); execSnapshots = mockExecAll(); GlobalConfig.set(adminConfig); fs.findLocalSiblingOrParent.mockResolvedValueOnce('uv.lock'); From fe4204632d7c299db2087fc979532a5f7d3998a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Fri, 27 Mar 2026 17:33:30 +0100 Subject: [PATCH 13/16] fix(pep621): skip UV_EXCLUDE_NEWER when minimumReleaseAgeBehaviour is timestamp-optional --- .../manager/pep621/processors/uv.spec.ts | 17 +++++++++++++++++ lib/modules/manager/pep621/processors/uv.ts | 7 ++++++- lib/modules/manager/types.ts | 1 + 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lib/modules/manager/pep621/processors/uv.spec.ts b/lib/modules/manager/pep621/processors/uv.spec.ts index 391dcae7f22..bd0e3722b5c 100644 --- a/lib/modules/manager/pep621/processors/uv.spec.ts +++ b/lib/modules/manager/pep621/processors/uv.spec.ts @@ -947,6 +947,23 @@ describe('modules/manager/pep621/processors/uv', () => { expect(execSnapshots[0].options?.env?.UV_EXCLUDE_NEWER).toBeUndefined(); }); + it('skips UV_EXCLUDE_NEWER when minimumReleaseAgeBehaviour is timestamp-optional', async () => { + await processor.updateArtifacts( + { + packageFileName: 'folder/pyproject.toml', + newPackageFileContent: '', + config: { + isLockFileMaintenance: true, + minimumReleaseAge: '3 days', + minimumReleaseAgeBehaviour: 'timestamp-optional', + }, + updatedDeps: [], + }, + parsePyProject('')!, + ); + expect(execSnapshots[0].options?.env?.UV_EXCLUDE_NEWER).toBeUndefined(); + }); + it('skips UV_EXCLUDE_NEWER on unparseable minimumReleaseAge', async () => { await processor.updateArtifacts( { diff --git a/lib/modules/manager/pep621/processors/uv.ts b/lib/modules/manager/pep621/processors/uv.ts index 20f91bf5288..5f61868cf9d 100644 --- a/lib/modules/manager/pep621/processors/uv.ts +++ b/lib/modules/manager/pep621/processors/uv.ts @@ -219,7 +219,12 @@ export class UvProcessor extends BasePyProjectProcessor { } // See https://docs.astral.sh/uv/guides/integration/dependency-bots/#dependency-cooldown - if (config.minimumReleaseAge) { + // Skip UV_EXCLUDE_NEWER when minimumReleaseAgeBehaviour is 'timestamp-optional', + // as simple API doesn't return releaseTimestamp and the user opted out of requiring it + if ( + config.minimumReleaseAge && + config.minimumReleaseAgeBehaviour !== 'timestamp-optional' + ) { const ms = toMs(config.minimumReleaseAge); if (isNullOrUndefined(ms)) { logger.debug( diff --git a/lib/modules/manager/types.ts b/lib/modules/manager/types.ts index 1f649be06c5..5a5ecb7174a 100644 --- a/lib/modules/manager/types.ts +++ b/lib/modules/manager/types.ts @@ -51,6 +51,7 @@ export interface UpdateArtifactsConfig { lockFiles?: string[]; toolSettings?: ToolSettingsOptions; minimumReleaseAge?: Nullish; + minimumReleaseAgeBehaviour?: MinimumReleaseAgeBehaviour; } export interface RangeConfig> extends ManagerData { From 295c20535ecbbbd8070ab99a6653ceac141ace11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Fri, 27 Mar 2026 17:38:36 +0100 Subject: [PATCH 14/16] docs(pep621): remove npm section from minimum-release-age docs --- docs/usage/key-concepts/minimum-release-age.md | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/docs/usage/key-concepts/minimum-release-age.md b/docs/usage/key-concepts/minimum-release-age.md index 2b0fad26ab9..277df1dc4eb 100644 --- a/docs/usage/key-concepts/minimum-release-age.md +++ b/docs/usage/key-concepts/minimum-release-age.md @@ -44,15 +44,7 @@ To protect against this, it's recommended to ensure that your package manager co There is ongoing work to [integrate more closely with package manager checks](https://github.com/renovatebot/renovate/issues/41652) to make sure that Renovate's minimum release age configuration is specified when calling package managers that support it. If you have a package manager you'd like supported, please raise a [Suggest an Idea Discussion](https://github.com/renovatebot/renovate/discussions/new?category=suggest-an-idea). -#### npm - -When `minimumReleaseAge` is configured, Renovate passes `--before=` to npm commands during lock file generation. -This ensures that npm only resolves package versions that were available before the cooldown threshold, protecting against newly published (and potentially malicious) transitive dependencies. - -The `--before` date is calculated as `now - minimumReleaseAge`. -If a `before=` or `min-release-age=` setting already exists in the project's `.npmrc`, Renovate uses the stricter (older) of the two dates. - -### uv +#### uv For uv, Renovate reads `[tool.uv] exclude-newer` from `pyproject.toml` and uses the more restrictive (older) date between it and `minimumReleaseAge`. From d8c65507a2463b62899a1e75c64ba067a264d53d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Mon, 30 Mar 2026 16:21:17 +0200 Subject: [PATCH 15/16] fix(pep621): add missing imports for Nullish and MinimumReleaseAgeBehaviour --- lib/modules/manager/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/modules/manager/types.ts b/lib/modules/manager/types.ts index 5a5ecb7174a..30047bd0817 100644 --- a/lib/modules/manager/types.ts +++ b/lib/modules/manager/types.ts @@ -1,6 +1,7 @@ import type { ReleaseType } from 'semver'; import type { MatchStringsStrategy, + MinimumReleaseAgeBehaviour, ToolSettingsOptions, UpdateType, ValidationMessage, @@ -9,6 +10,7 @@ import type { Category } from '../../constants/index.ts'; import type { MaybePromise, ModuleApi, + Nullish, RangeStrategy, SkipReason, StageName, From 5af02cd5e9e9746a11e0173835a19041bc05776c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Kol=C3=A1=C5=99?= Date: Wed, 1 Apr 2026 22:04:36 +0200 Subject: [PATCH 16/16] test(pep621): cover UV_EXCLUDE_NEWER for non-maintenance upgrade-package command --- .../manager/pep621/processors/uv.spec.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/modules/manager/pep621/processors/uv.spec.ts b/lib/modules/manager/pep621/processors/uv.spec.ts index bd0e3722b5c..21866a69702 100644 --- a/lib/modules/manager/pep621/processors/uv.spec.ts +++ b/lib/modules/manager/pep621/processors/uv.spec.ts @@ -934,6 +934,27 @@ describe('modules/manager/pep621/processors/uv', () => { }); }); + it('sets UV_EXCLUDE_NEWER for non-maintenance upgrade-package command', async () => { + const updatedDeps = [ + { packageName: 'dep1', depType: depTypes.dependencies }, + ]; + await processor.updateArtifacts( + { + packageFileName: 'folder/pyproject.toml', + newPackageFileContent: '', + config: { + minimumReleaseAge: '3 days', + }, + updatedDeps, + }, + parsePyProject('')!, + ); + expect(execSnapshots[0].cmd).toBe('uv lock --upgrade-package dep1'); + expect(execSnapshots[0].options?.env).toMatchObject({ + UV_EXCLUDE_NEWER: '2026-03-10T00:00:00.000Z', + }); + }); + it('skips UV_EXCLUDE_NEWER when minimumReleaseAge absent', async () => { await processor.updateArtifacts( {