repomatic.release package

The release lane.

Release preparation and version sync, binary building and verification, the binaries page, checksums, attestation, and the VirusTotal scan history.

Submodules

repomatic.release.attestation module

Naming and packing of the sigstore bundles attached to a release.

actions/attest writes every bundle to the same attestation.json basename, whatever it signed, so each release job has to rename its own before the files land in one directory. Three of them did, three different ways: the compiled binaries appended the suffix to the full filename, the man-page tarball dropped its .tar.gz first, and the consumer-declared extra assets were named after the job rather than any file. A release page therefore carried repomatic-manpages.attestation.json next to repomatic-manpages.tar.gz, and repomatic-extra-assets.attestation.json next to repomatic-claude-plugin.zip.

This module holds the one rule instead: a bundle is named after the artifact it attests. The subject list is read back out of the bundle rather than passed in, so the name is derived from what was actually signed and no caller can spell it differently. See bundle_filename() for the multi-subject case.

Note

The signing itself stays in actions/attest: it needs the job’s OIDC token, so it cannot move here. This module runs immediately after it, in the same job.

repomatic.release.attestation.ATTESTATION_SUFFIX: Final[str] = '.attestation.json'

Extension carried by every attestation bundle attached to a release.

Not .sigstore.json (the ecosystem’s own convention) because these files have been published under this name since the first attested release, and a release asset name is part of the surface users script against.

repomatic.release.attestation.bundle_subjects(bundle_path)[source]

Filenames of the artifacts a sigstore bundle attests.

A bundle wraps a DSSE envelope whose base64 payload is an in-toto Statement, and that statement’s subject array names every file signed in the same call. One entry for a single subject-path, several when actions/attest was handed a glob: “If multiple subjects are being attested at the same time, a single attestation will be created with references to each of the supplied subjects.”

Parameters:

bundle_path (Path) – The bundle actions/attest wrote.

Return type:

tuple[str, ...]

Returns:

Subject filenames, in the order the statement lists them.

Raises:

ValueError – If the file is not a bundle carrying a readable in-toto statement, or names a subject that is not a bare filename.

repomatic.release.attestation.bundle_filename(subjects, set_name=None)[source]

Name a bundle after the artifact it attests.

A single subject gives the bundle its own name, suffix appended to the whole filename so the two sort together on a release page listing assets alphabetically (papaya.tar.gz, then papaya.tar.gz.attestation.json).

Several subjects have no such name to borrow, since one bundle covers them all, so set_name is required and should describe the set rather than any member of it. That case only arises when a job hands actions/attest a glob.

Parameters:
Return type:

str

Returns:

The bundle’s filename.

Raises:

ValueError – If subjects is empty, or holds several entries with no set_name to fall back on.

repomatic.release.attestation.pack_attestation(bundle_path, asset_dir, set_name=None)[source]

Name a bundle after its subject and print what to upload with it.

Copies bundle_path into asset_dir under the name bundle_filename() derives, then returns that bundle alongside every artifact it attests, which is exactly the file list the release upload step has to attach for the provenance to be verifiable offline.

Every subject must already sit in asset_dir: a bundle naming a file that is not there means the job attested a different tree than the one it is about to upload, which would publish an asset whose sidecar covers something else. Immutable releases make that unfixable after the fact, so it fails here instead.

Idempotent: re-running copies the same bytes over the same name.

Parameters:
  • bundle_path (Path) – The bundle actions/attest wrote.

  • asset_dir (Path) – Directory holding the attested artifacts.

  • set_name (str | None) – Stem for the multi-subject case, see bundle_filename().

Return type:

list[Path]

Returns:

Sorted paths to upload, the renamed bundle included.

Raises:

ValueError – If a subject is missing from asset_dir.

repomatic.release.binaries_page module

Generate the binaries catalog: a CSV data file and its docs/binaries.md page.

The catalog inventories every compiled binary the repository ever released, one CSV row per binary: version (linking to the GitHub release), platform target (linking to the direct download), release date, and the VirusTotal detection snapshot (linking to the live analysis). It gives alpha and beta testers a single place to grab binaries from, and the maintainer an overview of how antivirus engines treat each release.

The data lives in docs/assets/binaries.csv, regenerated wholesale on every release from the GitHub Releases API (the single source of truth for published assets) and the JSON scan history maintained by scan-virustotal. The Markdown page renders it through a single csv-table directive and is otherwise static: it is created once from PAGE_TEMPLATE and only its marker-delimited region (the detection trend chart) is rewritten afterwards, so the intro and section prose stay hand-editable per repository.

Note

On the documentation site, the table is searchable and sortable client-side via the sphinx-datatables extension, which activates on the sphinx-datatable CSS class. The extension is optional: without it the csv-table directive still renders a plain table, and on GitHub the CSV file itself gets the built-in searchable grid viewer.

Note

Development builds are only linked, not cataloged: the rolling dev pre-release is refreshed on every push to the default branch, so any row frozen into the CSV would be stale within hours, while the workflow run artifacts behind the link always are the current builds.

repomatic.release.binaries_page.CHART_JS_URL = 'https://cdn.jsdelivr.net/npm/chart.js@4.5.0/dist/chart.umd.min.js'

Pinned CDN artifact drawing the detections trend chart.

The one external artifact this module publishes into every downstream repository’s docs, so it carries a checksum beside the pin: bump the version by hand together with CHART_JS_SRI.

repomatic.release.binaries_page.CHART_JS_SRI = 'sha384-XcdcwHqIPULERb2yDEM4R0XaQKU3YnDsrTmjACBZyfdVVqjh6xQ4/DCMd7XLcA6Y'

Subresource Integrity digest of CHART_JS_URL.

The browser refuses the script if the CDN bytes stop matching. Recompute on every version bump as the sha384 of the exact artifact, verified against the same file inside the npm registry tarball before trusting the CDN copy: hashlib.sha384(artifact_bytes) then base64.

repomatic.release.binaries_page.CSV_HEADERS = ('Version', 'Platform', 'Released', 'VirusTotal')

Column headers of the binaries CSV.

Deliberately compact: the version cell carries the link to the GitHub release, the platform cell the direct binary download, and the VirusTotal cell the analysis link, so no column holds a bare URL, filename, or 64-character checksum.

