repomatic.release.version_sync module

Self-hosted dependency-version updaters: the replacement for Renovate.

Backs the sync-tool-versions, sync-action-pins, and sync-workflow-pins commands. Each discovers the latest eligible upstream version from a datasource (GitHub releases, PyPI, or npm), gated by the shared [tool.repomatic] minimum-release-age cooldown (the GitHub/PyPI/npm counterpart to uv’s exclude-newer, which guards sync-uv-lock), then rewrites the pinned version in place.

The datasource adapters and version selection live here; the file I/O and checksum recompute that the commands drive stay in repomatic.cli.main. The string-level helpers (set_tool_version, find_action_pins, find_workflow_literals, and the apply_* rewriters) are pure so they can be unit-tested without network access.

repomatic.release.version_sync.MINIMUM_RELEASE_AGE_URL = 'https://repomatic.net/configuration#minimum-release-age'

Docs anchor for the minimum-release-age cooldown, linked from PR bodies.

repomatic.release.version_sync.MIN_AGE_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.'

Intro paragraph for the version-sync held-back section.

The GitHub/PyPI/npm counterpart to repomatic.deps.dep_report.EXCLUDE_NEWER_HELD_BACK_NOTE.

repomatic.release.version_sync.ACTION_PIN_RE = re.compile('(?P<prefix>uses:\\s*)(?P<slug>[\\w.-]+/[\\w.-]+)@(?P<sha>[0-9a-f]{40})(?P<gap>\\s*#\\s*)(?P<ref>v?\\d[\\w.-]*)')

Match a SHA-pinned GitHub Action uses: reference with its version comment.

The slug/slug@<40-hex> shape only matches owner/repo actions, so local ./… refs and reusable-workflow refs carrying a subpath (owner/repo/.github/workflows/x.yaml@…) are skipped automatically.

repomatic.release.version_sync.SETUP_UV_PACKAGE = 'uv'

PyPI project backing the astral-sh/setup-uv version pin.

repomatic.release.version_sync.SETUP_UV_SLUG = 'astral-sh/setup-uv'

Action slug provisioning uv, whose own pin decides what CI can verify.

repomatic.release.version_sync.SETUP_UV_CHECKSUMS_PATH = 'src/download/checksum/known-checksums.ts'

Path of the checksum table inside the SETUP_UV_SLUG repository.

repomatic.release.version_sync.GITHUB_API_CONTENTS_URL = 'https://api.github.com/repos/{slug}/contents/{path}?ref={ref}'

GitHub API URL for reading one file at one commit.

Requested with the raw media type, so the body arrives verbatim: the JSON form base64-encodes it and caps out at 1 MB, and the checksum table is already past half of that.

class repomatic.release.version_sync.Candidate(version: str, date: str, ref: str)[source]

Bases: NamedTuple

A single release version offered by a datasource.

Create new instance of Candidate(version, date, ref)

version: str

Comparable, display version (e.g. 1.7.12).

date: str

Publication date in YYYY-MM-DD format.

ref: str

Upstream reference to pin downstream.

The raw git tag for GitHub releases (needed to resolve the commit SHA and write the pin comment); identical to version for PyPI and npm.

class repomatic.release.version_sync.ActionPin(slug: str, sha: str, ref: str)[source]

Bases: NamedTuple

A SHA-pinned GitHub Action reference found in a workflow file.

Create new instance of ActionPin(slug, sha, ref)

slug: str

The owner/repo action slug.

sha: str

The currently pinned 40-character commit SHA.

ref: str

The version in the trailing # vX.Y.Z comment.

class repomatic.release.version_sync.UpstreamRefPin(version: str, sha: str | None)[source]

Bases: NamedTuple

An upstream thin-caller uses: ref found in a workflow file.

The counterpart of ActionPin for the upstream repo’s own reusable workflows and composite actions. Those refs carry a subpath (owner/repo/.github/workflows/x.yaml@…), which ACTION_PIN_RE deliberately does not match, so they need their own parser.

Create new instance of UpstreamRefPin(version, sha)

version: str

The bare version in the trailing # vX.Y.Z comment, or in the tag ref.

sha: str | None

The pinned 40-character commit SHA, or None for a bare tag pin.

class repomatic.release.version_sync.WorkflowLiteral(ecosystem: str, package: str, version: str)[source]

Bases: NamedTuple

A version literal embedded in a workflow command.

Create new instance of WorkflowLiteral(ecosystem, package, version)

ecosystem: str

Datasource: npm or pypi.

