Source code for repomatic.release.prepare_release

# 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 ..changelog import Changelog, resolved_changelog_path
from ..config import load_repomatic_config
from ..file_ops import write_if_changed
from ..metadata.core import Metadata
from ..registry import INSTALL_GUIDE_PATH, UPSTREAM_PACKAGE, WORKFLOW_TARGET_ROOT
from ..tooling.plugin import MARKETPLACE_PATH, REPO_MANIFEST_PATH
from .binary import binary_filename_re
from .version_sync import frozen_cli_invocation

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Callable

MARKETPLACE_REF_RE = re.compile(r'("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.
"""

JSON_VERSION_RE = re.compile(r'("version":\s*")[\d.]+(")')
"""A JSON `version` field holding a release version.

Ratchets forward on release, unlike {data}`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.
"""

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
`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](https://github.com/astral-sh/uv/issues/20995). Glob exemptions
([astral-sh/uv#20788](https://github.com/astral-sh/uv/issues/20788)) and
pin-based bypasses
([astral-sh/uv#19864](https://github.com/astral-sh/uv/issues/19864),
[astral-sh/uv#18921](https://github.com/astral-sh/uv/pull/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.
```
"""

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] def json_version_rule(version: str) -> tuple[re.Pattern[str], str]: """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. :param version: The release version to write. :return: A `(pattern, replacement)` rule for `PrepareRelease._rewrite_file`. """ return (JSON_VERSION_RE, rf"\g<1>{version}\g<2>")
[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, plugin_manifest_path: Path | None = None, default_branch: str = "main", ) -> None: self.changelog_path = changelog_path or resolved_changelog_path( load_repomatic_config() ) 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.plugin_manifest_path = ( plugin_manifest_path or Path(REPO_MANIFEST_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}`.ProjectMetadata.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}`.ProjectMetadata.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 *path* when it differs, and record the change. The bookkeeping half of a freeze pass: {attr}`modified_files` is what the caller commits, so every write has to pass through here. The write itself is {func}`~repomatic.file_ops.write_if_changed`, handed the text already in hand so it compares in memory rather than reading the file back. :param path: File to write. :param content: The transformed text. :param original: What the pass read before transforming it. :return: True if the file was modified. """ if not write_if_changed(path, content, previous=original): return False self.modified_files.append(path) logging.info(f"Updated {path}") return True def _rewrite_file( self, path: Path, label: str, *rules: tuple[str | re.Pattern[str], str], ) -> bool: """Apply every substitution in *rules* to *path*, writing back on a change. The shared body of every single-file freeze below, which were near-identical copies of an absent-file guard, a read, a chain of {func}`re.sub` calls and a compare-then-write. Each caller keeps its docstring and its rules, which is the part that carries meaning. :param path: File to rewrite. A missing one is not an error: a downstream repository need not carry every artifact the freeze knows how to pin. :param label: What the file is, for the skipped-file log line. :param rules: `(pattern, replacement)` pairs applied in order. A pattern may be a string or a compiled {class}`re.Pattern`. :return: True if the file was modified. """ if not path.exists(): logging.debug(f"{label} not found: {path}") return False original = path.read_text(encoding="UTF-8") content = original for pattern, replacement in rules: content = re.sub(pattern, replacement, content) return self._update_file(path, content, original) def _rewrite_workflows(self, transform: Callable[[str], str]) -> int: """Apply *transform* to every workflow file, writing back on a change. The shared body of the three multi-file rewriters below. Counting modified files rather than returning a boolean is what separates these from {meth}`_rewrite_file`: a freeze touching four of nine workflows is worth reporting as such. :param transform: Maps a workflow's current content to its new content. :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 for workflow_file in self._workflow_files(): original = workflow_file.read_text(encoding="UTF-8") if self._update_file(workflow_file, transform(original), original): count += 1 return count
[docs] def set_citation_release_date(self) -> bool: """Update the `date-released` field in citation.cff. :return: True if the file was modified. """ return self._rewrite_file( self.citation_path, "Citation file", # Anchored to a line start, which is what keeps a nested # `preferred-citation` block's own `date-released` out of scope. It # replaces the `count=1` this pass used before being shared, and is # the stricter of the two: position rather than ordinal. ( r"(?m)^date-released: \d{4}-\d{2}-\d{2}", f"date-released: {self.release_date}", ), )
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. """ 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 ) def retarget(content: str) -> str: for search, replace in pairs: content = content.replace(search, replace) return content return self._rewrite_workflows(retarget)
[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 _map_noncomment_lines( content: str, transform: Callable[[str], str], comment_prefix: str = "#", ) -> str: """Apply *transform* to every line that is not a comment. 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 string being rewritten. The shared body of the two rewriters below, which used to be near-identical copies. """ return "".join( line if line.lstrip().startswith(comment_prefix) else transform(line) for line in content.splitlines(keepends=True) ) @classmethod def _replace_skip_comments( cls, content: str, search: str, replacement: str, comment_prefix: str = "#", ) -> str: """Replace a literal string only in non-comment lines.""" return cls._map_noncomment_lines( content, lambda line: line.replace(search, replacement), comment_prefix ) @classmethod def _sub_skip_comments( cls, content: str, pattern: re.Pattern[str], replacement: str, comment_prefix: str = "#", ) -> str: """Regex-substitute only in non-comment lines.""" return cls._map_noncomment_lines( content, lambda line: pattern.sub(replacement, line), comment_prefix )
[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.release.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. """ return self._rewrite_file( self.install_path, "Install guide", # Pass 1: Rewrite URL paths from /releases/latest/download/ or # /releases/download/vX.Y.Z/ to /releases/download/v{version}/. ( r"/releases/(?:latest/download|download/v[\d.]+)/", f"/releases/download/v{version}/", ), # 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. ( binary_filename_re(UPSTREAM_PACKAGE), rf"{UPSTREAM_PACKAGE}-{version}-\g<target>.\g<ext>", ), )
[docs] def freeze_marketplace_pin(self, version: str) -> bool: """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 {data}`~repomatic.tooling.plugin.PLUGIN_ROOT` straight from this repository, 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 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. ``` :param version: The release version to freeze to. :return: True if the file was modified. """ return self._rewrite_file( self.marketplace_path, "Plugin marketplace", (MARKETPLACE_REF_RE, rf"\g<1>v{version}\g<2>"), json_version_rule(version), )
[docs] def unfreeze_marketplace_ref(self) -> bool: """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: True if the file was modified. """ return self._rewrite_file( self.marketplace_path, "Plugin marketplace", (MARKETPLACE_REF_RE, rf"\g<1>{self.default_branch}\g<2>"), )
[docs] def freeze_plugin_manifest_version(self, version: str) -> bool: """Stamp the release version into the Claude Code plugin manifest. This is part of the **freeze** step. A `git-subdir` marketplace source publishes {data}`~repomatic.tooling.plugin.PLUGIN_ROOT` verbatim, so the manifest a consumer installs is the checked-in one rather than the copy {func}`~repomatic.tooling.plugin.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 {meth}`freeze_marketplace_pin` gives: it ratchets forward with the tag it belongs to, and the post-release `.devN` bump leaves it alone. :param version: The release version to freeze to. :return: True if the file was modified. """ return self._rewrite_file( self.plugin_manifest_path, "Plugin manifest", json_version_rule(version), )
[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.package_name: logging.warning( "No package name found in pyproject.toml: " "skipping install guide CLI version pinning.", ) return False # 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+)?", ) return self._rewrite_file( self.install_path, "Install guide", (pattern, rf"\g<1>{version}"), )
[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. """ yaml_replace = frozen_cli_invocation( UPSTREAM_PACKAGE, version, SELF_PIN_COOLDOWN_EXEMPTION ) return self._rewrite_workflows( lambda content: self._replace_skip_comments( content, LOCAL_CLI_INVOCATION, yaml_replace ) )
[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. """ yaml_pattern = re.compile( r"uvx --no-progress " rf"(?:{re.escape(SELF_PIN_COOLDOWN_EXEMPTION)} )?" rf"'{re.escape(UPSTREAM_PACKAGE)}==[\d.]+'" ) return self._rewrite_workflows( lambda content: self._sub_skip_comments( content, yaml_pattern, LOCAL_CLI_INVOCATION ) )
[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_pin(self.current_version) self.freeze_plugin_manifest_version(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() self.unfreeze_marketplace_ref() return self.modified_files