diff --git a/docs/usage/key-concepts/minimum-release-age.md b/docs/usage/key-concepts/minimum-release-age.md index e7b524c2cc6..277df1dc4eb 100644 --- a/docs/usage/key-concepts/minimum-release-age.md +++ b/docs/usage/key-concepts/minimum-release-age.md @@ -44,6 +44,13 @@ 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). +#### uv + + +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 90b9452989a..21866a69702 100644 --- a/lib/modules/manager/pep621/processors/uv.spec.ts +++ b/lib/modules/manager/pep621/processors/uv.spec.ts @@ -891,5 +891,210 @@ describe('modules/manager/pep621/processors/uv', () => { }, ]); }); + + describe('UV_EXCLUDE_NEWER with minimumReleaseAge', () => { + let execSnapshots: ReturnType; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-03-13T00:00:00.000Z')); + 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' }], + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('sets UV_EXCLUDE_NEWER from minimumReleaseAge', async () => { + await processor.updateArtifacts( + { + packageFileName: 'folder/pyproject.toml', + newPackageFileContent: '', + config: { + isLockFileMaintenance: true, + minimumReleaseAge: '3 days', + }, + updatedDeps: [], + }, + parsePyProject('')!, + ); + expect(execSnapshots[0].options?.env).toMatchObject({ + UV_EXCLUDE_NEWER: '2026-03-10T00:00:00.000Z', + }); + }); + + 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( + { + packageFileName: 'folder/pyproject.toml', + newPackageFileContent: '', + config: { isLockFileMaintenance: true }, + updatedDeps: [], + }, + parsePyProject('')!, + ); + 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( + { + packageFileName: 'folder/pyproject.toml', + newPackageFileContent: '', + config: { + isLockFileMaintenance: true, + minimumReleaseAge: 'invalid garbage', + }, + updatedDeps: [], + }, + parsePyProject('')!, + ); + expect(execSnapshots[0].options?.env?.UV_EXCLUDE_NEWER).toBeUndefined(); + expect(logger.logger.debug).toHaveBeenCalledWith( + "Invalid minimumReleaseAge value 'invalid garbage', skipping UV_EXCLUDE_NEWER for uv lock", + ); + }); + + 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', + newPackageFileContent: '', + config: { + isLockFileMaintenance: true, + minimumReleaseAge: '3 days', + }, + updatedDeps: [], + }, + pyproject, + ); + expect(execSnapshots[0].options?.env).toMatchObject({ + UV_EXCLUDE_NEWER: '2026-03-05T00:00:00.000Z', + }); + }); + + 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', + newPackageFileContent: '', + config: { + isLockFileMaintenance: true, + minimumReleaseAge: '3 days', + }, + updatedDeps: [], + }, + pyproject, + ); + expect(execSnapshots[0].options?.env).toMatchObject({ + UV_EXCLUDE_NEWER: '2026-03-10T00:00:00.000Z', + }); + }); + + it('uses pyproject relative duration when more restrictive', async () => { + const pyproject = parsePyProject(codeBlock` + [tool.uv] + exclude-newer = "2 weeks" + `)!; + await processor.updateArtifacts( + { + packageFileName: 'folder/pyproject.toml', + newPackageFileContent: '', + config: { + isLockFileMaintenance: true, + minimumReleaseAge: '3 days', + }, + updatedDeps: [], + }, + 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-02-27T00:00:00.000Z', + }); + }); + + it('ignores unparseable pyproject exclude-newer and uses minimumReleaseAge', async () => { + 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( + "Invalid exclude-newer value 'not-a-date' in pyproject.toml, ignoring", + ); + }); + }); }); }); diff --git a/lib/modules/manager/pep621/processors/uv.ts b/lib/modules/manager/pep621/processors/uv.ts index fe023092a53..5f61868cf9d 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'; @@ -14,6 +15,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 +217,48 @@ export class UvProcessor extends BasePyProjectProcessor { } else { cmd = generateCMD(updatedDeps); } + + // See https://docs.astral.sh/uv/guides/integration/dependency-bots/#dependency-cooldown + // 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( + `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) { + 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( + `Invalid exclude-newer value '${pyprojectExcludeNewer}' in pyproject.toml, ignoring`, + ); + } + } + + extraEnv.UV_EXCLUDE_NEWER = excludeNewerDate.toISO(); + } + } + await exec(cmd, execOptions); // check for changes 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', + ); + }); + }); +}); 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 diff --git a/lib/modules/manager/types.ts b/lib/modules/manager/types.ts index 0be4c0775f8..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, @@ -50,6 +52,8 @@ export interface UpdateArtifactsConfig { skipArtifactsUpdate?: boolean; lockFiles?: string[]; toolSettings?: ToolSettingsOptions; + minimumReleaseAge?: Nullish; + minimumReleaseAgeBehaviour?: MinimumReleaseAgeBehaviour; } export interface RangeConfig> extends ManagerData {