repomatic.release.binaries_page.FLAGGED_DANGER_PCT = 10

Flagged-verdict share (percent) at which the catalog shield turns red.

Below it, a flagged binary is the routine Nuitka false-positive tail worth a warning tint; from one engine in ten upward, the release deserves a false-positive submission round (see the /av-false-positive skill).

repomatic.release.binaries_page.LEGACY_PAGE_END_MARKER = '<!-- binaries-end -->'

Oldest closing marker, migrated to PAGE_END_MARKER on first touch.

repomatic.release.binaries_page.LEGACY_PAGE_START_MARKERS = ('<!-- binaries-start -->', '<!-- binaries-chart-start -->')

Superseded opening markers, migrated to PAGE_START_MARKER on first touch.

Two generations precede the current bare open: the original <!-- binaries-start -->, then the <!-- binaries-chart-start --> of the short-lived -start/-end pair. Both collapse to PAGE_START_MARKER, so a page written by any past version refreshes cleanly.

repomatic.release.binaries_page.PAGE_REGION = 'binaries-chart'

Region name spliced by click_extra.blocks.replace_region().

The generated chart lives between the <!-- binaries-chart --> and <!-- binaries-chart-end --> markers that PAGE_START_MARKER and PAGE_END_MARKER spell out, following click-extra’s <!-- name --> / <!-- name-end --> marker grammar with name = this value.

repomatic.release.binaries_page.PAGE_END_MARKER = '<!-- binaries-chart-end -->'

Closing marker of the generated chart region in the binaries page.

repomatic.release.binaries_page.PAGE_START_MARKER = '<!-- binaries-chart -->'

Opening marker of the generated chart region in the binaries page.

repomatic.release.binaries_page.PAGE_TEMPLATE

Initial page content, used when the page does not exist yet.

The {repo_url} placeholder is substituted with str.replace (not str.format, which would choke on the csv-table directive’s braces). Everything outside the marker pair is written once and never touched again: repositories can reword the prose without fighting the generator.

repomatic.release.binaries_page.render_chart_section(records)[source]

Render the detection trend across releases as a Chart.js timeline.

Plots the share of antivirus engine verdicts flagging each release’s binaries (all platforms aggregated), using the at-release snapshot of every file, on a true time axis: spacing reflects the actual gaps between releases. Points reuse the catalog shields’ color language, read at view time from sphinx-design’s CSS variables so they match the theme exactly (with hardcoded fallbacks). The data is embedded in the page rather than fetched, so the chart also works on file:// previews; only the Chart.js bundle comes from its CDN, mirroring how the table’s DataTables assets load.

Return type:

str

Returns:

A ## VirusTotal detections section with a raw HTML fence, or an empty string when fewer than two releases have records (a one-point trend is not a trend).

repomatic.release.binaries_page.render_binaries_csv(repo_slug, releases, records)[source]

Render the catalog data as CSV, one row per released binary.

Rows cover every published release carrying compiled binaries, ordered by descending version then filename. Cells hold Markdown links (parsed by MyST inside the csv-table directive): the version to the GitHub release, the platform to the binary download, and the VirusTotal cell to the file’s analysis. The VirusTotal cell renders the at-release snapshot as a green check when no engine flags the binary, and as the flagged-verdict share (tinted by FLAGGED_DANGER_PCT) otherwise.

Caution

Only a binary backed by a scan record gets a VirusTotal cell; every other row leaves it empty. A file page exists on VirusTotal solely because the file was submitted, while the catalog spans every release a repository ever published, including those predating scan-virustotal and those whose upload failed. Deriving the URL from the GitHub asset digest alone therefore sent readers to a blank page for each binary nobody ever uploaded, which was most of the catalog on older projects.

Caution

The version and platform cells decorate their links with sphinx-design’s octicon role, so the rendering repository needs sphinx-design in its documentation build (already true across this ecosystem’s docs stacks).

Parameters:
Return type:

str

Returns:

The full CSV content, header row included.

repomatic.release.binaries_page.update_binaries_csv(csv_path, content)[source]

Write the catalog CSV, creating parent directories as needed.

Parameters:
Return type:

bool

Returns:

True when the file was created or its content changed.

repomatic.release.binaries_page.update_binaries_page(page_path, chart_section, repo_slug)[source]

Create the binaries page if missing and refresh its chart region.

A missing page is created (with parent directories) from PAGE_TEMPLATE. On an existing page only the region between PAGE_START_MARKER and PAGE_END_MARKER is replaced by click_extra.blocks.replace_region(), leaving all surrounding prose untouched. Pages carrying any LEGACY_PAGE_START_MARKERS open or the LEGACY_PAGE_END_MARKER close are migrated to the current markers in the same pass.

Parameters:
  • page_path (Path) – Path to the Markdown page.

  • chart_section (str) – Rendered chart from render_chart_section(), or an empty string to leave the region empty.

  • repo_slug (str) – Repository in owner/repo form, interpolated into the template on first creation.

Return type:

bool

Returns:

True when the file was created or its content changed.

Raises:

ValueError – When the page exists but lacks the markers. Loud on purpose: a page not written by this generator must never be overwritten.

repomatic.release.binary module

Binary build targets and verification utilities.

Defines the Nuitka compilation targets for all supported platforms and provides native binary verification: architecture and minimum-OS floors are parsed straight from the executables’ ELF, Mach-O and PE headers, so no external tool is needed on runners or inside build containers.

repomatic.release.binary.BINARY_ASSET_SUFFIXES = ('.bin', '.exe')

File extensions identifying compiled binaries among release assets.

The one definition of “a compiled release asset”: scan-virustotal uploads this set, the release workflow downloads it (--pattern flags in _release-engine.yaml), docs/binaries.md lists it, and the dev-release asset globs derive from it.

repomatic.release.binary.PYTHON_DIST_SUFFIXES = ('.tar.gz', '.whl')

File extensions identifying Python distributions among release assets.

The counterpart of BINARY_ASSET_SUFFIXES, and the same kind of single definition: pack_binary_assets() excludes this set from the binary upload list (create-release already attached those), and the dev-release asset globs add it on top of the compiled binaries.

repomatic.release.binary.MACHO_MAGIC_64: Final[int] = 4277009103

Magic of a 64-bit Mach-O header, in the file’s own (little) endianness.