package: str

The package name.

version: str

The currently pinned version.

repomatic.release.version_sync.parse_min_age(value)[source]

Parse a minimum-release-age value into a timedelta.

Accepts the friendly relative durations uv allows for exclude-newer (8 days, 2 weeks, 36 hours). An unrecognized value logs a warning and yields no cooldown.

Parameters:

value (str) – The configured minimum-release-age string.

Return type:

timedelta

Returns:

The cooldown duration, or timedelta(0) when value does not parse.

repomatic.release.version_sync.min_release_age_days(value)[source]

Convert a minimum-release-age value to whole days for npm’s cooldown.

npm’s min-release-age resolver option (npm 11.10.0+) refuses any package version younger than the given number of days, across the whole resolved tree, transitive dependencies included. It is the runtime, transitive-tree counterpart to the pin cooldown parse_min_age() feeds sync-workflow-pins: the same minimum-release-age window, enforced by npm at install time.

Sub-day remainders round up, so a cooldown always over-protects rather than collapsing to 0, npm’s “no cooldown” sentinel.

Parameters:

value (str) – The configured minimum-release-age string (e.g. 8 days).

Return type:

int

Returns:

The cooldown as a whole number of days (0 when disabled).

repomatic.release.version_sync.exclude_newer_cutoff(value, today)[source]

uv --exclude-newer cutoff date for a minimum-release-age value.

uv’s cooldown knob is an absolute date, so the relative window is resolved live against today: packages uploaded on or after the returned date drop out of resolution. This gates ad-hoc uvx tool installs (via repomatic.tooling.tool_runner.run_tool()) by the same window sync-workflow-pins applies to pins. The uv counterpart to min_release_age_days() (npm).

Parameters:
  • value (str) – The configured minimum-release-age string (e.g. 8 days).

  • today (date) – Reference date, resolved once per run.

Return type:

str | None

Returns:

The cutoff as YYYY-MM-DD, or None when the cooldown is disabled (0 days or an unrecognized value), so callers omit the flag.

repomatic.release.version_sync.format_cooldown_note(age_label, cutoff)[source]

Render the minimum-release-age cutoff sentence for a diff table.

The version-sync counterpart to repomatic.deps.dep_report.format_exclude_newer_note(). uv records an absolute exclude-newer timestamp; here the cooldown is a relative span, so the effective cutoff is today - min_age, recomputed each run rather than stored.

Parameters:
  • age_label (str) – The configured minimum-release-age value (e.g. 8 days).

  • cutoff (date) – The effective cutoff date (today - min_age); releases published after it are held back.

Return type:

str

Returns:

A one-line markdown note for repomatic.deps.dep_report.format_diff_table().

repomatic.release.version_sync.cleared_cooldown(released, cutoff)[source]

Whether a release dated released is safely older than cutoff.

The comparison is strict, and that one character is load-bearing. Datasources report a release date, while the cooldown this gates is enforced downstream at instant granularity: uv’s --exclude-newer cutoff is now - min_age, carrying the run’s time of day. An inclusive <= therefore adopts a release published on the cutoff day but later in the day than the run’s own clock, which uv then refuses to resolve, pinning a version that cannot be installed until it ages out.

That is not hypothetical: a sync-workflow-pins run at 05:20 UTC adopted a release published at 17:07 on the cutoff date, and every binary build failed on No solution found until the window elapsed.

Being strict costs up to 24 hours of extra window and guarantees correctness, since a release dated before the cutoff day is older than any instant on it. Same “over-protect rather than under-protect” convention as min_release_age_days().

Parameters:
  • released (date) – Release date, as reported by the datasource.

  • cutoff (date) – The window boundary, today - min_age.

Return type:

bool

Returns:

True when the release may be adopted.

repomatic.release.version_sync.select_latest(candidates, min_age, today, *, allow_prerelease=False)[source]

Return the highest version old enough to clear the cooldown.

Candidates published more recently than min_age are held back, then the highest remaining PEP 440 version wins. Prereleases and versions that do not parse are skipped.

Parameters:
  • candidates (list[Candidate]) – Versions offered by a datasource.

  • min_age (timedelta) – The stabilization window from minimum-release-age.

  • today (date) – Reference date for the cooldown computation.

  • allow_prerelease (bool) – Keep prerelease versions when True.

Return type:

Candidate | None

Returns:

The winning Candidate, or None when none qualify.

repomatic.release.version_sync.select_held_back(candidates, pinned, min_age, today, *, allow_prerelease=False)[source]

