# 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.
"""Pick the upstream pin `repomatic init` writes, and move it with `--upgrade`.
A downstream repository runs the reusable workflows of the release its thin
callers pin. `init` decides that pin: the running release, held back by the
`[tool.repomatic] minimum-release-age` cooldown when the release is too fresh,
and never moved below the pin the repository already carries. `init --upgrade`
picks the newest release past the cooldown instead, runs `init` as that
release, and describes the move for the pull request that carries it.
This module is the only reader of the running version and of the commit SHA of
its tag, so a test stages a release in one place.
"""
from __future__ import annotations
import logging
import re
import subprocess
from dataclasses import dataclass
from datetime import date, timedelta
from .. import __git_tag_sha__, __version__
from ..deps.pins import (
MIN_AGE_HELD_BACK_NOTE,
Candidate,
UpstreamRefPin,
exclude_newer_cutoff,
find_upstream_ref_pins,
format_cooldown_note,
github_candidates,
parse_min_age,
pypi_candidates,
select_held_back,
select_latest,
)
from ..deps.report import (
build_held_back,
format_diff_table,
format_held_back_table,
format_release_notes,
)
from ..deps.uv import uvx_cmd
from ..github.releases import (
ReleaseNotesRequest,
fetch_github_release_notes,
resolve_tag_to_sha,
)
from ..github.urls import repo_name
from ..github.workflow_files import github_yaml_paths
from ..humanize import today_utc
from ..markdown import sanitize_markdown_mentions
from ..pypi import PYPI_PACKAGE_URL
from ..release.self_pin import cooldown_exemption
from ..skills import UPGRADE_SKILL, skill_launcher
from ..templating import render_template
from ..upstream import DEFAULT_REPO, is_source_repo
from ..versions import is_newer, parse_tag_version, safe_version, strip_dev_suffix
TYPE_CHECKING = False
if TYPE_CHECKING:
from collections.abc import Iterable, Mapping, Sequence
from pathlib import Path
from ..config import Config
BREAKING_BULLET_RE = re.compile(
r"^[ \t]*[-*][ \t]+(?P<bullet>\*\*(?:Breaking|Deprecated):\*\*.*)$",
re.MULTILINE,
)
"""A release-notes bullet announcing a breaking change or a deprecation.
The changelog convention opens these bullets with a bold `**Breaking:**` or
`**Deprecated:**` label, and each GitHub release body repeats its changelog
section verbatim. The upgrade report lifts them above the full notes, because
they are the entries a repository may have to act on.
"""
INIT_WARNING_RE = re.compile(r"^[ \t]*warning:.*$", re.IGNORECASE | re.MULTILINE)
"""A warning line in the output of `repomatic init`.
Matches the `warning:` lines the configuration loader logs, like an unknown
`[tool.repomatic]` key, and the `Warning:` lines of the closing summary.
"""
def _base_version() -> str:
"""The running repomatic version with any PEP 440 `.devN` suffix stripped.
`"5.10.0.dev0"` becomes `"5.10.0"`. The bare (no `v`) form, used both for the
default pin and to age-check the running version against a release
datasource.
"""
return strip_dev_suffix(__version__)
[docs]
def default_version_pin() -> str:
"""Derive the default version pin from `__version__`.
Strips any `.dev0` suffix and prefixes with `v`. For example,
`"5.10.0.dev0"` becomes `"v5.10.0"`.
"""
return f"v{_base_version()}"
[docs]
def pins_running_release(version: str) -> bool:
"""Whether the pin *version* names the release the running build heads for.
Compared on the release tuple, so a development build matches the release
it is heading for: the pin drops the `.devN` suffix the running version
carries, and string equality would never match on a dev checkout.
:param version: A pin, in its `vX.Y.Z` tag spelling.
"""
pinned = parse_tag_version(version)
running = safe_version(__version__)
return (
pinned is not None and running is not None and pinned.release == running.release
)
[docs]
def github_yaml_texts(root: Path) -> dict[Path, str]:
"""Read every workflow and composite-action file under *root*, once.
:param root: Root of the repository to scan.
:return: The text of each file
{func}`~repomatic.github.workflow_files.github_yaml_paths` lists, keyed
by path. A file that cannot be read is skipped.
"""
texts: dict[Path, str] = {}
for path in github_yaml_paths(root):
try:
texts[path] = path.read_text(encoding="UTF-8")
except OSError as error:
logging.warning(f"Could not read {path}: {error}")
return texts
[docs]
def highest_upstream_pin(texts: Iterable[str], repo: str) -> UpstreamRefPin | None:
"""The highest upstream `uses:` pin the given workflow texts carry.
Read off the files {func}`github_yaml_texts` returns, the same files
`sync-workflow-pins` bumps. Stragglers left behind at an older pin
therefore converge upward onto the repository-wide maximum, matching how
`sync-action-pins` treats a slug pinned at more than one version.
Only *repo* is matched, not every slug in
{data}`~repomatic.upstream.UPSTREAM_REPO_SLUGS`: a caller still naming a
pre-rename slug is a tombstone `init` rewrites, not a pin to preserve.
:param texts: The contents of the workflow and composite-action files.
:param repo: Upstream `owner/repo` whose refs are read.
:return: The highest pin found, or `None` for a repository carrying none.
"""
best: UpstreamRefPin | None = None
for content in texts:
for pin in find_upstream_ref_pins(content, repo):
if best is None:
best = pin
continue
# At equal versions a SHA-pinned ref beats a bare tag one, so the
# floor never unpins a hardened ref.
hardens = pin.version == best.version and best.sha is None
if hardens or is_newer(pin.version, best.version):
best = pin
return best
def _select_cooldown_pin(
candidates: list[Candidate],
base: str,
min_age: timedelta,
today: date,
) -> Candidate | None:
"""Pick the release `init` should pin when the cooldown holds `base` back.
`init` normally pins the running repomatic version (`base`). When that
version is still inside the `minimum-release-age` window (a fresh release a
`--refresh` just pulled), pinning it would adopt an unproven cut, bypassing
the cooldown every other version adopter honors. This returns the newest
release *older* than `base` that has cleared the window, so the downstream
pin steps back to a proven release instead.
The step-back is skipped (returns `None`, meaning "keep pinning `base`")
whenever `base` is safe or nothing better exists:
- `base` is absent from *candidates* (an unreleased `.dev` cut, or the
datasource is unavailable): fail open to `base`.
- `base` has already cleared the cooldown: the common case, no change.
- No cooldown-cleared release is strictly older than `base`: nothing to step
back to.
:param candidates: Releases offered by the datasource.
:param base: The running repomatic version (bare, no `v` prefix).
:param min_age: The `minimum-release-age` stabilization window.
:param today: Reference date for the cooldown computation.
:return: The {class}`~repomatic.deps.pins.Candidate` to pin instead of
`base`, or `None` to keep pinning `base`.
"""
cutoff = today - min_age
base_date = next((c.date for c in candidates if c.version == base), None)
if base_date is None:
return None
try:
released = date.fromisoformat(base_date)
except ValueError:
return None
# `base` is old enough: the overwhelmingly common case, pin it unchanged.
if released <= cutoff:
return None
latest = select_latest(candidates, min_age, today)
if latest is None or not is_newer(base, latest.version):
return None
return latest
def _note_cooldown(warnings: list[str] | None, note: str) -> None:
"""Log a cooldown decision and surface it in the `init` summary."""
if warnings is not None:
warnings.append(note)
logging.warning(note)
[docs]
def resolve_default_pin(
config: Config,
*,
repo: str = DEFAULT_REPO,
today: date | None = None,
warnings: list[str] | None = None,
floor: UpstreamRefPin | None = None,
) -> tuple[str, str | None]:
"""Resolve the upstream pin, holding a fresh release back by cooldown.
Returns the `(version, commit_sha)` `init` stamps into thin-caller `uses:`
refs. In the common case, and on any datasource failure, this is the running
repomatic version paired with its build-time SHA. Only when adopting a
release still inside the `[tool.repomatic] minimum-release-age` window does
the pin step back to the newest cooldown-cleared release (see
`_select_cooldown_pin`), resolving that tag's SHA afresh.
```{important}
The cooldown may hold back an *adoption*. It may never rewrite a pin the
repository already carries, which is what *floor* records.
`init` is the only writer of these refs: `sync-action-pins` skips every slug
in {data}`~repomatic.upstream.UPSTREAM_REPO_SLUGS`, and
{data}`~repomatic.deps.pins.ACTION_PIN_RE` does not even match a
subpath-carrying reusable-workflow ref. So a downstream repository adopts a
new repomatic release one way: a newer `init` runs. A maintainer runs one by
hand, and the CI `sync-repomatic` job runs one through `init --upgrade`,
which picks the release itself (see {func}`resolve_upgrade_target`) and
bypasses this function. Two things follow.
A pin equal to the running version is not a decision to gate. Without a
newer release to adopt, the CI `sync-repomatic` job runs `init` at the
pinned version itself, so `base` equals *floor*; re-judging it there
downgrades the repository once a week after each hand-bump.
A pin below the running version is a skew. `init` renders caller *content*
from the running version, so a ref naming an older release ships that
content against an older reusable-workflow surface, which GitHub rejects as
soon as the two disagree (see
`test_thin_caller_workflow_call_inputs_stay_minimal`). Returning such a pin
is therefore only half a decision: {func}`resolve_workflow_pin` reads it
back and, when the repository already carries workflows, tells `init` to
skip regenerating them so the tree stays coherent at the pin it keeps. A
first-time adoption has no tree to keep, so there the skew stands as the
only alternative to writing no workflows at all.
```
:param config: Repomatic config supplying the `minimum-release-age` window.
:param repo: Upstream `owner/repo` whose releases gate the pin.
:param today: Reference date for the cooldown; defaults to the current UTC
date.
:param warnings: When provided, a cooldown note is appended here (in
addition to being logged), so `init` can surface it in its final
summary rather than only mid-run.
:param floor: The highest upstream pin already committed downstream, from
{func}`highest_upstream_pin`. `None` for a repository carrying none,
the one case the cooldown may step back freely.
:return: `(version_pin, commit_sha)`. `commit_sha` is `None` when no SHA can
be resolved, leaving a bare tag pin.
"""
base = _base_version()
build_sha = __git_tag_sha__ or None
# Unreleased dev cuts never appear in a release datasource: pin as-is,
# sparing the source repo's own development builds a pointless lookup.
if base != __version__:
return f"v{base}", build_sha
if floor is not None:
# Regeneration, not adoption: the repository already pins this exact
# version. Answered without a datasource round-trip, which is what
# every CI sync does. Falls back to the on-disk SHA so a build carrying
# no tag SHA of its own never unpins a hardened ref.
if base == floor.version:
return f"v{base}", build_sha or floor.sha
# A deliberate rollback (`uvx repomatic==<older> init`). Honor it: pin
# and content stay coherent, and holding the newer floor here would
# make the rollback silently fail.
if is_newer(floor.version, base):
return f"v{base}", build_sha
min_age = parse_min_age(config.minimum_release_age)
if not min_age:
return f"v{base}", build_sha
if today is None:
today = today_utc()
repo_url = f"https://github.com/{repo}"
stepped_back = _select_cooldown_pin(
github_candidates(repo_url), base, min_age, today
)
if stepped_back is None:
return f"v{base}", build_sha
# The cleared release is no better than what the repository already runs:
# decline the adoption instead of regressing to it.
if floor is not None and not is_newer(stepped_back.version, floor.version):
_note_cooldown(
warnings,
f"repomatic {base} is inside the {config.minimum_release_age} "
f"minimum-release-age window; keeping the release this repository "
f"already pins, v{floor.version}. Pass --no-cooldown to adopt "
f"{base} now.",
)
return f"v{floor.version}", floor.sha
_note_cooldown(
warnings,
f"repomatic {base} is inside the {config.minimum_release_age} "
f"minimum-release-age window; pinning the newest cleared release "
f"v{stepped_back.version} instead. Pass --no-cooldown to pin {base}.",
)
# Fall back to a bare tag pin when the SHA lookup fails.
return f"v{stepped_back.version}", resolve_tag_to_sha(repo_url, stepped_back.ref)
[docs]
@dataclass(frozen=True)
class WorkflowPin:
"""The upstream pin `init` writes into the thin callers it generates."""
version: str
"""The pin, in its `vX.Y.Z` tag spelling."""
commit_sha: str | None
"""The commit SHA paired with {attr}`version`, or `None` for a bare tag pin
the next `sync-action-pins` run re-hardens."""
adopts_content: bool = True
"""Whether the workflows take the content of the running release.
`False` when the cooldown holds the pin below the running release on a tree
that already carries workflows. `init` then leaves the workflows as they
are, at the pin they already have.
"""
[docs]
def resolve_workflow_pin(
config: Config,
*,
version: str | None,
cooldown: bool,
repo: str,
floor: UpstreamRefPin | None,
warnings: list[str],
) -> WorkflowPin:
"""Resolve the pin `init` writes, and whether the workflows move with it.
An explicit *version* keeps the build-time SHA and bypasses the cooldown.
Without one, the pin derives from the running release, held back by
{func}`resolve_default_pin` when *cooldown* is set.
A pin the cooldown held below the running version cannot carry this
version's caller content. `init` renders bodies from the running wheel and
substitutes only the ref, so writing them beside an older `uses:` pin ships
half an adoption: the trigger blocks, `concurrency` groups and `env:` of the
new release, against the reusable-workflow surface of the old one.
Declining the pin has to decline the content with it, or the cooldown buys
nothing while the caller half lands anyway, silently.
Only a repository that already carries workflows (a *floor*) has somewhere
to stand: leaving them untouched keeps a coherent tree at *floor*. A
first-time adoption has no such fallback, since skipping would write no
workflows at all, and is the one case {func}`resolve_default_pin` documents
the skew as unavoidable.
:param config: Repomatic config supplying the `minimum-release-age` window.
:param version: An explicit pin, in its `vX.Y.Z` tag spelling, or `None`.
:param cooldown: Hold a fresh running release back by the cooldown.
:param repo: Upstream `owner/repo` whose releases gate the pin.
:param floor: The highest upstream pin already committed downstream.
:param warnings: Cooldown notes are appended here, for the `init` summary.
"""
commit_sha = __git_tag_sha__ or None
if version is not None:
return WorkflowPin(version, commit_sha)
if not cooldown:
return WorkflowPin(default_version_pin(), commit_sha)
version, commit_sha = resolve_default_pin(
config, repo=repo, warnings=warnings, floor=floor
)
if floor is None or not is_newer(_base_version(), version.removeprefix("v")):
return WorkflowPin(version, commit_sha)
_note_cooldown(
warnings,
f"Leaving the workflows at {version}: their content belongs to repomatic "
f"{_base_version()}, which the pin above declines. Pass --no-cooldown to "
"adopt both together.",
)
return WorkflowPin(version, commit_sha, adopts_content=False)
[docs]
@dataclass(frozen=True)
class UpgradeTarget:
"""The release `init --upgrade` runs as, chosen before any file is written."""
version: str
"""Bare version to run `init` as."""
released: str
"""PyPI upload date of {attr}`version`, as `YYYY-MM-DD`. Empty when the
datasource does not list the release pinned on disk."""
previous: str | None = None
"""Bare version of the highest upstream pin on disk before the run, or `None`
for a repository carrying none."""
held_back: Candidate | None = None
"""The newest release above {attr}`version` that the `minimum-release-age`
window still holds back: the one a later run adopts."""
@property
def in_process(self) -> bool:
"""Whether the running release is the target, so no hand-off is needed."""
return self.version == __version__
[docs]
def resolve_upgrade_target(
config: Config,
output_dir: Path,
*,
repo: str = DEFAULT_REPO,
today: date | None = None,
) -> UpgradeTarget | None:
"""Pick the release `init --upgrade` runs as.
That is the newest final, non-yanked release on PyPI older than the
`[tool.repomatic] minimum-release-age` window, and never one below the
upstream pin the repository already carries: a release pinned by hand inside
the window stays. PyPI is the datasource because `uvx` installs from it, so
a version whose publication failed, with a tag but no wheel, is never
picked.
:param config: Repomatic config supplying the window and both opt-outs.
:param output_dir: Root of the target repository, read for its current pin.
:param repo: Upstream `owner/repo`; its name is the PyPI package.
:param today: Reference date for the cooldown; defaults to the current UTC
date.
:return: The target, or `None` when the upgrade does not apply: turned off
by `upstream-pin.sync` or `workflow.sync`, run from a build that is not
a release, or no release has cleared the window.
"""
if not config.upstream_pin_sync:
logging.info(
"[tool.repomatic] upstream-pin.sync is disabled. Skipping the upgrade."
)
return None
if not config.workflow.sync:
logging.info(
"[tool.repomatic] workflow.sync is disabled, so no upstream pin can"
" move. Skipping the upgrade."
)
return None
if is_source_repo(output_dir):
logging.info(
"The source repository runs from its lockfile. Skipping the upgrade."
)
return None
if _base_version() != __version__:
logging.info(
f"repomatic {__version__} is a development build. Skipping the upgrade."
)
return None
if today is None:
today = today_utc()
package = repo_name(repo)
min_age = parse_min_age(config.minimum_release_age)
candidates = pypi_candidates(package)
chosen = select_latest(candidates, min_age, today)
if chosen is None:
logging.warning(
f"No {package} release is older than the {config.minimum_release_age}"
" minimum-release-age window. Skipping the upgrade."
)
return None
floor = highest_upstream_pin(github_yaml_texts(output_dir).values(), repo)
if floor is not None and is_newer(floor.version, chosen.version):
# A release pinned by hand inside the window. The upgrade never moves a
# pin backwards, so it regenerates the files at that release instead.
chosen = next(
(c for c in candidates if c.version == floor.version),
Candidate(floor.version, "", floor.version),
)
return UpgradeTarget(
version=chosen.version,
released=chosen.date,
previous=floor.version if floor is not None else None,
held_back=select_held_back(candidates, chosen.version, min_age, today),
)
[docs]
def upgrade_command(
version: str,
init_args: Sequence[str],
*,
config: Config,
repo: str = DEFAULT_REPO,
today: date | None = None,
) -> list[str]:
"""Build the command running `init` as another `repomatic` release.
`--no-cooldown` hands the child the decision {func}`resolve_upgrade_target`
already took. Left to judge the window again, the child reads GitHub release
dates where the parent read PyPI ones, and a split verdict near the limit
writes the new configs but leaves the callers at the old release.
The window reaches the resolution as `--exclude-newer`, which gates the
child's dependency tree like any other install. The package itself carries
the self-pin exemption, because it already cleared this repository's window:
with that window disabled, no cutoff is passed, and the workflow's own
`UV_EXCLUDE_NEWER` would refuse the release.
:param version: Bare version to run.
:param init_args: Arguments for the child `init`, after `--no-cooldown`.
:param config: Repomatic config supplying the `minimum-release-age` window.
:param repo: Upstream `owner/repo`; its name is the package and the command.
:param today: Reference date for the cutoff; defaults to the current UTC
date.
:return: The command line.
"""
if today is None:
today = today_utc()
package = repo_name(repo)
cutoff = exclude_newer_cutoff(config.minimum_release_age, today)
return [
*uvx_cmd(exclude_newer=cutoff),
*cooldown_exemption(package).split(),
"--from",
f"{package}=={version}",
package,
"init",
"--no-cooldown",
*init_args,
]
[docs]
def run_upgrade(
command: Sequence[str], output_dir: Path
) -> subprocess.CompletedProcess[str]:
"""Run the `init` hand-off built by {func}`upgrade_command`.
Runs from *output_dir*, where the child finds `[tool.repomatic]` as the
parent did. Both output streams are merged in the order they were written,
then captured, because {func}`upgrade_report` quotes the warnings the child
printed: a config key the new release no longer knows shows up nowhere else.
:param command: The command line from {func}`upgrade_command`.
:param output_dir: Root of the target repository.
:return: The finished process, its merged output in `stdout`.
"""
logging.info(f"Running: {' '.join(command)}")
return subprocess.run(
command,
cwd=output_dir,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="UTF-8",
check=False,
)
[docs]
def hand_off_upgrade(
target: UpgradeTarget,
config: Config,
*,
repo: str,
output_dir: Path,
flags: Sequence[str] = (),
) -> subprocess.CompletedProcess[str]:
"""Run `init` as the release *target* names, in a child process.
The child gets the upstream repository and the output directory of this
run, then *flags*: the deletion flags this run was given, so the child
prunes what this run would have.
:param target: The target {func}`resolve_upgrade_target` picked.
:param config: Repomatic config supplying the `minimum-release-age` window.
:param repo: Upstream `owner/repo`; its name is the package and the command.
:param output_dir: Root of the target repository.
:param flags: Extra `init` options for the child, like
{meth}`repomatic.init.run.DeletionPolicy.flags` returns.
:return: The finished process, its merged output in `stdout`.
"""
command = upgrade_command(
target.version,
["--upstream-repo", repo, "--output-dir", str(output_dir), *flags],
config=config,
repo=repo,
)
return run_upgrade(command, output_dir)
def _breaking_section(
notes: Mapping[str, tuple[str, list[tuple[str, str]]]],
previous: str,
adopted: str,
) -> str:
"""List the breaking and deprecation bullets of the fetched release notes.
Empty when no notes were fetched. Silence there means the notes could not
be read, which must not render as a claim that nothing breaks.
"""
if not notes:
return ""
heading = "## 💥 Breaking changes and deprecations"
bullets = [
f"- [`{tag}`]({repo_url}/releases/tag/{tag}): "
f"{sanitize_markdown_mentions(match['bullet'].rstrip())}"
for repo_url, versions in notes.values()
for tag, body in versions
for match in BREAKING_BULLET_RE.finditer(body)
]
if not bullets:
return (
f"{heading}\n\nNo release between `v{previous}` and `v{adopted}` has"
" a `**Breaking:**` or `**Deprecated:**` entry."
)
return "\n".join((heading, "", *bullets))
def _first_sentence(line: str) -> str:
"""Cut a log line after its first sentence, like a list of valid options."""
head, separator, _rest = line.partition(". ")
return f"{head}." if separator else line
def _init_output_section(output: str, adopted: str) -> str:
"""Show the warnings of a hand-off run, then its whole output, collapsed."""
if not output.strip():
return ""
warnings = list(
dict.fromkeys(
_first_sentence(match.group(0).strip())
for match in INIT_WARNING_RE.finditer(output)
)
)
lines = ["## 📋 `repomatic init` output", ""]
if warnings:
lines += ["The run printed these warnings:", "", "```text", *warnings, "```"]
lines.append("")
lines += [
"<details>",
(
"<summary>Full output of <code>repomatic init</code> at"
f" <code>v{adopted}</code></summary>"
),
"",
"```text",
output.strip(),
"```",
"",
"</details>",
]
return "\n".join(lines)
[docs]
def upgrade_invite(previous: str, adopted: str) -> str:
"""Render the invitation to review a move of the upstream pin.
Shared by every pull request that moves the pin, the `init --upgrade`
report and a `sync-workflow-pins` lockstep bump, so both hand over the same
launcher.
:param previous: Bare version the pin moved from.
:param adopted: Bare version the pin moved to.
:return: The `upgrade-invite` section, ending on the skill launcher.
"""
return render_template(
"upgrade-invite",
previous=f"v{previous}",
adopted=f"v{adopted}",
command=skill_launcher(UPGRADE_SKILL, f"v{previous}", f"v{adopted}"),
)
[docs]
def upgrade_report(
target: UpgradeTarget,
output_dir: Path,
config: Config,
*,
init_output: str = "",
repo: str = DEFAULT_REPO,
today: date | None = None,
) -> tuple[str, str] | None:
"""Describe the upgrade a run just applied, for its pull request body.
Reads the pin back from disk rather than trusting *target*, since what the
run wrote is what the pull request carries. The sections, in reading order:
- The version move, with its compare link and release date.
- Every `**Breaking:**` and `**Deprecated:**` bullet of the releases in
between.
- The warnings the run printed, then its whole output, collapsed.
- The release notes, one collapsed block per release.
- The invitation to run the `repomatic-upgrade` review.
- The newer release the window still holds back, if any.
:param target: The target {func}`resolve_upgrade_target` picked.
:param output_dir: Root of the target repository, read for the new pin.
:param config: Repomatic config supplying the `minimum-release-age` window.
:param init_output: The output of a hand-off run, empty for an in-process one.
:param repo: Upstream `owner/repo` whose releases are described.
:param today: Reference date for relative dates; defaults to the current
UTC date.
:return: `(adopted, markdown)`, or `None` when the run moved no pin forward.
"""
pin = highest_upstream_pin(github_yaml_texts(output_dir).values(), repo)
previous = target.previous
if previous is None or pin is None or not is_newer(pin.version, previous):
return None
adopted = pin.version
if today is None:
today = today_utc()
package = repo_name(repo)
repo_url = f"https://github.com/{repo}"
package_url = PYPI_PACKAGE_URL.format(package=package)
min_age = parse_min_age(config.minimum_release_age)
released = target.released if adopted == target.version else ""
diff_table = format_diff_table(
[(package, previous, adopted)],
upload_times={package: released} if released else None,
cooldown_note=(
format_cooldown_note(config.minimum_release_age, today - min_age)
if min_age
else ""
),
comparison_urls={package: f"{repo_url}/compare/v{previous}...v{adopted}"},
reference_date=today,
name_urls={package: package_url},
heading="Upgraded release",
)
notes = fetch_github_release_notes([
ReleaseNotesRequest(package, repo_url, previous, adopted)
])
held_back = ""
if target.held_back is not None:
held_back = format_held_back_table(
[
build_held_back(
package,
adopted,
target.held_back.version,
target.held_back.date,
min_age,
today,
)
],
MIN_AGE_HELD_BACK_NOTE,
name_urls={package: package_url},
)
sections = (
diff_table,
_breaking_section(notes, previous, adopted),
_init_output_section(init_output, adopted),
format_release_notes(notes),
upgrade_invite(previous, adopted),
held_back,
)
return adopted, "\n\n".join(section for section in sections if section)