repomatic.release.binary.MACHO_FAT_MAGICS: Final[frozenset[int]] = frozenset({3405691582, 3405691583})

Big-endian magics of universal (fat) Mach-O containers, 32- and 64-bit.

class repomatic.release.binary.BinaryFormat(label, extension, floor_label)[source]

Bases: Enum

Executable container format a compiled binary uses.

Each member owns what varies by format: the name messages print, the file extension its executables take, what a target’s floor measures, and the header parser read_info() dispatches to. The floor label doubles as the enforceability flag, since a format recording no measurable floor has nothing to label.

Note

Members carry no magic bytes of their own: detect() needs to read them in a fixed order (a fat Mach-O and a PE both fail an equality test on the first four bytes), so the probe stays one method rather than a table.

ELF = ('elf', 'bin', 'glibc')
MACHO = ('macho', 'bin', 'macOS')
PE = ('pe', 'exe', None)
label

Lowercase format name, as printed in verification messages.

extension

File extension executables of this format take.

floor_label

What a target’s floor measures for this format, or None.

None marks a format whose headers record nothing a scan can check, so BuildTarget.enforced_floor reports no floor to enforce. Only PE is in that case: its version headers are nominal, and the Windows floor is CPython’s own support policy, not a linker artifact.

read_info(path)[source]

Parse a binary’s headers into its machine set and measured floor.

The machine element type follows the format (ELF machine names, Mach-O CPU type integers, the PE machine id or None when unparsable), matching what ARCH_MACHINES records as the expectation. The floor is the measured requirement verify_binary_floor() compares against the declared one: always None on PE, whose headers record nothing enforceable.

Parameters:

path (Path) – Path to the binary file.

Return type:

tuple[frozenset[object], str | None]

Returns:

(machines, floor) as parsed from the headers.

classmethod detect(path)[source]

Identify a file’s executable format from its magic bytes.

Parameters:

path (Path) – Path to the file to probe.

Return type:

BinaryFormat | None

Returns:

The matching format, or None for anything unrecognized.

repomatic.release.binary.PLATFORM_FORMATS: Final[dict[Platform | Group, BinaryFormat]] = {Group(id='linux', name='Linux distributions'): BinaryFormat.ELF, Platform(id='macos', name='macOS'): BinaryFormat.MACHO, Platform(id='windows', name='Windows'): BinaryFormat.PE}

Executable format each build platform compiles to.

Read through BuildTarget.binary_format, which is how every caller reaches it.

repomatic.release.binary.MACHINE_IDS: Final[dict[tuple[BinaryFormat, Architecture], str | int]] = {(BinaryFormat.ELF, Architecture(id='aarch64', name='ARM64 (AArch64)')): 'EM_AARCH64', (BinaryFormat.ELF, Architecture(id='x86_64', name='x86-64 (AMD64)')): 'EM_X86_64', (BinaryFormat.MACHO, Architecture(id='aarch64', name='ARM64 (AArch64)')): 16777228, (BinaryFormat.MACHO, Architecture(id='x86_64', name='x86-64 (AMD64)')): 16777223, (BinaryFormat.PE, Architecture(id='aarch64', name='ARM64 (AArch64)')): 43620, (BinaryFormat.PE, Architecture(id='x86_64', name='x86-64 (AMD64)')): 34404}

Machine identifier a binary’s header carries, per format and architecture.

One table rather than three, keyed the way PlatformKey keys the tool registry: the value is a pyelftools machine name on ELF and a raw header integer on Mach-O and PE, so a caller compares it against whatever the matching parser returns.

class repomatic.release.binary.BuildTarget(id, runner, platform, arch, floor, container=None)[source]

Bases: object

One Nuitka compile target: a runner image, a platform and an architecture.

Carries the metadata every consumer branches on, and the methods that interpret it, so a caller asks the target what its binaries must look like instead of re-deriving it from a platform name.

Platform and architecture are Extra Platforms traits rather than free strings, which is the same vocabulary PlatformKey keys the downloaded-binary registry on. as_matrix_entry() renders both back to their ids for the workflow matrix.

Every field is irreducible: anything a format decides (the file extension, what the floor measures, which header field to read) lives on BinaryFormat instead.

id: str

Short target identifier, chosen for user-friendliness.

It names the published release asset, so it must stay stable: download URLs, docs/install.md and the binaries.csv catalog all match on it.

Note

It is deliberately not derived from platform and arch, even though all six targets currently read {platform}-{short arch}. The asset names froze on the short x64 and arm64 spellings while those fields carry the canonical extra-platforms ids, and a future target splitting an existing pair (a musl Linux, say) would need a name the derivation cannot produce. tests/test_binary.py pins the pairing.

runner: str

Runner image, as named in GitHub-hosted runners.

Named for what it holds, not for the os matrix key it renders to: ubuntu-26.04-arm is an image, and platform is the operating system.

Hint

One compile job per target, each on one of the six runners the test matrix already covers, so a published binary is built on an image the suite is validated against. The targets are exactly KNOWN_RUNNERS, not a separate selection: an image is added here by widening the test axes, never on its own.

platform: Platform | Group

Operating system the binary runs on.

arch: Architecture

CPU architecture the binary is compiled for.

floor: str

Oldest runtime the binary is built to run on.

What the version counts is the format’s business, named by BinaryFormat.floor_label: a glibc symbol version on ELF, a macOS deployment target on Mach-O, a Windows release on PE. The first two are measured out of the compiled files by verify_binary_floor(); the third is documentation, since PE version headers record nothing to measure.

On macOS the release workflow also exports it as MACOSX_DEPLOYMENT_TARGET at compile time. Without it, compiled objects and processed dylibs inherit the build runner’s own macOS version.

container: str | None = None

OCI image the Linux compile and self-test jobs run in.

Passed to the container: key of the release workflow. Compiling inside manylinux_2_28 caps the toolchain at glibc 2.28, so binaries stop inheriting the floor of whatever glibc the current runner image ships.

Declared per target rather than derived from platform, which would assume every Linux target is a manylinux one. Absent on macOS and Windows: GitHub Actions containers do not exist for those runners.

property binary_format: BinaryFormat

Executable format the compiler emits for this target.

property extension: str

File extension of the compiled binary.

property expected_machine: str | int

