Skip to content

feat(pep621): use exclude-newer during uv lock when minimumReleaseAge is set - #41913

Open
thejoeejoee wants to merge 16 commits into
renovatebot:mainfrom
thejoeejoee:feat/uv-exclude-newer
Open

feat(pep621): use exclude-newer during uv lock when minimumReleaseAge is set#41913
thejoeejoee wants to merge 16 commits into
renovatebot:mainfrom
thejoeejoee:feat/uv-exclude-newer

Conversation

@thejoeejoee

@thejoeejoee thejoeejoee commented Mar 13, 2026

Copy link
Copy Markdown

Changes

When minimumReleaseAge is configured, Renovate now passes UV_EXCLUDE_NEWER as an environment variable to uv lock.

Additionally, if pyproject.toml already defines [tool.uv] exclude-newer, Renovate compares it with its own computed date and uses the more restrictive (older) one. This prevents Renovate from accidentally widening the package resolution window beyond what the project intended.

📖 Related: uv documents recommended exclude-newer usage for dependency bots in their integration guide.

Key changes:

  • lib/modules/manager/types.ts — added minimumReleaseAge to UpdateArtifactsConfig
  • lib/modules/manager/pep621/schema.ts — added exclude-newer to UvConfig Zod schema (handles both TOML string and Date types)
  • lib/modules/manager/pep621/processors/uv.tsUV_EXCLUDE_NEWER env var with min(renovate_date, pyproject_date) logic
  • lib/modules/manager/pep621/processors/uv.spec.ts — 28 tests covering env var usage, min-date comparison, invalid values, and edge cases

Context

AI assistance disclosure

  • Yes — substantive assistance (AI-generated non‑trivial portions of code, tests, or documentation).

opencode (Claude Opus 4.6 / Sonnet 4.6) used for test writing, and code review iteration

Documentation (please check one with an [x])

  • I have updated the documentation

How I've tested my work (please select one)

  • Newly added/modified unit tests

@thejoeejoee
thejoeejoee marked this pull request as ready for review March 13, 2026 19:07
@thejoeejoee
thejoeejoee force-pushed the feat/uv-exclude-newer branch from 5f43c9b to 4bc8168 Compare March 13, 2026 19:07

@RahulGautamSingh RahulGautamSingh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, use the PR template

Comment thread lib/modules/manager/pep621/processors/uv.ts Outdated
Comment thread lib/modules/manager/pep621/processors/uv.ts Outdated

@Churro Churro left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this could benefit from some refinement:

  • With uv, --exclude-newer can also be defined in pyproject.toml or uv.toml (see here). A CLI value always takes precedence but this creates a problem if the pyproject.toml setting is not the same value as minimumReleaseAge:
    • If it is more restrictive (older date), a less restrictive renovate minimumReleaseAge would override it and potentially allow packages the user explicitly wanted to exclude.
    • If it is less restrictive (newer date or absent), renovate correctly tightens the constraint.
    • Suggestion: Read exclude-newer from pyproject.toml and, if present, use the more restrictive (older) date of the two, i.e., min(renovate_minimum_release_age, pyproject_exclude_newer_date).
  • Using UV_EXCLUDE_NEWER instead of --exclude-newer would be more consistent with the existing code style (UV_EXTRA_URL, UV_INDEX_* patterns).
  • --exclude-newer works only since uv 0.2.22. Unlikely that someone still uses such old versions but, strictly speaking, it would be necessary to check if a uv constraint is set and if < 0.2.22 to omit this setting. The elegance of the approach via UV_EXCLUDE_NEWER env var is that incompatible uv versions would simply ignore the setting.
  • Might also be worth a hint in renovate docs, considering that uv also explicitly exemplifies using this combination: https://docs.astral.sh/uv/guides/integration/dependency-bots/#dependency-cooldown

@thejoeejoee
thejoeejoee force-pushed the feat/uv-exclude-newer branch from 5e60f51 to 65df176 Compare March 15, 2026 12:10
@thejoeejoee

thejoeejoee commented Mar 15, 2026

Copy link
Copy Markdown
Author

Thanks, all four points are addressed in the later commits — min() of both dates, UV_EXCLUDE_NEWER env var (so old uv versions just ignore it), and a docs note linking the uv dependency guide.

