repomatic.sync_ops 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 registry is the single source of truth consumed three ways: the thin sync-* commands and the aggregate sync-deps command in repomatic.cli.main, and the consolidated CI job emitted by repomatic.github.workflow_sync.

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. Three of the five operations rewrite .github/workflows/*.yaml (action pins, workflow literals, and the actionlint matcher URL all 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.release.version_sync and repomatic.deps.uv; this module composes them with the file I/O and checksum recompute. Terminal and PR-body rendering stay in repomatic.cli.main, fed from the SyncPlan.

repomatic.sync_ops.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.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.sync_ops.ResolveContext(config, today, release_notes=False, held_back=True, dry_run=False, lockfile=<factory>)[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.

class repomatic.sync_ops.ToolVersionExtras(binary_overrides=<factory>, actionlint_version=None, checksums_path=None)[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, for the matcher-URL realignment.

checksums_path: Path | None = None

The tool_registry.py path the checksum recompute rewrites.

class repomatic.sync_ops.UvProjectExtras(exclude_newer='', reverted=False, pins_synced=False, pruned_bypasses=<factory>, frozen_bypasses=<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.

reverted: bool = False

Whether a cosmetic-only re-lock was discarded.

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.

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.sync_ops.SyncPlan(operation, subject, heading, changes=<factory>, dates=<factory>, released_overrides=<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='', 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 repomatic.cli.main 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.

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.

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.

Written verbatim by SyncOperation.apply only when rebase is unset; otherwise it records which files the resolve touched (and what it computed) while the apply replays the rewrite on current disk state.

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, matcher URL).

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.sync_ops.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 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.

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

repomatic.sync_ops.print_sync_table(ctx, changes, dates, *, subject, reference_date)[source]

Print the shared terminal table for the dependency updaters.

Columns are {subject} | Old | New | Released, the released date carrying a relative hint. Shared by sync-uv-lock and the three sync-* commands so their terminal output matches, and respects the global --table-format. Old/New stay separate columns (not the merged Change cell of the markdown PR body) so structured --table-format json/csv output stays parseable.

Return type:

None

repomatic.sync_ops.print_held_back_table(ctx, held_back, *, subject='Package')[source]

Print the shared held-back terminal table for the cooldown-gated updaters.

Columns are subject followed by HELD_BACK_COLUMNS. Shared by sync-uv-lock and the three sync-* commands, and respects the global --table-format.

Return type:

None

repomatic.sync_ops.print_bypass_table(ctx, forecasts)[source]

Print the active cooldown-bypass freezes with their expiry forecasts.

Columns are BYPASS_COLUMNS, mirroring the markdown section from format_bypass_section(), and respects the global --table-format.

Return type:

None

repomatic.sync_ops.print_plan_tables(ctx, plan, reference_date)[source]

Print a resolved plan’s diff, bypass and held-back tables.

The terminal counterpart of render_plan_markdown(), deliberately beside it and in the same order, so a run’s terminal output and its PR body read the same way and cannot drift apart. Every dependency updater goes through here: the two lockfile commands, the three version-sync commands, and the aggregate sync-deps.

Each table respects the global --table-format, and an empty section prints nothing.

Return type:

None

repomatic.sync_ops.emit_lockfile_sync_report(ctx, plan, *, reference_date, table, output, output_format)[source]

Emit the terminal tables and markdown report of a lockfile sync.

sync-uv-lock and sync-dep-sources share this tail. They alone can suppress the terminal tables with --no-table (their CI jobs want only the markdown report), and they alone have an exclude-newer cutoff to announce, uv’s lock-level cooldown standing in for the minimum-release-age window the version-sync trio reports.

Return type:

None

repomatic.sync_ops.emit_version_sync_report(ctx, plan, output, output_format)[source]

Print a terminal report and optionally write a markdown PR-body report.

Shared by the three sync-* version updaters. The terminal table and the markdown PR body (diff table, held-back section, release notes) route through the same shared renderers sync-uv-lock and sync-deps use (render_plan_markdown()), so every dependency updater’s report matches.

Return type:

None

repomatic.sync_ops.run_version_sync(ctx, op_name, output, output_format, release_notes, held_back, up_to_date)[source]

Shared body of the three version-sync commands.

sync-tool-versions, sync-action-pins, and sync-workflow-pins differ only in their operation, feature flag, and messages: the resolve, apply, and report sequence is identical. The feature-flag guard stays with each command, which knows its own [tool.repomatic] key.

Parameters:
  • ctx (Context) – The Click context, exited with 0 when nothing needs updating.

  • op_name (str) – The OPERATIONS_BY_NAME key.

  • output (Path | None) – The --output report path.

  • output_format (str) – The --output-format value.

  • release_notes (bool) – Whether to fetch GitHub release notes.

  • held_back (bool) – Whether to report cooldown-held releases.

  • up_to_date (str) – Message printed when nothing needs updating.

Return type:

None

repomatic.sync_ops.resolve_lockfile_plan(op_name, config, *, lockfile, table, output, release_notes, held_back)[source]

Resolve one of the two lockfile-mutating operations.

sync-uv-lock and sync-dep-sources build the same resolve context and, unlike the version-sync trio, gate the held-back probe on a consumer being present: that probe costs a second full uv resolution, so a run that prints no table and writes no report must not pay for it.

The apply and the narration stay with each command, whose “what happened” lines differ (adopted releases for one, bypass lifecycle for the other).

Return type:

tuple[SyncOperation, ResolveContext, SyncPlan]

Returns:

(operation, resolve_context, plan).

class repomatic.sync_ops.SyncOperation(name, config_flag, job_name, resolve, apply, applies_here, write_domain, workflow='autofix.yaml', job='sync-deps', ci_flags=())[source]

Bases: object

One cooldown-respecting dependency updater, as data.

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

Note

The CI metadata (job_name, write_domain, ci_flags) describes the job that runs the operation, but nothing generates that job from it: the autofix.yaml steps are hand-written, and tests/test_sync_ops.py is what holds the two descriptions in step.

name: str

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

config_flag: str

The Config boolean gating this operation.

job_name: str

Human-facing CI job step name, with its emoji (⛓️ Sync uv.lock).

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.

write_domain: tuple[str, ...]

Human-readable globs the operation mutates (for conflict awareness).

workflow: str = 'autofix.yaml'

Workflow file whose job runs this operation in CI.

job: str = 'sync-deps'

Job ID inside workflow hosting this operation’s steps.

Defaults to the consolidated sync-deps job, which shares one checkout across every bumper whose write domain exists downstream. An operation that writes only to this repository’s own source belongs in a job of its own, in a workflow repomatic init never materializes downstream (see SELF_MAINTENANCE_WORKFLOWS).

ci_flags: tuple[str, ...] = ()

Extra CLI flags the consolidated CI job passes to the command.

property branch: str

The PR branch name (identical to name).

property template: str

The PR-body template name (identical to name).

property consolidated: bool

Whether this operation shares the multi-bumper sync-deps job.

A consolidated operation must reset the working tree before it runs, so the previous bumper’s diff never bleeds into its PR. An operation with a job to itself starts from a clean checkout and needs no reset.

Compared against the job field default rather than against a repeated "sync-deps" literal, so renaming the shared job is a one-line change.

is_enabled(config)[source]

Whether this operation is enabled in config.

Return type:

bool

repomatic.sync_ops.SYNC_OPERATIONS: tuple[SyncOperation, ...] = (SyncOperation(name='sync-dep-sources', config_flag='dep_sources_sync', job_name='🔀 Sync dependency sources', resolve=<function _resolve_dep_sources>, apply=<function _apply_dep_sources>, applies_here=<function _dep_sources_applies>, write_domain=('uv.lock', 'pyproject.toml'), workflow='autofix.yaml', job='sync-deps', ci_flags=('--no-table', '--release-notes')), SyncOperation(name='sync-uv-lock', config_flag='uv_lock_sync', job_name='⛓️ Sync uv.lock', resolve=<function _resolve_uv_lock>, apply=<function _apply_uv_lock>, applies_here=<function _uv_lock_applies>, write_domain=('uv.lock', 'pyproject.toml [tool.uv]'), workflow='autofix.yaml', job='sync-deps', ci_flags=('--no-table', '--release-notes')), SyncOperation(name='sync-action-pins', config_flag='action_pins_sync', job_name='📌 Sync action pins', resolve=<function _resolve_action_pins>, apply=<function _apply_file_writes>, applies_here=<function _workflow_files_present>, write_domain=('.github/workflows/*.yaml', '.github/actions/**/*.yaml'), workflow='autofix.yaml', job='sync-deps', ci_flags=('--release-notes',)), SyncOperation(name='sync-workflow-pins', config_flag='workflow_pins_sync', job_name='🔖 Sync workflow pins', resolve=<function _resolve_workflow_pins>, apply=<function _apply_file_writes>, applies_here=<function _workflow_files_present>, write_domain=('.github/workflows/*.yaml', '.github/actions/**/*.yaml'), workflow='autofix.yaml', job='sync-deps', ci_flags=('--release-notes',)), SyncOperation(name='sync-tool-versions', config_flag='tool_versions_sync', job_name='🔼 Sync tool versions', resolve=<function _resolve_tool_versions>, apply=<function _apply_tool_versions>, applies_here=<function _tool_versions_applies>, write_domain=('repomatic/tooling/tool_registry.py', '.github/workflows/lint.yaml'), workflow='self-maintenance.yaml', job='sync-tool-versions', ci_flags=('--release-notes',)))

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.sync_ops.OPERATIONS_BY_NAME: dict[str, SyncOperation] = {'sync-action-pins': SyncOperation(name='sync-action-pins', config_flag='action_pins_sync', job_name='📌 Sync action pins', resolve=<function _resolve_action_pins>, apply=<function _apply_file_writes>, applies_here=<function _workflow_files_present>, write_domain=('.github/workflows/*.yaml', '.github/actions/**/*.yaml'), workflow='autofix.yaml', job='sync-deps', ci_flags=('--release-notes',)), 'sync-dep-sources': SyncOperation(name='sync-dep-sources', config_flag='dep_sources_sync', job_name='🔀 Sync dependency sources', resolve=<function _resolve_dep_sources>, apply=<function _apply_dep_sources>, applies_here=<function _dep_sources_applies>, write_domain=('uv.lock', 'pyproject.toml'), workflow='autofix.yaml', job='sync-deps', ci_flags=('--no-table', '--release-notes')), 'sync-tool-versions': SyncOperation(name='sync-tool-versions', config_flag='tool_versions_sync', job_name='🔼 Sync tool versions', resolve=<function _resolve_tool_versions>, apply=<function _apply_tool_versions>, applies_here=<function _tool_versions_applies>, write_domain=('repomatic/tooling/tool_registry.py', '.github/workflows/lint.yaml'), workflow='self-maintenance.yaml', job='sync-tool-versions', ci_flags=('--release-notes',)), 'sync-uv-lock': SyncOperation(name='sync-uv-lock', config_flag='uv_lock_sync', job_name='⛓️ Sync uv.lock', resolve=<function _resolve_uv_lock>, apply=<function _apply_uv_lock>, applies_here=<function _uv_lock_applies>, write_domain=('uv.lock', 'pyproject.toml [tool.uv]'), workflow='autofix.yaml', job='sync-deps', ci_flags=('--no-table', '--release-notes')), 'sync-workflow-pins': SyncOperation(name='sync-workflow-pins', config_flag='workflow_pins_sync', job_name='🔖 Sync workflow pins', resolve=<function _resolve_workflow_pins>, apply=<function _apply_file_writes>, applies_here=<function _workflow_files_present>, write_domain=('.github/workflows/*.yaml', '.github/actions/**/*.yaml'), workflow='autofix.yaml', job='sync-deps', ci_flags=('--release-notes',))}