Return the highest release withheld from pinned only by the cooldown.

The counterpart to select_latest(): among candidates strictly newer than pinned, keep those still inside the cooldown window (published more recently than min_age) and return the highest. These are the releases a later run adopts once they age out, surfaced in the ## ⏸️ Held back by cooldown PR section. No extra network call is needed: the candidates are already in hand from the select_latest() sweep.

Parameters:
  • candidates (list[Candidate]) – Versions offered by a datasource.

  • pinned (str) – The version this run settled on; only strictly newer candidates can be held back.

  • min_age (timedelta) – The stabilization window from minimum-release-age.

  • today (date) – Reference date for the cooldown computation.

  • allow_prerelease (bool) – Keep prerelease versions when True.

Return type:

Candidate | None

Returns:

The withheld Candidate, or None when nothing newer is inside the cooldown.

repomatic.release.version_sync.pin_inside_cooldown(candidates, pinned, min_age, today)[source]

The release date of pinned when it has not yet cleared the cooldown.

Audits a pin already written to disk, where select_latest() only ever judges one it is about to write. The two ask the same question through cleared_cooldown(), so an audit can never disagree with the decision that produced the pin.

Worth auditing separately because a pin can enter the tree without passing the selector at all: hand-edited, merged from a branch, restored from a revert, or written by an older release whose selector had a different boundary. Such a pin resolves through uvx in CI, where no per-package exemption is reachable, so it fails the whole job until it ages out.

Parameters:
  • candidates (list[Candidate]) – Versions offered by the datasource, as already fetched for the selection pass.

  • pinned (str) – The version currently written in the workflow.

  • min_age (timedelta) – The stabilization window from minimum-release-age.

  • today (date) – Reference date for the cooldown computation.

Return type:

date | None

Returns:

The release date when pinned is still inside the window, or None when it has cleared, is not among candidates, or carries an unparsable date.

repomatic.release.version_sync.github_candidates(repo_url, tag_pattern=None)[source]

Collect release candidates from a GitHub repository.

Parameters:
Return type:

list[Candidate]

Returns:

One Candidate per release whose tag yields a version. Empty when the API is unavailable (logged, never raised).

repomatic.release.version_sync.pypi_candidates(package)[source]

Collect non-yanked release candidates from PyPI.

Parameters:

package (str) – The PyPI package name.

Return type:

list[Candidate]

Returns:

One Candidate per non-yanked version.

repomatic.release.version_sync.npm_candidates(package)[source]

Collect release candidates from the npm registry.

Parameters:

package (str) – The npm package name.

Return type:

list[Candidate]

Returns:

One Candidate per published version.

repomatic.release.version_sync.setup_uv_verified_versions(shas)[source]

uv releases every pinned setup-uv commit can checksum-verify.

setup-uv verifies a download against a checksum table bundled into the action release. A version absent from that table is not refused: it is installed with no verification at all, on a core.debug line no CI log shows by default (src/download/checksum/checksum.ts). uv ships weekly and setup-uv roughly monthly, and sync-action-pins and sync-workflow-pins walk the two pins independently, so the uv pin drifts past the table on its own. Measured on 2026-08-20: setup-uv v9.0.0 stopped at uv 0.11.30 while every workflow here pinned 0.12.3, five releases later.

Intersecting rather than picking one table keeps a repository mid-bump honest: while sync-action-pins has landed on some files and not others, the only uv a whole fleet can verify is one both tables carry.

Parameters:

shas (Iterable[str]) – Every distinct SETUP_UV_SLUG commit pinned in the repository.

Return type:

frozenset[str] | None

Returns:

The uv versions verifiable by all of them, or None when no table could be read (no pin found, or every fetch failed), which leaves the caller ungated rather than blocked.

repomatic.release.version_sync.set_tool_version(content, name, new_version)[source]

Rewrite a tool’s version= field in the tool_registry.py source.