Machine identifier this target’s binaries carry in their header.

property enforced_floor: str | None

floor, or None when the format records nothing to measure.

as_matrix_entry()[source]

Flat, JSON-safe mapping of this target, for GitHub matrix inclusion.

Renders platform and arch back to their extra-platforms ids, the spelling the workflow expressions (matrix.platform_id) and tests.yaml’s runner check compare against, and runner back to the os key runs-on: reads. A target with no container omits the key, so an entry carries only what applies to it.

Return type:

dict[str, str]

repomatic.release.binary.NUITKA_BUILD_TARGETS: Final[dict[str, BuildTarget]] = {'linux-arm64': BuildTarget(id='linux-arm64', runner='ubuntu-26.04-arm', platform=Group(id='linux', name='Linux distributions'), arch=Architecture(id='aarch64', name='ARM64 (AArch64)'), floor='2.28', container='quay.io/pypa/manylinux_2_28_aarch64@sha256:e7035406e58d96b7407246af1f6514a3cbd753a0025b42b9adfbeadd3b29ba80'), 'linux-x64': BuildTarget(id='linux-x64', runner='ubuntu-26.04', platform=Group(id='linux', name='Linux distributions'), arch=Architecture(id='x86_64', name='x86-64 (AMD64)'), floor='2.28', container='quay.io/pypa/manylinux_2_28_x86_64@sha256:fdb9a9c223b215604dc7b6f7e8fff4b39bfea5fbaa7777a2e5544a60dfa437f8'), 'macos-arm64': BuildTarget(id='macos-arm64', runner='macos-26', platform=Platform(id='macos', name='macOS'), arch=Architecture(id='aarch64', name='ARM64 (AArch64)'), floor='11.0', container=None), 'macos-x64': BuildTarget(id='macos-x64', runner='macos-26-intel', platform=Platform(id='macos', name='macOS'), arch=Architecture(id='x86_64', name='x86-64 (AMD64)'), floor='10.15', container=None), 'windows-arm64': BuildTarget(id='windows-arm64', runner='windows-11-arm', platform=Platform(id='windows', name='Windows'), arch=Architecture(id='aarch64', name='ARM64 (AArch64)'), floor='11', container=None), 'windows-x64': BuildTarget(id='windows-x64', runner='windows-2025', platform=Platform(id='windows', name='Windows'), arch=Architecture(id='x86_64', name='x86-64 (AMD64)'), floor='10', container=None)}

GitHub-hosted runner matrix for Nuitka builds, keyed by target name.

The roster is closed: every entry compiles on a runner the test matrix already covers, and the key doubles as the compiled binary’s published identifier. See BuildTarget for what each field means and which of them are frozen.

repomatic.release.binary.FLAT_BUILD_TARGETS: Final[list[dict[str, str]]] = [{'arch': 'aarch64', 'container': 'quay.io/pypa/manylinux_2_28_aarch64@sha256:e7035406e58d96b7407246af1f6514a3cbd753a0025b42b9adfbeadd3b29ba80', 'extension': 'bin', 'floor': '2.28', 'os': 'ubuntu-26.04-arm', 'platform_id': 'linux', 'target': 'linux-arm64'}, {'arch': 'x86_64', 'container': 'quay.io/pypa/manylinux_2_28_x86_64@sha256:fdb9a9c223b215604dc7b6f7e8fff4b39bfea5fbaa7777a2e5544a60dfa437f8', 'extension': 'bin', 'floor': '2.28', 'os': 'ubuntu-26.04', 'platform_id': 'linux', 'target': 'linux-x64'}, {'arch': 'aarch64', 'extension': 'bin', 'floor': '11.0', 'os': 'macos-26', 'platform_id': 'macos', 'target': 'macos-arm64'}, {'arch': 'x86_64', 'extension': 'bin', 'floor': '10.15', 'os': 'macos-26-intel', 'platform_id': 'macos', 'target': 'macos-x64'}, {'arch': 'aarch64', 'extension': 'exe', 'floor': '11', 'os': 'windows-11-arm', 'platform_id': 'windows', 'target': 'windows-arm64'}, {'arch': 'x86_64', 'extension': 'exe', 'floor': '10', 'os': 'windows-2025', 'platform_id': 'windows', 'target': 'windows-x64'}]

List of build targets in a flat format, suitable for matrix inclusion.

repomatic.release.binary.binary_name(package, target, version=None)[source]

Compose a compiled binary’s release-asset filename.

The one definition of the naming convention: {package}-{version}-{target}.{ext} for the versioned upload, and with no version the stable alias ({package}-{target}.{ext}) backing the releases/latest/download URLs. The extension comes from NUITKA_BUILD_TARGETS.

Return type:

str

repomatic.release.binary.versionless_alias(filename, version)[source]

Map a versioned binary filename to its stable alias, or None.

Strips the -{version}- segment (papaya-1.2.3-linux-arm64.bin becomes papaya-linux-arm64.bin). Returns None for filenames that carry no such segment or are not compiled binaries, so callers can filter and map in one pass.

Return type:

str | None

repomatic.release.binary.binary_filename_re(package)[source]

Match a package binary filename, versioned or versionless.

Captures target and ext, both alternations derived from NUITKA_BUILD_TARGETS so a new build target extends the pattern without anyone editing a regex. The release freeze rewrites both spellings onto the versioned form through this; tests/test_platform_keys.py pins the pattern against every target.

Return type:

Pattern[str]

repomatic.release.binary.pack_binary_assets(dist_dir, version)[source]

Pack a release’s upload list, materializing the versionless aliases.

Mirrors what the release engine’s upload step needs: every file in dist_dir except the Python distributions (create-release already uploaded those), plus a byte-identical versionless alias copied beside each versioned binary so the stable releases/latest/download URLs always resolve. Aliases share their sibling’s digest, which is what lets artifact attestations verify them unchanged and the binaries catalog collapse them (see binaries_page._binary_assets).

Idempotent: re-running overwrites the same aliases with the same bytes.

Parameters:
  • dist_dir (Path) – Directory holding the compiled binaries and attestation bundles downloaded from the build jobs.

  • version (str) – The release version whose binaries earn aliases.

Return type:

list[Path]

Returns:

Sorted paths to upload, aliases included.