@thejoeejoee thejoeejoee changed the title feat(pep621): append --exclude-newer to uv lock when minimumReleaseAge is set feat(pep621): use exclude-newer during uv lock when minimumReleaseAge is set Mar 15, 2026
rebkwok added a commit to bennettoxford/bennettbot that referenced this pull request Mar 20, 2026
Note that this currently doesn't work for uv, pending this PR
renovatebot/renovate#41913
(part of an epic to add this feature for all managers -
renovatebot/renovate#41652)

@Churro Churro left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please revise the tests as a whole and make them meaningful.

There's no point in ~500 lines of AI slop just to satisfy code coverage. In particular visible with these:

  • sets UV_EXCLUDE_NEWER on per-package update
    -> same path as sets UV_EXCLUDE_NEWER env var on lockfile ..., only difference is --upgrade-package vs --upgrade, which is already tested elsewhere
  • does not set UV_EXCLUDE_NEWER on empty string minimumReleaseAge
    -> identical path to does not set UV_EXCLUDE_NEWER when no minimumReleaseAge ('' is falsy)
  • does not set UV_EXCLUDE_NEWER when no minimumReleaseAge even if pyproject has exclude-newer
    -> same path as does not set UV_EXCLUDE_NEWER when no minimumReleaseAge, pyproject value is never reached
  • handles pyproject exclude-newer as local date without time component
    -> same path as uses pyproject exclude-newer when more restrictive... , just different input format. Not a distinct code branch
  • handles exclude-newer as Date object in schema
    -> Schema test, not an updateArtifacts code path. Belongs in schema tests if anywhere.

// EDIT: Here's a suggestion of what it could look like:

    describe('UV_EXCLUDE_NEWER with minimumReleaseAge', () => {
      let execSnapshots: ReturnType<typeof mockExecAll>;

      beforeEach(() => {
        vi.spyOn(Date, 'now').mockReturnValue(
          new Date('2026-03-13T00:00:00.000Z').getTime(),
        );
        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();
      });

      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].cmd).not.toContain('--exclude-newer');
        expect(execSnapshots[0].options?.env).toMatchObject({
          UV_EXCLUDE_NEWER: '2026-03-10T00:00:00.000Z',
        });
      });

      it('skips UV_EXCLUDE_NEWER when minimumReleaseAge is 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 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 than minimumReleaseAge', async () => {
        // pyproject (2026-03-05) is earlier than minimumReleaseAge (3 days = 2026-03-10)
        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 ISO date is less restrictive', async () => {
        // pyproject (2026-03-12) is later than minimumReleaseAge (3 days = 2026-03-10)
        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 than minimumReleaseAge', async () => {
        // 2 weeks before 2026-03-13 = 2026-02-27, earlier than 3 days (2026-03-10)
        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,
        );

        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",
        );
      });
    });

Comment thread lib/modules/manager/types.ts Outdated
Comment thread lib/modules/manager/pep621/processors/uv.ts Outdated
Comment thread docs/usage/key-concepts/minimum-release-age.md Outdated
Comment thread lib/modules/manager/pep621/processors/uv.ts Outdated
Comment thread lib/modules/manager/pep621/processors/uv.ts
Comment thread lib/modules/manager/pep621/processors/uv.spec.ts Outdated
@thejoeejoee
thejoeejoee requested a review from Churro March 23, 2026 22:42

@Churro Churro left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Otherwise LGTM

Comment thread lib/modules/manager/pep621/processors/uv.spec.ts Outdated
Comment thread docs/usage/key-concepts/minimum-release-age.md
Comment thread lib/modules/manager/pep621/processors/uv.ts Outdated

@RahulGautamSingh RahulGautamSingh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM except open comment and suggested changes.

Comment thread lib/modules/manager/pep621/processors/uv.spec.ts Outdated
Comment thread lib/modules/manager/pep621/processors/uv.spec.ts
Comment thread lib/modules/manager/pep621/processors/uv.ts Outdated
@thejoeejoee
thejoeejoee force-pushed the feat/uv-exclude-newer branch 3 times, most recently from 95d9fa8 to df77a59 Compare March 27, 2026 11:45
@thejoeejoee
thejoeejoee force-pushed the feat/uv-exclude-newer branch from db2a318 to fe42046 Compare March 27, 2026 16:37

@RahulGautamSingh RahulGautamSingh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be worth adding one test with a non-maintenance update to confirm the env var is set for regular --upgrade-package commands too.

