repomatic.deps.sync module

Registry of the cooldown-respecting dependency updaters, and their driver.

The five sync-* dependency bumpers (sync-dep-sources, sync-uv-lock, sync-tool-versions, sync-action-pins, sync-workflow-pins) share a shape: discover the latest eligible upstream version, gated by the [tool.repomatic] minimum-release-age cooldown (or uv’s exclude-newer for the lock), then rewrite the pin. This module turns that shape into data: one SyncOperation per bumper, in SYNC_OPERATIONS. The thin sync-* commands and the aggregate sync-deps command in repomatic.cli.deps all drive the operations through it.

Resolve then apply

Each operation splits into a read phase and a write phase so sync-deps can run the slow, network-bound discovery for every operation concurrently, then write serially:

  • SyncOperation.resolve performs the network discovery and computes the new file contents in memory, returning a SyncPlan. It does not touch the repository, so the resolves are safe to run in parallel.

  • SyncOperation.apply writes the planned contents. Two of the five operations rewrite .github/workflows/*.yaml (action pins and workflow literals both live there), so applies must run serially.

sync-uv-lock and sync-dep-sources are the documented exceptions: their discovery is a mutation (uv lock rewrites uv.lock), so their SyncOperation.resolve writes during the parallel phase and their SyncOperation.apply is a no-op. Their shared write domain (uv.lock, pyproject.toml) is disjoint from every other operation’s, and the two are serialized against each other through _UV_PROJECT_MUTEX. A --dry-run resolve snapshots and restores the mutated files so the preview leaves no trace.

The datasource adapters, version selection, and pure string rewriters live in repomatic.deps.pins and repomatic.deps.cooldown; this module composes them with the file I/O and checksum recompute. The markdown report of a plan is rendered here too (render_plan_markdown()), since the CI jobs read it. The terminal output stays in the CLI.

repomatic.deps.sync.ACTIONLINT_MATCHER_URL = 'https://raw.githubusercontent.com/rhysd/actionlint/refs/tags/v{version}/.github/actionlint-matcher.json'

Where actionlint keeps its problem matcher, at the tag of a release.

repomatic.deps.sync.DEPENDENCY_LABEL = '🔗 dependencies'

GitHub label applied to every dependency-update PR.

Shared by all five bumpers so a single label filters the whole family. Workflow YAML cannot import Python, so autofix.yaml repeats this string literally, and the labeller’s own rule tables (repomatic.github.labels.DEFAULT_CONTENT_RULES and DEFAULT_FILE_RULES) key their dependency rules on the same spelling. tests/test_sync_ops.py asserts both copies match this constant, and tests/test_labels.py that it names a label labels.toml actually defines: applying an unknown label fails the gh call outright, so a rename in the registry has to reach all of them at once.

class repomatic.deps.sync.ResolveContext(config, today, release_notes=False, held_back=True, dry_run=False, lockfile=<factory>, jobs=1)[source]

Bases: object

Inputs shared by every SyncOperation.resolve.

Each operation reads the subset it needs. The cooldown is derived from config (minimum-release-age for the version-sync trio, exclude-newer from the lock for sync-uv-lock).

config: Config

The resolved [tool.repomatic] configuration.

today: date

Reference date for the cooldown computation, fixed once per run.

release_notes: bool = False

Fetch upstream release notes and append them to the report.

held_back: bool = True

Report newer releases withheld only by the cooldown.

dry_run: bool = False

Plan without persisting: restore any files the resolve had to mutate.

lockfile: Path

Path to uv.lock for sync-uv-lock.

jobs: int = 1

Worker threads for the per-item datasource fetches.

The CLI resolves it from --jobs on its own thread, since a worker thread running a resolve has no Click context to read it from.

property min_age: timedelta[source]

The minimum-release-age cooldown, parsed.

property pinnable_files: dict[Path, str][source]

The .github/ files the two pin updaters scan, read once per run.

class repomatic.deps.sync.ToolVersionExtras(binary_overrides=<factory>, actionlint_version=None, jobs=1)[source]

Bases: object

sync-tool-versions write extras, applied after the source rewrite.

binary_overrides: dict[str, str]

Binary tool name to new version, for the checksum recompute.

actionlint_version: str | None = None

New actionlint version, to refresh the bundled problem matcher at its tag.

jobs: int = 1

How many artifact downloads of the checksum recompute run at once.

The --jobs width the CLI resolved, carried from the resolve: the apply runs with no Click context to read it from.

class repomatic.deps.sync.UvProjectExtras(exclude_newer='', pins_synced=False, pruned_bypasses=<factory>, frozen_bypasses=<factory>, stale_bypass_comments=<factory>, bypass_forecasts=<factory>, source_swaps=<factory>)[source]

Bases: object

Extras of the uv-project pair (sync-uv-lock, sync-dep-sources).

The pair shares one write domain (uv.lock, pyproject.toml) and resolves under _UV_PROJECT_MUTEX. Each resolve already wrote those files, so these fields only inform the terminal and PR-body rendering.

exclude_newer: str = ''

The exclude-newer cutoff from the lock, or empty.

pins_synced: bool = False

Whether the [tool.uv] policy pins were refreshed from the template.

pruned_bypasses: list[BypassForecast]

Expired exclude-newer-package entries removed from pyproject.toml, snapshot with the version and expiry each freeze had.

frozen_bypasses: list[str]

exclude-newer-package entries rewritten into freeze cutoffs.

stale_bypass_comments: list[str]

Pruned entries the comment above exclude-newer-package still names.

bypass_forecasts: list[BypassForecast]

Active cooldown-bypass freezes with their expiry forecasts.

source_swaps: list[ReleaseSwap]

Git-tracked dependencies swapped to their released versions.

class repomatic.deps.sync.SyncPlan(operation, subject, heading, changes=<factory>, dates=<factory>, released_overrides=<factory>, change_labels=<factory>, name_urls=<factory>, comparison_urls=<factory>, held_back=<factory>, held_back_name_urls=<factory>, held_back_note='Newer releases already published but withheld because they are still inside the [`minimum-release-age`](https://repomatic.net/configuration#minimum-release-age) cooldown window.', notes_section='', upgrade_section='', cooldown_note='', cutoff=None, reference_date=None, file_writes=<factory>, self_pin_exemptions=<factory>, rebase=None, tool_versions=<factory>, uv_project=<factory>)[source]

Bases: object

The resolved, not-yet-written outcome of one operation’s read phase.

Carries everything SyncOperation.apply needs to write the changes and everything the CLI needs to render the terminal table and the markdown PR body, so the write and the rendering never re-resolve.

operation: str

The operation name (sync-uv-lock, …).

subject: str

Header for the first table column (Package, Tool, Action).

heading: str

Noun after ## 🆙 in the diff table (Updated tools, …).

changes: list[tuple[str, str, str]]

Applied (name, old, new) triples, in the order the report renders.

dates: dict[str, str]

Name to release/upload date (YYYY-MM-DD or ISO 8601) for the table.

released_overrides: dict[str, str]

Name to literal markdown replacing its “Released” table cell.

Marks rows whose version was decided outside the cooldown-checked release listing (the upstream toolkit’s lockstep-aligned pin), so the table shows the exemption instead of a blank cell.

change_labels: dict[str, str]

Name to literal markdown labelling its “Change” table cell.

Marks the uv pin stepped back onto the checksum table of the pinned setup-uv (see _gate_uv_on_checksums), which the arrow alone renders like any bump.

name_urls: dict[str, str]

Name to the URL its table cell links to (PyPI, GitHub, npm).

comparison_urls: dict[str, str]

Name to a compare URL linked on the change cell.

held_back: list[HeldBackPackage]

Newer releases withheld only by the cooldown.

held_back_name_urls: dict[str, str]

Name to URL for the held-back section.

held_back_note: str = 'Newer releases already published but withheld because they are still inside the [`minimum-release-age`](https://repomatic.net/configuration#minimum-release-age) cooldown window.'

Intro paragraph for the held-back section (cooldown wording).

notes_section: str = ''

Pre-rendered release-notes markdown, or empty.

upgrade_section: str = ''

Pre-rendered invitation to review an upstream toolkit bump, or empty.

cooldown_note: str = ''

Pre-rendered cooldown-cutoff sentence shown above the diff table.

cutoff: date | None = None

Effective minimum-release-age cutoff, for the terminal report.

reference_date: date | None = None

Reference date for the table’s relative “Released” hints (the run date).

file_writes: dict[Path, str]

Path to its new full text, as computed at resolve time.

sync-tool-versions writes this text as is. The two .github/ pin updaters only read which files it names: their apply replays the rewrite on current disk state through rebase.

self_pin_exemptions: list[str]

Workflow files that gained a missing self-pin cooldown exemption.

Names only the files whose sole edit was the splice, since a file that also moved a version is already reported through changes. Kept as a separate list for the same reason UvProjectExtras.frozen_bypasses is: the rewrite has no (name, old, new) triple to render, yet it still produced a hunk the report has to explain. Without it a splice-only run reads as “nothing to update” and the write is dropped, which is what let an exemption-less downstream pin sit broken indefinitely: the backfill only ever landed on a run that happened to move the version too.

rebase: Callable[[str], tuple[str, list[tuple[str, str, str]]]] | None = None

Replay this operation’s rewriter against a file’s current text.

Set by _plan_file_rewrites. Applies run serially after every resolve finished, and the two .github/ pin updaters routinely plan rewrites of the same workflow files from the same pre-apply snapshot: writing file_writes verbatim would silently revert whichever sibling applied first. The closure re-runs the pure rewriter on whatever is on disk at apply time instead.

tool_versions: ToolVersionExtras

sync-tool-versions write extras (checksum recompute, problem matcher).

uv_project: UvProjectExtras

sync-uv-lock and sync-dep-sources rendering extras.

property has_changes: bool

Whether the operation found anything to update.

Cooldown-bypass edits count: a run that only prunes or freezes exclude-newer-package entries still rewrites pyproject.toml and must produce a report explaining that hunk. A run that only splices a missing self-pin exemption into a workflow counts for the same reason.

note_cooldown(age_label, min_age, today)[source]

Record the cooldown cutoff and its rendered diff-table note.

No-op fields (a None cutoff, an empty note) when the cooldown is disabled (0 days or unparsable).

Return type:

None

repomatic.deps.sync.render_plan_markdown(plan)[source]

Render a plan as the markdown PR-body section every updater shares.

Concatenates the source-swap section (when the plan carries one), the diff table, any release notes, the upgrade-review invitation, the uv cooldown-bypass section, and the held-back section exactly as the individual sync-* commands do, so sync-deps and the thin commands produce identical output for the same plan. The invitation follows the release notes because those are what it asks the maintainer to work through.

Every other section reports what the run did to the working tree, so the held-back one closes the body: it is the only forward-looking section, listing releases the run deliberately left alone. A run that only rewrites exclude-newer-package entries moves no version at all, and leading with the forecast would open its PR on the releases it did not adopt instead of the pyproject.toml hunk it asks to merge.

Return type:

str

class repomatic.deps.sync.SyncOperation(name, config_key, resolve, apply, applies_here)[source]

Bases: object

One cooldown-respecting dependency updater, as data.

The CLI command, workflow job step, PR branch and PR-body template all share name, which makes a rename a one-line change.

name: str

Command, branch, and template name (all identical).

config_key: str

The [tool.repomatic] key gating this operation, like uv-lock.sync.

resolve: Callable[[ResolveContext], SyncPlan]

Read phase: network discovery, returns a SyncPlan.

apply: Callable[[SyncPlan], None]

Write phase: persist the plan’s file writes.

applies_here: Callable[[], bool]

Whether the operation is meaningful in the current working tree.

is_enabled(config)[source]

Whether this operation is enabled in config.

Return type:

bool

repomatic.deps.sync.SYNC_OPERATIONS: tuple[SyncOperation, ...] = (SyncOperation(name='sync-dep-sources', config_key='dep-sources.sync', resolve=<function _resolve_dep_sources>, apply=<function _apply_dep_sources>, applies_here=<function _dep_sources_applies>), SyncOperation(name='sync-uv-lock', config_key='uv-lock.sync', resolve=<function _resolve_uv_lock>, apply=<function _apply_uv_lock>, applies_here=<function _uv_lock_applies>), SyncOperation(name='sync-action-pins', config_key='action-pins.sync', resolve=<function _resolve_action_pins>, apply=<function _apply_file_writes>, applies_here=<function _workflow_files_present>), SyncOperation(name='sync-workflow-pins', config_key='workflow-pins.sync', resolve=<function _resolve_workflow_pins>, apply=<function _apply_file_writes>, applies_here=<function _workflow_files_present>), SyncOperation(name='sync-tool-versions', config_key='tool-versions.sync', resolve=<function _resolve_tool_versions>, apply=<function _apply_tool_versions>, applies_here=<function _tool_versions_applies>))

The cooldown-respecting dependency updaters, in CI execution order.

sync-dep-sources first (adopting a release changes what the routine re-lock even does), then sync-uv-lock (its lock churn gates other Python work), then the two workflow-file rewriters, then the upstream-only tool bump last.

Only the first four share the sync-deps job in autofix.yaml. sync-tool-versions runs from self-maintenance.yaml on its own daily schedule, since it rewrites this package’s source and has no downstream meaning; the order still applies to a local repomatic sync-deps, which runs every enabled operation in one pass.

repomatic.deps.sync.OPERATIONS_BY_NAME: dict[str, SyncOperation] = {'sync-action-pins': SyncOperation(name='sync-action-pins', config_key='action-pins.sync', resolve=<function _resolve_action_pins>, apply=<function _apply_file_writes>, applies_here=<function _workflow_files_present>), 'sync-dep-sources': SyncOperation(name='sync-dep-sources', config_key='dep-sources.sync', resolve=<function _resolve_dep_sources>, apply=<function _apply_dep_sources>, applies_here=<function _dep_sources_applies>), 'sync-tool-versions': SyncOperation(name='sync-tool-versions', config_key='tool-versions.sync', resolve=<function _resolve_tool_versions>, apply=<function _apply_tool_versions>, applies_here=<function _tool_versions_applies>), 'sync-uv-lock': SyncOperation(name='sync-uv-lock', config_key='uv-lock.sync', resolve=<function _resolve_uv_lock>, apply=<function _apply_uv_lock>, applies_here=<function _uv_lock_applies>), 'sync-workflow-pins': SyncOperation(name='sync-workflow-pins', config_key='workflow-pins.sync', resolve=<function _resolve_workflow_pins>, apply=<function _apply_file_writes>, applies_here=<function _workflow_files_present>)}

SYNC_OPERATIONS keyed by SyncOperation.name.

repomatic.deps.sync.selected_operations(config, names=None)[source]

Return the operations to run, in SYNC_OPERATIONS order.

The config feature flags are always authoritative: a disabled operation is dropped whether or not it was named (mirrors each standalone sync-* command, which exits when its flag is off).

Parameters:
  • config (Config) – The resolved configuration; disabled operations are dropped.

  • names (Sequence[str] | None) – When given, restrict to these operation names. Unknown names are ignored (the CLI validates them upstream). With none, operations whose SyncOperation.applies_here is false (no uv.lock, no workflow files, not the repomatic checkout) are dropped too: naming an operation is an explicit opt-in that bypasses that probe (the “scope exclusions are defaults, not absolutes” rule in claude.md).

Return type:

list[SyncOperation]

repomatic.deps.sync.run_sync_operations(operations, rc, *, spinner_label=None)[source]

Resolve operations concurrently, then apply them serially.

The resolve phase fans out through click_extra.run_jobs() (the work is network-bound and disjoint per operation), sized by ResolveContext.jobs. A Ctrl+C drops queued resolves instead of waiting for them. When labelled, an click_extra.OperationTrail reports each resolve as a ✓/✘ line and closes with a summary, its rendering tracking the worker count and its elapsed times following --time (click-extra’s own default). The apply phase runs in SYNC_OPERATIONS order because two of the five rewrite the same workflow files. In --dry-run no apply runs. An operation whose resolve raises is logged and reported with a None plan so one failure never blocks the others.

Parameters:
  • operations (Sequence[SyncOperation]) – The operations to run (already filtered by the caller).

  • rc (ResolveContext) – Shared resolve inputs.

  • spinner_label (str | None) – Present-tense label for the resolve trail (like "Resolving dependency updates"). When set, the trail prints a ✓/✘ line per operation on any stream, CI logs included, and a TTY also gets a running tally. Unset (programmatic and test calls), the trail is silent.

Return type:

list[tuple[SyncOperation, SyncPlan | None]]

Returns:

Each operation paired with its plan (or None if its resolve failed), in SYNC_OPERATIONS order.