repomatic.release.binary.BINARY_AFFECTING_PATHS: Final[tuple[str, ...]] = ('.github/workflows/_release-engine.yaml', '.github/workflows/release.yaml', 'pyproject.toml', 'tests/', 'uv.lock')

Path prefixes that always affect compiled binaries, regardless of the project.

Project-specific source directories (derived from [project.scripts] in pyproject.toml) are added dynamically by binary_affecting_paths.

The release workflow entries cover both layouts: upstream keeps the _release-engine.yaml lane (which defines the Nuitka compile and binary self-test jobs) in-repo, while downstream repos call the engine cross-repo from their generated release.yaml, so a pin bump there rightly triggers a rebuild.

repomatic.release.binary.SKIP_BINARY_BUILD_BRANCHES: Final[frozenset[str]] = frozenset({'format-images', 'format-json', 'format-markdown', 'format-shell', 'sync-gitignore', 'sync-mailmap', 'update-dep-graph'})

Autofix branches whose changes cannot affect compiled binaries.

Members are PR branch names produced by autofix jobs that touch only repository housekeeping (.mailmap, .gitignore, JSON, Markdown, images, shell scripts, dependency graph). The binary output is unchanged, so skip_binary_build returns True when the PR head branch matches a member, saving an expensive Nuitka compilation.

Note

This set is intentionally disjoint from repomatic.git_ops.VERSION_BUMP_BRANCHES: version-bump branches do change binaries (they rewrite the version string baked into the build), so they belong to a different policy.

repomatic.release.binary.LC_VERSION_MIN_MACOSX: Final[int] = 36

Mach-O load command carrying the minimum macOS version (pre-10.14 SDKs).

repomatic.release.binary.LC_BUILD_VERSION: Final[int] = 50

Mach-O load command carrying the platform and minimum OS (10.14+ SDKs).

repomatic.release.binary.MACHO_PLATFORM_MACOS: Final[int] = 1

platform field value naming macOS inside an LC_BUILD_VERSION command.

repomatic.release.binary.verify_binary_arch(target, binary_path)[source]

Verify that a binary matches the expected architecture for a target.

Parses the executable’s own headers, so it needs no external tool and behaves identically on runner VMs and inside build containers.

Parameters:
  • target (str) – Build target (e.g., ‘linux-arm64’, ‘macos-x64’).

  • binary_path (Path) – Path to the binary file.

Raises:
  • ValueError – If target is unknown.

  • ValueError – If binary format or architecture does not match.

Return type:

None

repomatic.release.binary.verify_binary_floor(target, binary_path, dist_dirs=())[source]

Verify the binary and its dist tree stay within the target’s OS floor.

Scans the onefile binary itself plus every native library of the given Nuitka dist directories (whose content the onefile payload repacks), and compares each file’s measured requirement to the target’s declared floor:

What the floor counts comes from the format, per BinaryFormat.floor_label:

  • ELF: the highest GLIBC_x.y version requirement of each file. A higher requirement means a compiled object picked up symbols newer than the build container provides for, and the binary would die at load time on the distributions the floor promises.

  • Mach-O: the minos of each file, against the deployment target the build exports as MACOSX_DEPLOYMENT_TARGET.

  • PE: nothing. Its version headers are nominal, so BuildTarget.enforced_floor reports no floor and this returns early; the Windows floor is CPython’s own support policy, tracked in the docs.

Parameters:
  • target (str) – Build target (e.g., ‘linux-arm64’, ‘macos-x64’).

  • binary_path (Path) – Path to the binary file.

  • dist_dirs (Iterable[Path]) – Nuitka dist directories to include in the scan.

Raises:
  • ValueError – If target is unknown.

  • ValueError – If any scanned file exceeds the declared floor.

Return type:

None

repomatic.release.checksums module

Recompute SHA-256 checksums for the binary tool registry.

Iterates every TOOL_REGISTRY entry with a binary spec, downloads each platform’s release artifact, and rewrites stale hashes in-place in tool_registry.py (alongside the VERSIONS stamps). Driven by repomatic update-checksums and, with a version override, by sync-tool-versions so a version bump and its matching checksums land in one pass.

repomatic.release.checksums.update_registry_checksums(registry_path, version_overrides=None)[source]

Recompute binary checksums and version stamps in tool_registry.py.

Iterates every TOOL_REGISTRY entry with a binary spec, downloads each platform URL (concurrently, sized by the global --jobs option and sequential at DEBUG verbosity or without an active CLI context), computes its SHA-256, and replaces stale hashes in-place. Also reconciles each tool’s VERSIONS stamp with the version the checksums were computed for, the basis of the offline staleness test.

Parameters:
  • registry_path (Path) – Path to tool_registry.py.

  • version_overrides (dict[str, str] | None) – Optional mapping of tool name to a version to download instead of the in-memory ToolSpec.version. sync-tool-versions passes this so it can bump the version in the source and refresh the checksums in a single process: the in-memory registry still holds the pre-bump version because the file was edited, not reimported.

Return type:

list[tuple[str, str, str]]

Returns:

List of (url, old_hash, new_hash) for each updated checksum. Empty if all checksums are already correct.

repomatic.release.prepare_release module

Prepare a release by updating changelog, citation, install guide, and workflow files.

A release cycle produces exactly two commits that must be merged via “Rebase and merge” (never squash):

  1. Freeze commit ([changelog] Release vX.Y.Z):

    • Strips the .dev0 suffix from the version.

    • Finalizes the changelog date and comparison URL.

    • Freezes workflow action references: @main@vX.Y.Z.

    • Freezes CLI invocations: uv run --frozen -- repomatic (from the lockfile) → uvx 'repomatic==X.Y.Z' (from PyPI, for downstream repos).

    • Freezes the install guide’s binary download URLs to versioned release paths.

    • Pins the install guide’s versioned CLI examples to the release.

    • Sets the release date in citation.cff.

  2. Unfreeze commit ([changelog] Post-release bump vX.Y.Z vX.Y.(Z+1)):

    • Reverts action references: @vX.Y.Z@main.

    • Reverts CLI invocations back to local source for dogfooding.

    • Bumps the version with a .dev0 suffix.

    • Adds a new unreleased changelog section.

The auto-tagging job in release.yaml depends on these being separate commits — it uses release_commits_matrix to identify and tag only the freeze commit. Squash-merging would collapse both into one, breaking the tagging logic. See the detect-squash-merge job for the safeguard.