Targets the first version="…" inside the named ToolSpec( entry, stopping at the next entry so a later tool is never touched.

Parameters:
  • content (str) – The tool_registry.py source text.

  • name (str) – The TOOL_REGISTRY key (e.g. "gitleaks").

  • new_version (str) – The version to write.

Return type:

str

Returns:

The updated source text.

repomatic.release.version_sync.set_with_package_version(content, package, new_version)[source]

Rewrite a with_packages pin in the tool_registry.py source.

Targets the "{package}=={version}" literal wherever it appears, unlike set_tool_version(), which is scoped to one ToolSpec( entry. Two tools pinning the same package therefore converge on one version rather than drifting apart, matching how _widest_changes() collapses a name pinned at several versions elsewhere.

Parameters:
  • content (str) – The tool_registry.py source text.

  • package (str) – The package name as spelled in the pin ("mdformat-gfm").

  • new_version (str) – The version to write.

Return type:

str

Returns:

The updated source text.

repomatic.release.version_sync.find_action_pins(content)[source]

Find every SHA-pinned GitHub Action reference in a workflow file.

Return type:

list[ActionPin]

repomatic.release.version_sync.apply_action_pins(content, resolved)[source]

Rewrite SHA-pinned actions to their resolved SHA and version comment.

Parameters:
  • content (str) – The workflow file text.

  • resolved (dict[str, tuple[str, str]]) – Mapping of owner/repo slug to (new_sha, new_ref).

Return type:

tuple[str, list[tuple[str, str, str]]]

Returns:

The updated text and a list of (slug, old_ref, new_ref) changes actually applied (entries whose SHA already matched are skipped).

repomatic.release.version_sync.find_workflow_literals(content)[source]

Find npm and PyPI version literals embedded in a workflow file.

Return type:

list[WorkflowLiteral]

repomatic.release.version_sync.self_pin_exemption_re(package: str) Pattern[str][source]

Match a uvx command pinning package, capturing the flags before it.

Memoized: the splice runs once per workflow file for the same package.

Return type:

Pattern[str]

repomatic.release.version_sync.frozen_cli_invocation(package, version, exemption)[source]

Render the frozen, cooldown-exempt uvx invocation of package.

The one spelling both writers emit: the release freeze (PrepareRelease.freeze_cli_version) writes it wholesale, and apply_self_pin_exemption() converges an exemption-less command onto the same byte sequence, so the unfreeze pattern has exactly one shape to recognize.

Return type:

str

repomatic.release.version_sync.apply_self_pin_exemption(content, package, exemption)[source]

Splice a cooldown exemption into every uvx command pinning package.

The upstream toolkit’s inline pin moves in lockstep with the uses: refs, regardless of the cooldown, so the version it names can be minutes old. Every workflow exports a UV_EXCLUDE_NEWER covering all resolution, and uvx reads no per-package exemption from the environment or from pyproject.toml, so without the flag on the command line the freshly aligned pin fails to resolve until the window elapses.

Idempotent: a command already carrying the exemption is left untouched.

Parameters:
  • content (str) – The workflow file text.

  • package (str) – The self-pinned distribution name.

  • exemption (str) – The flag to splice in, ahead of the quoted requirement.

Return type:

str

Returns:

The updated text.

repomatic.release.version_sync.apply_workflow_literals(content, resolved, self_pin=None)[source]

Rewrite npm/PyPI version literals to their resolved version.

Parameters:
  • content (str) – The workflow file text.

  • resolved (dict[tuple[str, str], str]) – Mapping of (ecosystem, package) to the new version.

  • self_pin (tuple[str, str] | None) – Optional (package, exemption_flag) for the upstream toolkit’s own pin, whose rewrite bypasses the cooldown and therefore needs apply_self_pin_exemption() on the resulting command.

Return type:

tuple[str, list[tuple[str, str, str]]]

Returns:

The updated text and a list of (package, old_version, new_version) changes actually applied.

Important

The returned list covers version moves only. A self_pin splice edits the text while reporting nothing, because it names a package rather than moving a version, so a caller deciding whether to write must compare the returned text against its input rather than test the list. Gating on the list silently discards the backfill, which is what stranded downstream repos already pinned at the newest release.

repomatic.release.version_sync.find_upstream_ref_pins(content, upstream_repo)[source]

Extract the uses: refs of the upstream repo’s workflows, with their SHAs.

Matches reusable-workflow and composite-action refs of upstream_repo, both SHA-pinned with a trailing version comment (owner/repo/.github/workflows/lint.yaml@abc123 # v1.2.3) and directly tag-pinned (...@v1.2.3, which yields a None SHA).

Shared by lint-repo’s inline-pin lockstep check, sync-workflow-pins’ upstream-pin alignment and init’s pin floor (_highest_upstream_pin()), so all three read the refs the same way.

Return type:

list[UpstreamRefPin]

repomatic.release.version_sync.find_upstream_ref_versions(content, upstream_repo)[source]

Extract the bare uses: ref versions of the upstream repo’s workflows.

The version-only view of find_upstream_ref_pins().

Return type:

set[str]