@Churro

Churro commented Apr 12, 2026

Copy link
Copy Markdown
Collaborator

From my perspective, the code is fine but only for the "happy paths":

  1. Fresh lockfile - ✅
  2. Existing lockfile, all within window - ✅
  3. Existing lockfile, some deps too new - 🟥 needs fallback or graceful degradation
  4. Lockfile convergence over time - 🟥 natural consequence of no. 3's fallback

I've built https://github.com/renovate-demo/41913-uv-lockfile-maintenance to demonstrate the lockfile maintenance case. When run with the code from this PR, we see in logs:

DEBUG: updateArtifacts for lockFileMaintenanceFiles (repository=renovate-demo/41913-uv-lockfile-maintenance, branch=renovate/lock-file-maintenance)
DEBUG: Executing command (repository=renovate-demo/41913-uv-lockfile-maintenance, branch=renovate/lock-file-maintenance)
       "command": "uv lock --upgrade"
DEBUG: rawExec err (repository=renovate-demo/41913-uv-lockfile-maintenance, branch=renovate/lock-file-maintenance)
       "err": {
         "cmd": "uv lock --upgrade",
         "stderr": "Using CPython 3.13.0\nResolving despite existing lockfile due to addition of global exclude newer 2025-03-28T11:29:06.431Z\n  × No solution found when resolving dependencies:\n  ╰─▶ Because only flask<=3.1.0 is available and your project depends on\n      flask>=3.1.3, we can conclude that your project's requirements are\n      unsatisfiable.\n",
         "stdout": "",
         "options": { ... },
         "exitCode": 1,
         "name": "ExecError",
       },
       "durationMs": 258
DEBUG: Failed to update uv lock file (repository=renovate-demo/41913-uv-lockfile-maintenance, branch=renovate/lock-file-maintenance)

So summarizing, I think a fallback similar to the one I proposed in #42552 is needed. Let's probably pause with this PR until a way forward is agreed upon regarding npm.

rebkwok added a commit to bennettoxford/bennettbot that referenced this pull request Apr 15, 2026
Disable lockFileMaintenance is as it doesn't currently respect the
minimumReleaseAge for uv. An open PR for adding it has been put on hold for now.
renovatebot/renovate#41913 (comment)

Instead, we'll use the update-dependencies-action to update the uv lockfile instead
(in a following commit)
rebkwok added a commit to bennettoxford/bennettbot that referenced this pull request May 1, 2026
upgrade-all upgrades with a 7-days-ago excludes-newer setting, as
per our dependency update policy. This writes an excludes-newer
timestamp into the uv.lock file. We deliberately don't include a
excludes-newer setting in pyproject.toml as it interferes with
automated security updates which we don't (always) want to be
applied with a 7-day lag. Note that this is a workaround until
Renovate properly respects minimumReleaseAge for uv lockfileMaintentance
(see PR renovatebot/renovate#41913)

However, if we leave the timestamp in the uv.lock, any subsequent
uv sync will remove it - which means anyone who runs a
just check/test/run locally ends up with an unexpected uv.lock change.

Running devenv as a subsequent recipe after upgrade-all immediately
removes the timestamp, leaving just the dependency updates in
place.
rebkwok added a commit to bennettoxford/bennettbot that referenced this pull request May 1, 2026
upgrade-all upgrades with a 7-days-ago excludes-newer setting, as
per our dependency update policy. This writes an excludes-newer
timestamp into the uv.lock file. We deliberately don't include a
excludes-newer setting in pyproject.toml as it interferes with
automated security updates which we don't (always) want to be
applied with a 7-day lag. Note that this is a workaround until
Renovate properly respects minimumReleaseAge for uv lockfileMaintentance
(see PR renovatebot/renovate#41913)

However, if we leave the timestamp in the uv.lock, any subsequent
uv sync will remove it - which means anyone who runs a
just check/test/run locally ends up with an unexpected uv.lock change.

Running devenv as a subsequent recipe after upgrade-all immediately
removes the timestamp, leaving just the dependency updates in
place.

<!-- prettier-ignore -->
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.

@simonhaenisch simonhaenisch May 5, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was a recent change in uv, now you set exclude-newer-span for relative durations, and exclude-newer is for absolute timestamps only?

See astral-sh/uv#19024.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pass --exclude-newer to uv

4 participants