Caution

Rebase-merging the two commits delivers them in a single push, and GitHub Actions reads workflow files from that push’s head: the unfreeze commit. So the release lane of this repository always executes the unfrozen workflow content, running LOCAL_CLI_INVOCATION against uv.lock, even while building the freeze commit named in release_commits_matrix. Every job calling the CLI therefore needs its own checkout of matrix.commit, and a job written on the assumption that the frozen uvx 'repomatic==X.Y.Z' form is what runs will fail with Failed to spawn: repomatic.

Only downstream repositories, which call the reusable workflow at its vX.Y.Z tag, ever execute the frozen form. tests/test_workflows.py locks the checkout requirement across every workflow.

Both operations are idempotent: re-running on an already-frozen or already-unfrozen tree is a no-op.

repomatic.release.prepare_release.SELF_PIN_COOLDOWN_EXEMPTION = '--exclude-newer-package repomatic=P0D'

uv escape hatch letting a just-published repomatic install under the cooldown.

Every workflow exports a UV_EXCLUDE_NEWER covering all package resolution (see claude.md § Cooldown on every install), and it applies to the frozen 'repomatic==X.Y.Z' self-pin like any other requirement. That pin moves in lockstep with the uses: refs pointing at the same tag, so the version it names is always minutes old: without an exemption every downstream repo would fail to resolve it until the window elapsed. A zero-length window sets that one package’s cutoff to “now”, leaving the rest of the tree gated.

uv exposes no environment variable for --exclude-newer-package, so the exemption has to ride on the command line, which is why the freeze splices it in beside the pin instead of the workflows declaring it once.

Note

A uvx resolution reads no project configuration at all, so moving the exemption into [tool.uv] or an adjacent uv.toml would not work either: both are ignored. See claude.md § Per-ecosystem knobs.

Todo

Declare the exemption once, instead of splicing it onto every frozen command line, as soon as uv grows a configuration or environment knob for --exclude-newer-package: uv#20995.

Spelled as the ISO 8601 P0D rather than the "0 day" used in pyproject.toml’s exclude-newer-package table: the flag travels through YAML folded scalars into a shell, where the space in 0 day would need quoting that survives both. P0D needs none.

Caution

Every character here lands on 80-odd already-long workflow lines at freeze time. tests/test_prepare_release.py simulates a freeze and fails if the result breaches yamllint’s 120-column cap, so lengthening this string means reflowing the workflows that no longer fit.

repomatic.release.prepare_release.LOCAL_CLI_INVOCATION = 'uv --no-progress run --frozen -- repomatic'

How every workflow on main runs the CLI, before the freeze rewrites it.

Resolving from uv.lock rather than from the index is what keeps the cooldown off the critical path here. A lockfile entry is pinned and hash-verified, so it is strictly stronger than a publication-age gate, and it cannot be made unsatisfiable by one: uvx --from . re-resolves [project.dependencies] on every call, and reads neither uv.lock nor [tool.uv] exclude-newer-package, so a floor naming a release younger than the window took every workflow down at once with nowhere to record the bypass.

--frozen uses the lockfile as-is instead of asserting it is current, which is deliberate: --locked would fail every job the moment pyproject.toml drifted ahead of uv.lock, including the sync-uv-lock job whose whole purpose is to close that gap.

class repomatic.release.prepare_release.PrepareRelease(changelog_path=None, citation_path=None, workflow_dir=None, install_path=None, marketplace_path=None, default_branch='main')[source]

Bases: object

Prepare files for a release by updating dates, URLs, and removing warnings.

modified_files: list[Path]
property current_version: str[source]

Extract current version from the bump-my-version config.

Delegates discovery to Metadata.get_current_version(), which searches .bumpversion.toml then pyproject.toml.

property package_name: str | None[source]

Canonical PyPI package name, used to spot pinned install examples.

Delegates discovery to Metadata.package_name, which reads pyproject.toml.

property release_date: str[source]

Return today’s date in UTC as YYYY-MM-DD.

set_citation_release_date()[source]

Update the date-released field in citation.cff.

Return type:

bool

Returns:

True if the file was modified.

property composite_action_names: list[str][source]

Discover composite action directories under .github/actions/.