SYNC_OPERATIONS keyed by SyncOperation.name.

repomatic.sync_ops.selected_operations(config, *, here_only=True, 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.

  • here_only (bool) – Drop operations whose SyncOperation.applies_here is false (no uv.lock, no workflow files, not the repomatic checkout). Ignored when names is given: naming an operation is an explicit opt-in that bypasses the working-tree probe (the “scope exclusions are defaults, not absolutes” rule in claude.md).

  • names (Sequence[str] | None) – When given, restrict to these operation names. Unknown names are ignored (the CLI validates them upstream).

Return type:

list[SyncOperation]

repomatic.sync_ops.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 the global --jobs option and sequential when no CLI context is active (as in tests). At DEBUG verbosity the fan-out also collapses to sequential so per-operation log narration stays coherent, and 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 resolved worker count and its elapsed times following --time (click-extra’s own default). The apply phase runs in SYNC_OPERATIONS order because three 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 and attached to a TTY, the trail shows a / line per operation and a running tally; unset (programmatic and test calls) forces it silent, so CI and tests show nothing.

Return type:

list[tuple[SyncOperation, SyncPlan | None]]

Returns:

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

repomatic.sync_ops.operation_order(operations)[source]

Sort operations into SYNC_OPERATIONS order.

Return type:

list[SyncOperation]