# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""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 {data}`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.
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timezone
from functools import cached_property
from pathlib import Path
from .binary import binary_filename_re
from .changelog import Changelog
from .config import load_repomatic_config
from .metadata import Metadata
from .plugin import ARCHIVE_NAME, MARKETPLACE_PATH
from .registry import INSTALL_GUIDE_PATH, UPSTREAM_PACKAGE, WORKFLOW_TARGET_ROOT
from .version_sync import frozen_cli_invocation
SELF_PIN_COOLDOWN_EXEMPTION = f"--exclude-newer-package {UPSTREAM_PACKAGE}=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, and
[uv#20995](https://github.com/astral-sh/uv/issues/20995) for the upstream
request that would let a workflow declare this once.
```
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.
```
"""
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.
"""
[docs]
class PrepareRelease:
"""Prepare files for a release by updating dates, URLs, and removing warnings."""
def __init__(
self,
changelog_path: Path | None = None,
citation_path: Path | None = None,
workflow_dir: Path | None = None,
install_path: Path | None = None,
marketplace_path: Path | None = None,
default_branch: str = "main",
) -> None:
self.changelog_path = (
changelog_path or Path(load_repomatic_config().changelog_location).resolve()
)
self.citation_path = citation_path or Path("./citation.cff").resolve()
self.workflow_dir = workflow_dir or Path(WORKFLOW_TARGET_ROOT).resolve()
self.install_path = install_path or Path(INSTALL_GUIDE_PATH).resolve()
self.marketplace_path = marketplace_path or Path(MARKETPLACE_PATH).resolve()
self.default_branch = default_branch
self.modified_files: list[Path] = []
[docs]
@cached_property
def current_version(self) -> str:
"""Extract current version from the bump-my-version config.
Delegates discovery to {meth}`.Metadata.get_current_version`, which
searches `.bumpversion.toml` then `pyproject.toml`.
"""
version = Metadata.get_current_version()
if version is None:
raise RuntimeError(
"No bump-my-version config found "
"(searched .bumpversion.toml and pyproject.toml).",
)
logging.info(f"Current version: {version}")
return version
[docs]
@cached_property
def package_name(self) -> str | None:
"""Canonical PyPI package name, used to spot pinned install examples.
Delegates discovery to {attr}`.Metadata.package_name`, which reads
`pyproject.toml`.
"""
return Metadata().package_name
[docs]
@cached_property
def release_date(self) -> str:
"""Return today's date in UTC as YYYY-MM-DD."""
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
def _update_file(self, path: Path, content: str, original: str) -> bool:
"""Write content to file if it changed. Return True if modified."""
if content != original:
path.write_text(content, encoding="UTF-8")
self.modified_files.append(path)
logging.info(f"Updated {path}")
return True
logging.debug(f"No changes to {path}")
return False
[docs]
def set_citation_release_date(self) -> bool:
"""Update the `date-released` field in citation.cff.
:return: True if the file was modified.
"""
if not self.citation_path.exists():
logging.debug(f"Citation file not found: {self.citation_path}")
return False
original = self.citation_path.read_text(encoding="UTF-8")
content = re.sub(
r"date-released: \d{4}-\d{2}-\d{2}",
f"date-released: {self.release_date}",
original,
count=1,
)
return self._update_file(self.citation_path, content, original)
def _workflow_files(self) -> list[Path]:
"""Enumerate the workflow files under {attr}`workflow_dir`.
Covers both YAML extensions: `repomatic init` writes `.yaml`, but
downstream-authored workflows may use `.yml`, and a file skipped here
would ship with unfrozen (mutable) refs in the release.
"""
return sorted(
path
for pattern in ("*.yaml", "*.yml")
for path in self.workflow_dir.glob(pattern)
)
[docs]
@cached_property
def composite_action_names(self) -> list[str]:
"""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.
:return: Sorted list of composite action directory names.
"""
actions_dir = self.workflow_dir.parent / "actions"
if not actions_dir.exists():
return []
names = {
path.parent.name
for pattern in ("*/action.yaml", "*/action.yml")
for path in actions_dir.glob(pattern)
}
return sorted(names)
def _retarget_workflow_refs(self, src_ref: str, dst_ref: str) -> int:
"""Rewrite every upstream workflow reference from *src_ref* to *dst_ref*.
The single engine behind {meth}`freeze_workflow_urls` and
{meth}`unfreeze_workflow_urls`, which only differ in direction. Covers
the raw-content URLs (`/{package}/{ref}/`) and the composite-action
refs (`/{package}/.github/actions/{name}@{ref}`) across every workflow
file under {attr}`workflow_dir`. Composite action names are discovered
from {attr}`composite_action_names`.
:param src_ref: The git ref currently referenced.
:param dst_ref: The git ref to point at instead.
:return: Number of files modified.
"""
if not self.workflow_dir.exists():
logging.debug(f"Workflow directory not found: {self.workflow_dir}")
return 0
pairs = [
(f"/{UPSTREAM_PACKAGE}/{src_ref}/", f"/{UPSTREAM_PACKAGE}/{dst_ref}/"),
]
pairs.extend(
(
f"/{UPSTREAM_PACKAGE}/.github/actions/{name}@{src_ref}",
f"/{UPSTREAM_PACKAGE}/.github/actions/{name}@{dst_ref}",
)
for name in self.composite_action_names
)
count = 0
for yaml_file in self._workflow_files():
original = yaml_file.read_text(encoding="UTF-8")
content = original
for search, replace in pairs:
content = content.replace(search, replace)
if self._update_file(yaml_file, content, original):
count += 1
return count
[docs]
def freeze_workflow_urls(self) -> int:
"""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: Number of files modified.
"""
return self._retarget_workflow_refs(
self.default_branch, f"v{self.current_version}"
)
@staticmethod
def _replace_skip_comments(
content: str,
search: str,
replacement: str,
comment_prefix: str = "#",
) -> str:
"""Replace a string only in non-comment lines.
Comment lines (where the first non-whitespace character is
`comment_prefix`) are preserved unchanged. This prevents
freeze/unfreeze operations from corrupting explanatory comments that
mention the search string.
:param content: The file content to process.
:param search: The literal string to find and replace.
:param replacement: The string to substitute.
:param comment_prefix: The character that marks a comment line.
:return: The content with replacements applied to non-comment lines.
"""
lines = content.splitlines(keepends=True)
return "".join(
line
if line.lstrip().startswith(comment_prefix)
else line.replace(search, replacement)
for line in lines
)
@staticmethod
def _sub_skip_comments(
content: str,
pattern: re.Pattern[str],
replacement: str,
comment_prefix: str = "#",
) -> str:
"""Regex-substitute only in non-comment lines.
:param content: The file content to process.
:param pattern: The compiled regex pattern to match.
:param replacement: The string to substitute.
:param comment_prefix: The character that marks a comment line.
:return: The content with substitutions applied to non-comment lines.
"""
lines = content.splitlines(keepends=True)
return "".join(
line
if line.lstrip().startswith(comment_prefix)
else pattern.sub(replacement, line)
for line in lines
)
[docs]
def freeze_install_download_urls(self, version: str) -> bool:
"""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
{func}`~repomatic.binary.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.
```
:param version: The release version to freeze to.
:return: True if the file was modified.
"""
if not self.install_path.exists():
logging.debug(f"Install guide not found: {self.install_path}")
return False
original = self.install_path.read_text(encoding="UTF-8")
# Pass 1: Rewrite URL paths from /releases/latest/download/ or
# /releases/download/vX.Y.Z/ to /releases/download/v{version}/.
content = re.sub(
r"/releases/(?:latest/download|download/v[\d.]+)/",
f"/releases/download/v{version}/",
original,
)
# Pass 2: Rewrite binary filenames (in both URL and display text)
# from repomatic-target.ext or repomatic-X.Y.Z-target.ext to
# repomatic-{version}-target.ext, through the shared naming pattern.
content = binary_filename_re(UPSTREAM_PACKAGE).sub(
rf"{UPSTREAM_PACKAGE}-{version}-\g<target>.\g<ext>",
content,
)
return self._update_file(self.install_path, content, original)
[docs]
def freeze_marketplace_archive_url(self, version: str) -> bool:
"""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
{data}`~repomatic.plugin.ARCHIVE_NAME`, and pinning the tag is what makes
a marketplace ref meaningful: adding the catalog at
`kdeldycke/[email protected]` 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
{meth}`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
{data}`~repomatic.plugin.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.
```
:param version: The release version to freeze to.
:return: True if the file was modified.
"""
if not self.marketplace_path.exists():
logging.debug(f"Plugin marketplace not found: {self.marketplace_path}")
return False
original = self.marketplace_path.read_text(encoding="UTF-8")
content = re.sub(
r"/releases/(?:latest/download|download/v[\d.]+)/[\w.-]+\.zip",
f"/releases/download/v{version}/{ARCHIVE_NAME}",
original,
)
return self._update_file(self.marketplace_path, content, original)
[docs]
def freeze_install_cli_version(self, version: str) -> bool:
"""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 {meth}`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.
```
:param version: The release version to pin the examples to.
:return: True if the file was modified.
"""
if not self.install_path.exists():
logging.debug(f"Install guide not found: {self.install_path}")
return False
if not self.package_name:
logging.warning(
"No package name found in pyproject.toml: "
"skipping install guide CLI version pinning.",
)
return False
original = self.install_path.read_text(encoding="UTF-8")
# Match `{package}@X.Y.Z` (uvx pin) and `{package}==X.Y.Z` (PEP 508
# pin), leaving `@main`, git refs, and other packages' pins untouched.
# The leading boundary keeps distro-prefixed names (python-{package})
# out of scope; the optional `.devN` tail absorbs development pins.
pattern = re.compile(
rf"(?<![\w-])({re.escape(self.package_name)}(?:@|==))"
rf"\d+(?:\.\d+)*(?:\.dev\d+)?",
)
content = pattern.sub(rf"\g<1>{version}", original)
return self._update_file(self.install_path, content, original)
[docs]
def freeze_cli_version(self, version: str) -> int:
"""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 {data}`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.
:param version: The PyPI version to freeze to.
:return: Number of files modified.
"""
if not self.workflow_dir.exists():
logging.debug(f"Workflow directory not found: {self.workflow_dir}")
return 0
count = 0
search = LOCAL_CLI_INVOCATION
yaml_replace = frozen_cli_invocation(
UPSTREAM_PACKAGE, version, SELF_PIN_COOLDOWN_EXEMPTION
)
for workflow_file in self._workflow_files():
original = workflow_file.read_text(encoding="UTF-8")
content = self._replace_skip_comments(original, search, yaml_replace)
if self._update_file(workflow_file, content, original):
count += 1
return count
[docs]
def unfreeze_cli_version(self) -> int:
"""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
{data}`LOCAL_CLI_INVOCATION`, taking {data}`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
{meth}`freeze_cli_version`).
:return: Number of files modified.
"""
if not self.workflow_dir.exists():
logging.debug(f"Workflow directory not found: {self.workflow_dir}")
return 0
count = 0
yaml_pattern = re.compile(
r"uvx --no-progress "
rf"(?:{re.escape(SELF_PIN_COOLDOWN_EXEMPTION)} )?"
rf"'{re.escape(UPSTREAM_PACKAGE)}==[\d.]+'"
)
replace = LOCAL_CLI_INVOCATION
for workflow_file in self._workflow_files():
original = workflow_file.read_text(encoding="UTF-8")
content = self._sub_skip_comments(original, yaml_pattern, replace)
if self._update_file(workflow_file, content, original):
count += 1
return count
[docs]
def unfreeze_workflow_urls(self) -> int:
"""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 {meth}`freeze_workflow_urls`.
:return: Number of files modified.
"""
return self._retarget_workflow_refs(
f"v{self.current_version}", self.default_branch
)
[docs]
def prepare_release(self, update_workflows: bool = False) -> list[Path]:
"""Run all freeze steps to prepare the release commit.
:param update_workflows: If True, also freeze workflow URLs to versioned tag
and freeze CLI invocations to the current version.
:return: List of modified files.
"""
self.modified_files = []
if Changelog.freeze_file(
self.changelog_path,
version=self.current_version,
release_date=self.release_date,
default_branch=self.default_branch,
):
self.modified_files.append(self.changelog_path)
self.set_citation_release_date()
# Unconditional: every downstream repo following the install-page
# recipe carries a pinned CLI example, not just repos that dogfood
# repomatic's own workflows.
self.freeze_install_cli_version(self.current_version)
if update_workflows:
self.freeze_workflow_urls()
self.freeze_cli_version(self.current_version)
self.freeze_install_download_urls(self.current_version)
self.freeze_marketplace_archive_url(self.current_version)
return self.modified_files
[docs]
def post_release(self, update_workflows: bool = False) -> list[Path]:
"""Run all unfreeze steps to prepare the post-release commit.
:param update_workflows: If True, unfreeze workflow URLs back to default
branch and unfreeze CLI invocations back to local source.
:return: List of modified files.
"""
self.modified_files = []
if update_workflows:
self.unfreeze_workflow_urls()
self.unfreeze_cli_version()
return self.modified_files