Enumerates every .github/actions/*/action.yaml (or .yml) and returns the directory names. New composite actions automatically participate in freeze/unfreeze without requiring code changes here.

Returns:

Sorted list of composite action directory names.

freeze_workflow_urls()[source]

Replace workflow URLs from default branch to versioned tag.

This is part of the freeze step: it freezes workflow references to the release tag so released versions reference immutable URLs.

Return type:

int

Returns:

Number of files modified.

freeze_install_download_urls(version)[source]

Replace binary download URLs in the install guide with versioned paths.

This is part of the freeze step: it freezes the install guide’s download links to a specific GitHub release so users get explicit, versioned URLs instead of the /releases/latest/download/ redirect. Both spellings resolve, since every release also carries versionless alias copies of its binaries (see pack_binary_assets()); the frozen URL is preferred because it names the version the reader is installing, and keeps working once a later release moves latest.

Handles two input forms:

  • Initial (never frozen): /releases/latest/download/repomatic-linux-arm64.bin

  • Previously frozen: /releases/download/v6.0.0/repomatic-6.0.0-linux-arm64.bin

Both are transformed to: /releases/download/v{version}/repomatic-{version}-linux-arm64.bin

Note

No unfreeze method is needed. Unlike workflow URLs (which toggle @main@vX.Y.Z), download URLs ratchet forward: they always point to a specific release. After unfreeze, the install guide still shows the last release’s URLs, which is what users wanting stable binaries need.

Caution

The freeze runs before the binaries exist, since it is the freeze commit that triggers the build. So it pins the version optimistically, and a release whose binary lane fails leaves the install guide linking six URLs that 404 until the next release ratchets past it. Re-point the guide at the last release that carries binaries when that happens, by calling this method with that version.

Parameters:

version (str) – The release version to freeze to.

Return type:

bool

Returns:

True if the file was modified.

freeze_marketplace_archive_url(version)[source]

Pin the plugin marketplace’s archive URL to this release.

This is part of the freeze step. The archive source in .claude-plugin/marketplace.json points at the release asset named by ARCHIVE_NAME, and pinning the tag is what makes a marketplace ref meaningful: adding the catalog at kdeldycke/repomatic@v6.0.0 then installs v6.0.0’s plugin, where a latest redirect would hand over whatever shipped most recently regardless of the ref asked for.

Handles the same two input forms as freeze_install_download_urls():

  • Initial (never frozen): /releases/latest/download/repomatic-claude-plugin.zip

  • Previously frozen: /releases/download/v6.0.0/repomatic-claude-plugin.zip

Note

The trailing filename is rewritten too, not just the tag, so ARCHIVE_NAME is the single source of truth for the whole URL. Renaming the asset would otherwise leave the checked-in URL naming a file the next release no longer publishes, and the mismatch would only surface as a failed /plugin install. Rewriting both together also keeps the default branch installable across the rename: the URL still names the asset the last published release actually carries until this method flips tag and filename in the same commit.

Note

No unfreeze method, for the same reason download URLs have none: the URL ratchets forward. The post-release .devN bump leaves it alone, so the default branch keeps pointing at the newest published release rather than at a vX.Y.Z.dev0 tag that was never created. That is what makes every state of this file installable, which a bump-my-version entry rewriting it on both commits could not achieve.

Parameters:

version (str) – The release version to freeze to.

Return type:

bool

Returns:

True if the file was modified.

freeze_install_cli_version(version)[source]

Pin the install guide’s versioned CLI examples to the release.

This is part of the freeze step: the install guide’s Specific version``tab demonstrates a pinned invocation (``uvx {package}@X.Y.Z or a {package}==X.Y.Z requirement), which must always showcase the latest release. Without this pass the pinned example silently rots (click-extra’s install guide sat on a 14-releases-old pin).

Note

Like freeze_install_download_urls(), this ratchets forward with no unfreeze: after a release the examples keep demonstrating that release, which is what readers should copy until the next one ships.

Parameters:

version (str) – The release version to pin the examples to.

Return type:

bool

Returns:

True if the file was modified.

freeze_cli_version(version)[source]

Replace local source CLI invocations with a frozen PyPI version.

This is part of the freeze step: it freezes repomatic invocations to a specific PyPI version so the released workflow files reference a published package. Downstream repos that check out a tagged release will install from PyPI rather than expecting a local source tree.

Replaces uv --no-progress run --frozen -- repomatic with uvx --no-progress 'repomatic=={version}' in all workflow YAML files. Comment lines (starting with #) are skipped to avoid corrupting explanatory comments.

The two halves are not symmetric by accident. On main the CLI runs from uv.lock, which is pinned and hash-verified, and which no cooldown can make unsatisfiable. A downstream repo has no such lockfile for this project, so its copy has to resolve the published package from the index, which is what uvx does.

The pin is spliced in behind SELF_PIN_COOLDOWN_EXEMPTION, which is what keeps a release installable the minute it is published despite the workflow-wide cooldown. Local source needs no exemption, so main carries none between releases.

Parameters:

version (str) – The PyPI version to freeze to.

Return type:

int

Returns:

Number of files modified.

unfreeze_cli_version()[source]

Replace frozen PyPI CLI invocations with local source.

This is part of the unfreeze step: it reverts repomatic invocations back to local source (--from . repomatic) for the next development cycle on main.

Replaces uvx --no-progress 'repomatic==X.Y.Z' with LOCAL_CLI_INVOCATION, taking SELF_PIN_COOLDOWN_EXEMPTION with it when the freeze put one there: the lockfile resolves from the working tree, so it never needs the escape hatch. The exemption is optional in the pattern so a workflow frozen by an older release still unfreezes cleanly. Comment lines are skipped (see freeze_cli_version()).

Return type:

int

Returns:

Number of files modified.

unfreeze_workflow_urls()[source]

Replace workflow URLs from versioned tag back to default branch.

This is part of the unfreeze step: it reverts workflow references back to the default branch for the next development cycle, across the same reference set as freeze_workflow_urls().

Return type:

int

Returns:

Number of files modified.

prepare_release(update_workflows=False)[source]

Run all freeze steps to prepare the release commit.

Parameters:

update_workflows (bool) – If True, also freeze workflow URLs to versioned tag and freeze CLI invocations to the current version.

Return type:

list[Path]

Returns:

List of modified files.

post_release(update_workflows=False)[source]

Run all unfreeze steps to prepare the post-release commit.

Parameters:

update_workflows (bool) – If True, unfreeze workflow URLs back to default branch and unfreeze CLI invocations back to local source.

Return type:

list[Path]

Returns:

List of modified files.

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]

repomatic.release.virustotal module

Upload release binaries to VirusTotal and record detection snapshots.

Submits compiled binaries (.bin, .exe) to the VirusTotal API for malware scanning. This seeds antivirus vendor databases with the signatures of freshly built binaries, which keeps false-positive rates in check for downstream distributors.

Detection statistics polled after an upload are appended to a CSV history, one record per binary per scan date. The sync-binaries command renders that history into the binaries catalog page (docs/binaries.md).

Note

Scan results are deliberately kept out of GitHub release notes: a raw flagged / total count next to a download link reads as a malware verdict to visitors, when it is almost always Nuitka onefile false positives. See kdeldycke/meta-package-manager#1911 for the confusion this caused. The catalog page provides the context release notes cannot.

Note

The free-tier API allows 4 requests per minute. All API calls (uploads and polls) are rate-limited with a sleep between each request.

repomatic.release.virustotal.FREE_TIER_RATE_LIMIT = 4

VirusTotal free-tier request budget, in API calls per minute.

The single source for the upload and polling pace: the scan-virustotal CLI default and both client functions below derive from it.

repomatic.release.virustotal.SCAN_HEADERS = ('tag', 'filename', 'sha256', 'scanned', 'malicious', 'suspicious', 'undetected', 'harmless')

Columns of the committed scan history, in file order.

The release and the file it identifies first, then the four verdict counts, so the table reads left to right from what was scanned to what came back. Rows are ordered by release version rather than alphabetically, which a plain sort of the tag strings would get wrong past v9.

repomatic.release.virustotal.VIRUSTOTAL_GUI_URL = 'https://www.virustotal.com/gui/file/{sha256}'

URL template for the VirusTotal file analysis page.

class repomatic.release.virustotal.DetectionStats(malicious, suspicious, undetected, harmless)[source]

Bases: object

Detection statistics from a completed VirusTotal analysis.

Stores only the four categories that constitute a definitive verdict. type-unsupported, timeout, and failure from the API response are excluded from the total.

malicious: int

Number of engines that flagged the file as malicious.

suspicious: int

Number of engines that flagged the file as suspicious.

undetected: int

Number of engines that found no threat.

harmless: int

Number of engines that classified the file as harmless.

property flagged: int

Total engines that flagged the file (malicious + suspicious).

property total: int

Total engines that produced a definitive verdict.

class repomatic.release.virustotal.ScanResult(filename, sha256, analysis_url, detection_stats=None)[source]

Bases: object

Result of uploading a single file to VirusTotal.

filename: str

Original filename of the uploaded binary.

sha256: str

SHA-256 hash of the file content.

analysis_url: str

VirusTotal web GUI URL for the file analysis.

detection_stats: DetectionStats | None = None

Detection statistics, or None if analysis is still pending.

class repomatic.release.virustotal.ScanRecord(tag, filename, sha256, scanned, stats)[source]

Bases: object

A detection snapshot for one binary, taken on a given date.

Records accumulate in a CSV history (see upsert_scan_records()) committed to the repository. Each record freezes the flagged / total verdict counts at scan time, so the history supports trend analysis across releases even after VirusTotal re-analyzes the files or vendors process false-positive reports.

tag: str

Git tag of the release the binary belongs to (e.g. v1.2.3).

filename: str

Filename of the scanned binary.

sha256: str

SHA-256 hash of the file content.

scanned: str

Scan date in YYYY-MM-DD format.

stats: DetectionStats

Detection statistics at scan time.

property key: tuple[str, str]

Deduplication identity: the same file scanned on the same day.

as_row()[source]

Flatten to one CSV row, in SCAN_HEADERS order.

Return type:

tuple[str, ...]

classmethod from_row(data)[source]

Rebuild a record from one parsed CSV row, or a legacy JSON mapping.

Both shapes carry the same eight keys, so one reader covers a store mid-migration as well as one already converted.

Return type:

ScanRecord

repomatic.release.virustotal.scan_files(api_key, file_paths, rate_limit=4)[source]

Upload files to VirusTotal and return scan results.

Uses the synchronous vt.Client API. Sleeps between uploads to respect the free-tier rate limit.

Parameters:
  • api_key (str) – VirusTotal API key.

  • file_paths (list[Path]) – Paths to binary files to upload.

  • rate_limit (int) – Maximum requests per minute (free tier: 4).

Return type:

list[ScanResult]

Returns:

List of scan results with analysis URLs.

repomatic.release.virustotal.poll_detection_stats(api_key, results, rate_limit=4, timeout=600)[source]

Poll VirusTotal for detection statistics of previously uploaded files.

Queries GET /files/{sha256} for each file until analysis completes or the timeout is reached. Respects the free-tier rate limit for all API calls.

Parameters:
  • api_key (str) – VirusTotal API key.

  • results (list[ScanResult]) – Scan results from a previous upload.

  • rate_limit (int) – Maximum API requests per minute (shared with uploads).

  • timeout (int) – Maximum seconds to wait for all analyses to complete.

Return type:

list[ScanResult]

Returns:

Results with detection_stats populated (or None for files whose analysis did not complete before the timeout).

repomatic.release.virustotal.records_from_results(results, tag, scanned=None)[source]

Build history records from scan results whose analysis completed.

Results still pending (no detection statistics) are skipped: a record without verdict counts carries no information the release assets don’t already provide.

Parameters:
  • results (list[ScanResult]) – Scan results, typically from poll_detection_stats().

  • tag (str) – Git tag of the release the binaries belong to.

  • scanned (str | None) – Snapshot date in YYYY-MM-DD format. Today (UTC) when None.

Return type:

list[ScanRecord]

Returns:

One record per result with detection statistics.

repomatic.release.virustotal.records_from_release_notes(body, tag, scanned)[source]

Recover detection snapshots from a legacy release-notes table.

Before the scan history file existed, the release pipeline appended a VirusTotal table to GitHub release notes, with a flagged / total Detections cell frozen minutes after publication. Those cells are genuine at-release snapshots, so sync-binaries --backfill-records harvests them to seed the history for releases that predate the file.

Note

The legacy table only recorded the flagged and total aggregates, not the malicious/suspicious/undetected/harmless split. The split is rebuilt as flagged = malicious and the remainder = undetected, which is lossless for everything the catalog consumes (flagged and total).

Parameters:
  • body (str) – Release notes markdown.

  • tag (str) – Git tag of the release.

  • scanned (str) – Snapshot date, normally the release publication date.

Return type:

list[ScanRecord]

Returns:

One record per table row carrying a numeric Detections cell.

repomatic.release.virustotal.load_scan_records(path)[source]

Load scan records from the CSV history file.

Note

A repository whose history predates the CSV store carries the same records in a sibling .json, and is read from there when the CSV is absent. The next upsert_scan_records() write lands as CSV, so a repository migrates on its first release after upgrading without anyone converting anything. The stale .json is then inert and can be deleted.

Parameters:

path (Path) – Path to the CSV file.

Return type:

list[ScanRecord]

Returns:

The records, or an empty list when neither file exists.

Raises:

ValueError – When a file exists but cannot be parsed. Loud on purpose: a corrupt history must never be silently clobbered by the next upsert_scan_records() write.

repomatic.release.virustotal.upsert_scan_records(path, new_records)[source]

Merge new records into the CSV history at path.

Records sharing the same (sha256, scanned) identity are replaced, so re-running a scan the same day is idempotent. The file is created (with its parent directories) when missing, and always rewritten in normalized form, sorted by version, filename and scan date.

CSV also keeps the store out of the autofix lane: nothing there rewrites a .csv, where a committed JSON file has to match whatever layout Biome is configured for or format-json reformats it right back. See claude.md § Naming conventions rule 8 for the rest of that reasoning.

Parameters:
  • path (Path) – Path to the CSV history.

  • new_records (list[ScanRecord]) – Records to merge in.

Return type:

bool

Returns:

True when the file content changed.