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.MARKETPLACE_REF_RE = re.compile('("ref":\\s*")[^"]*(")')

The git ref the plugin marketplace entry reads its plugin directory from.

Matches any value rather than a tag shape, because this pin round-trips: the freeze writes vX.Y.Z and the unfreeze writes the default branch back.

repomatic.release.prepare_release.JSON_VERSION_RE = re.compile('("version":\\s*")[\\d.]+(")')

A JSON version field holding a release version.

Ratchets forward on release, unlike MARKETPLACE_REF_RE beside it: it names the last release rather than the ref serving the content, and it is the string the app compares to decide whether an update is due.

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 docs/workflows.md § Install-time cooldown), 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 (verified against uv 0.12.3). The one knob that does reach it, --config-file / UV_CONFIG_FILE, replaces discovered configuration instead of merging with it: set for a whole CI environment, it silently drops required-version, exclude-newer, dependency-groups and build-backend from every other uv command there, unless a complete mirror of [tool.uv] sits beside it.

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. Glob exemptions (astral-sh/uv#20788) and pin-based bypasses (astral-sh/uv#19864, astral-sh/uv#18921) could settle it too.

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.

repomatic.release.prepare_release.json_version_rule(version)[source]

The rewrite landing version in a JSON version field.

Shared by the plugin manifest and the marketplace entry that resolves to it, which carry the same field and must never disagree: a catalog advertising a version the manifest does not declare is what leaves an installed copy reporting an update that changes nothing.

Parameters:

version (str) – The release version to write.

Return type:

tuple[Pattern[str], str]

Returns:

A (pattern, replacement) rule for PrepareRelease._rewrite_file.

class repomatic.release.prepare_release.PrepareRelease(changelog_path=None, citation_path=None, workflow_dir=None, install_path=None, marketplace_path=None, plugin_manifest_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 ProjectMetadata.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 ProjectMetadata.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_pin(version)[source]

Pin the plugin marketplace’s git-subdir source to this release.

This is part of the freeze step. The entry in .claude-plugin/marketplace.json publishes PLUGIN_ROOT straight from this repository, 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 an unpinned entry would hand over whatever the default branch holds at install time.

Two values move together, and both are rewritten in place:

  • ref, the git tag the plugin directory is read from, in the vX.Y.Z tag namespace.

  • version, the bare X.Y.Z the app compares against an installed copy to decide whether an update is due. Bumping ref alone leaves the Update button greyed out, because detection reads the catalog entry rather than the plugin manifest it points at.

Note

An entry carrying neither key is left alone, which is the never-released state: it tracks the default branch until a first release gives the freeze a tag to write.

Note

No unfreeze method, for the same reason download URLs have none: the pin ratchets forward. The post-release .devN bump leaves it alone, so the default branch keeps naming the newest published release rather than 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.

unfreeze_marketplace_ref()[source]

Point the plugin marketplace’s source back at the default branch.

This is part of the unfreeze step, and it is what separates this pin from the ratcheting ones. A tag is immutable, so an entry left pinned to one can never pick up a change: the app’s Sync automatically toggle, which exists to “keep plugins up to date when the repository changes on GitHub”, would have nothing to observe. Tracking the default branch is what makes that toggle mean anything between releases.

The entry’s version stays on the last release, since it names the release the plugin most closely corresponds to and is the string update detection compares. A released tree keeps the tag its freeze wrote, so adding the catalog at @vX.Y.Z still installs that release exactly.

Return type:

bool

Returns:

True if the file was modified.

freeze_plugin_manifest_version(version)[source]

Stamp the release version into the Claude Code plugin manifest.

This is part of the freeze step. A git-subdir marketplace source publishes PLUGIN_ROOT verbatim, so the manifest a consumer installs is the checked-in one rather than the copy pack_plugin() stamps at pack time. Left version-free it fails claude plugin validate --strict on No version specified.

Written here rather than by hand or by bump-my-version, for the reason freeze_marketplace_pin() gives: it ratchets forward with the tag it belongs to, and the post-release .devN bump leaves it alone.

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.