Compare commits

..
8 Commits
Author SHA1 Message Date
Kevin Veen-Birkenbach f6228988e1 Release version 1.15.2
CI / security-codeql (push) Has been cancelled
CI / test-unit (push) Has been cancelled
CI / test-integration (push) Has been cancelled
CI / test-env-virtual (push) Has been cancelled
CI / test-env-nix (push) Has been cancelled
CI / test-e2e (push) Has been cancelled
CI / test-virgin-user (push) Has been cancelled
CI / test-virgin-root (push) Has been cancelled
CI / lint-shell (push) Has been cancelled
CI / lint-python (push) Has been cancelled
CI / lint-docker (push) Has been cancelled
Mark stable commit / mark-stable (push) Canceled after 0s
2026-05-28 11:06:43 +02:00
Kevin Veen-Birkenbach 5c7171acd9 fix(config): add alias: infinito for infinito-nexus/core
`pkgmgr install infinito` (and `pkgmgr path infinito`, `pkgmgr version
infinito`, etc.) failed with:

    Identifier 'infinito' did not match any repository in config.

The infinito-nexus/core entry in defaults.yaml had no alias, so the
resolver could only match it via the full id `github.com/infinito-nexus/core`
or the bare repository name `core`. Downstream consumers (the
infinito-nexus-core Dockerfile, roles/sys-cli, roles/web-app-navigator,
and the test-install-pkgmgr CI job) all invoke the short identifier
`infinito` and broke once kpmx 1.15.x was rolled out via the floating
`pkgmgr-<distro>:stable` images.

Registering `alias: infinito` on the entry restores the short identifier
without renaming the repository or touching the consumer side.
2026-05-28 11:05:47 +02:00
Kevin Veen-Birkenbach 06cc5b6725 Release version 1.15.1
Mark stable commit / mark-stable (push) Canceled after 0s
2026-05-28 08:18:23 +02:00
Kevin Veen-BirkenbachandClaude Opus 4.7 ece575cc73 fix(release/changelog): insert new entry under H1 instead of above it
update_changelog used to blindly prepend the new ## [version] entry to
the entire file body. When CHANGELOG.md leads with a # Changelog H1
the result was:

  ## [new] - YYYY-MM-DD
  ...
  # Changelog
  ## [old] - ...

which trips markdownlint MD041 (first-line-h1) and MD012
(no-multiple-blanks), and reads as if the document were two stacked
changelogs.

The new _insert_after_h1 helper:

* Synthesises # Changelog when the file is empty.
* Inserts the entry between the existing H1 (plus any intro prose)
  and the first existing ## release entry.
* For legacy headerless files (file starts with ##) prepends the
  synthesised H1 + entry, so the resulting document is also
  MD041-clean.

Tests cover the three layouts (empty, H1+existing-entries,
legacy-headerless). All 61 release-unit tests stay green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 08:17:35 +02:00
Kevin Veen-Birkenbach a4099717be Release version 1.15.0
Mark stable commit / mark-stable (push) Canceled after 0s
2026-05-28 07:56:07 +02:00
Kevin Veen-BirkenbachandClaude Opus 4.7 a37b9ed8a7 feat(archive): add pkgmgr archive subcommand for task-tracked spec dirs
Walks a directory of numbered NNN-topic.md files, promotes every file
with zero unchecked task-list items into the directorys README under
the ## Archive section, then deletes the source file. Keeps spec
directories (typically docs/requirements) short and focused on open
work.

The action ships as pkgmgr.actions.archive with four leaf modules
(discovery, inspect, readme, workflow) and is wired into the CLI as
pkgmgr archive [DIR] [--readme PATH] [--dry-run] [--include-template].

Extracted verbatim from cli/contributing/requirements/archive in
infinito-nexus-core so every kpmx-managed repository can rely on the
same archival convention without copy-pasting helpers. Twenty unit
tests cover discovery, inspection, README merge, and end-to-end
workflow paths.

Also: realign tests/unit/pkgmgr/cli/commands/test_release.py with the
new run_release(retry=False) signature shipped in v1.14.0.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 07:55:11 +02:00
Kevin Veen-Birkenbach a4a5b661b9 Release version 1.14.0
Mark stable commit / mark-stable (push) Canceled after 0s
2026-05-27 20:53:14 +02:00
Kevin Veen-BirkenbachandClaude Opus 4.7 43fbcfb227 feat(release): add retry mode to re-deploy existing release without re-tagging
Recovers from a release whose tag+commit landed cleanly but whose
post-tag steps (git push, latest-tag bump, twine upload) failed
mid-flight. pkgmgr release --retry skips the version bump, file
rewrites, commit, and tag-creation steps and re-runs only the
idempotent tail: re-push the existing HEAD tag, re-align the floating
latest tag, and (unless --no-publish) re-invoke publish.

The retry logic lives in its own module pkgmgr.actions.release.retry
so the workflow.py orchestrator stays focused on the forward path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 20:52:06 +02:00
30 changed files with 1241 additions and 19 deletions

No files matched your search

+58
View File
@@ -1,3 +1,61 @@
# Changelog
## [1.15.2] - 2026-05-28
* Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
## [1.15.1] - 2026-05-28
* Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
## [1.15.0] - 2026-05-28
* Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
## [1.14.0] - 2026-05-27
* Added
* New release --retry mode re-deploys the HEAD release without
re-tagging or modifying any files. It re-pushes the existing version
tag, re-aligns the floating latest tag, and (unless --no-publish)
re-runs publish. Use this to recover from a release whose post-tag
push or PyPI upload failed mid-flight. The release_type argument
becomes optional under --retry.
* New module pkgmgr.actions.release.retry hosts the retry_release
helper so the workflow orchestrator stays focused on the forward
path.
* RepoPaths now exposes a debian_control slot, discovered alongside
debian_changelog under both packaging/debian and the legacy debian
layout.
* pkgmgr.actions.release.package_name.resolve_package_name centralises
the distro-name lookup chain and is unit-tested under
tests/unit/pkgmgr/actions/release/test_package_name.py.
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
idempotent push, latest-tag re-alignment, missing-tag error path,
and branch-detection fallback.
Changed
* pkgmgr release now derives the distro-package name from existing
packaging metadata instead of the repository folder name. The lookup
order is packaging/debian/control Package field, then
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
then folder basename as legacy fallback. Renaming a repository
folder no longer silently flips the debian/changelog top entry and
the RPM changelog stanza to a new identifier. Those keep matching
the authoritative value in the packaging files, which is what apt,
pacman, and dnf index against.
Fixed
* dpkg-source --before-build no longer fails with the message about
source package having two conflicting values after a repo-folder
rename, because the changelog and control file stay in agreement
on the next release.
## [1.13.4] - 2026-05-27
* Changed
+1 -1
View File
@@ -32,7 +32,7 @@
rec {
pkgmgr = pyPkgs.buildPythonApplication {
pname = "package-manager";
version = "1.13.4";
version = "1.15.2";
# Use the git repo as source
src = ./.;
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
pkgname=package-manager
pkgver=1.13.4
pkgver=1.15.2
pkgrel=1
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
arch=('any')
+62
View File
@@ -1,3 +1,65 @@
package-manager (1.15.2-1) unstable; urgency=medium
* Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 28 May 2026 11:06:43 +0200
package-manager (1.15.1-1) unstable; urgency=medium
* Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 28 May 2026 08:18:23 +0200
package-manager (1.15.0-1) unstable; urgency=medium
* Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 28 May 2026 07:56:07 +0200
package-manager (1.14.0-1) unstable; urgency=medium
* Added
* New release --retry mode re-deploys the HEAD release without
re-tagging or modifying any files. It re-pushes the existing version
tag, re-aligns the floating latest tag, and (unless --no-publish)
re-runs publish. Use this to recover from a release whose post-tag
push or PyPI upload failed mid-flight. The release_type argument
becomes optional under --retry.
* New module pkgmgr.actions.release.retry hosts the retry_release
helper so the workflow orchestrator stays focused on the forward
path.
* RepoPaths now exposes a debian_control slot, discovered alongside
debian_changelog under both packaging/debian and the legacy debian
layout.
* pkgmgr.actions.release.package_name.resolve_package_name centralises
the distro-name lookup chain and is unit-tested under
tests/unit/pkgmgr/actions/release/test_package_name.py.
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
idempotent push, latest-tag re-alignment, missing-tag error path,
and branch-detection fallback.
Changed
* pkgmgr release now derives the distro-package name from existing
packaging metadata instead of the repository folder name. The lookup
order is packaging/debian/control Package field, then
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
then folder basename as legacy fallback. Renaming a repository
folder no longer silently flips the debian/changelog top entry and
the RPM changelog stanza to a new identifier. Those keep matching
the authoritative value in the packaging files, which is what apt,
pacman, and dnf index against.
Fixed
* dpkg-source --before-build no longer fails with the message about
source package having two conflicting values after a repo-folder
rename, because the changelog and control file stay in agreement
on the next release.
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 27 May 2026 20:53:14 +0200
package-manager (1.13.4-1) unstable; urgency=medium
* Changed
+51 -1
View File
@@ -1,5 +1,5 @@
Name: package-manager
Version: 1.13.4
Version: 1.15.2
Release: 1%{?dist}
Summary: Wrapper that runs Kevin's package-manager via Nix flake
@@ -74,6 +74,56 @@ echo ">>> package-manager removed. Nix itself was not removed."
/usr/lib/package-manager/
%changelog
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.2-1
- Restore `infinito` as an alias for the infinito-nexus/core repository so `pkgmgr install infinito` (and friends) resolves again.
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.1-1
- Insert pkgmgr release changelog entry under the H1 instead of above it. Fixes the markdownlint MD041 (first-line-h1) and MD012 (no-multiple-blanks) regressions that previously trashed every CHANGELOG.md after a release.
* Thu May 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.15.0-1
- Add pkgmgr archive subcommand: promote fully-checked NNN-topic.md spec files into the directorys README Archive section and delete the source files. Lookup pattern, README path, and template handling are configurable. Extracted from infinito-nexus-core so every kpmx-managed repo gets the same archival flow.
* Wed May 27 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.14.0-1
- Added
* New release --retry mode re-deploys the HEAD release without
re-tagging or modifying any files. It re-pushes the existing version
tag, re-aligns the floating latest tag, and (unless --no-publish)
re-runs publish. Use this to recover from a release whose post-tag
push or PyPI upload failed mid-flight. The release_type argument
becomes optional under --retry.
* New module pkgmgr.actions.release.retry hosts the retry_release
helper so the workflow orchestrator stays focused on the forward
path.
* RepoPaths now exposes a debian_control slot, discovered alongside
debian_changelog under both packaging/debian and the legacy debian
layout.
* pkgmgr.actions.release.package_name.resolve_package_name centralises
the distro-name lookup chain and is unit-tested under
tests/unit/pkgmgr/actions/release/test_package_name.py.
* tests/unit/pkgmgr/actions/release/test_retry.py covers routing,
idempotent push, latest-tag re-alignment, missing-tag error path,
and branch-detection fallback.
Changed
* pkgmgr release now derives the distro-package name from existing
packaging metadata instead of the repository folder name. The lookup
order is packaging/debian/control Package field, then
packaging/arch/PKGBUILD pkgname value, then RPM spec Name field,
then folder basename as legacy fallback. Renaming a repository
folder no longer silently flips the debian/changelog top entry and
the RPM changelog stanza to a new identifier. Those keep matching
the authoritative value in the packaging files, which is what apt,
pacman, and dnf index against.
Fixed
* dpkg-source --before-build no longer fails with the message about
source package having two conflicting values after a repo-folder
rename, because the changelog and control file stay in agreement
on the next release.
* Wed May 27 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.13.4-1
- Changed
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "kpmx"
version = "1.13.4"
version = "1.15.2"
description = "Kevin's package-manager tool (pkgmgr)"
readme = "README.md"
requires-python = ">=3.9"
+31
View File
@@ -0,0 +1,31 @@
"""Archive fully-checked Markdown files into a README index, then delete them.
The archive action walks a directory for numbered ``NNN-topic.md`` files
(default pattern ``^\\d{3}-[^/]+\\.md$``), promotes every file with zero
unchecked ``- [ ]`` task-list markers into a ``## Archive`` index inside
the directory README, and deletes the per-file source. Useful for
keeping ``docs/requirements/`` (or any other task-tracked spec folder)
short and focused on open work.
The module was extracted from
``cli/contributing/requirements/archive`` in infinito-nexus-core so
every kpmx-managed repository can rely on the same archival convention
without copy-pasting the helpers.
"""
from __future__ import annotations
from .discovery import iter_archivable_files
from .inspect import count_unchecked_items, extract_h1
from .readme import existing_archive_entries, merge_archive_section
from .workflow import ArchivePlan, run_archive
__all__ = [
"ArchivePlan",
"count_unchecked_items",
"existing_archive_entries",
"extract_h1",
"iter_archivable_files",
"merge_archive_section",
"run_archive",
]
+53
View File
@@ -0,0 +1,53 @@
"""Locate archivable Markdown files under a target directory."""
from __future__ import annotations
import re
from pathlib import Path
from typing import Iterable
DEFAULT_FILENAME_PATTERN = re.compile(r"^\d{3}-[^/]+\.md$")
TEMPLATE_FILENAME = "000-template.md"
def iter_archivable_files(
directory: Path,
*,
include_template: bool = False,
pattern: re.Pattern[str] = DEFAULT_FILENAME_PATTERN,
template_filename: str = TEMPLATE_FILENAME,
) -> list[Path]:
"""Return all files in *directory* whose name matches *pattern*, sorted.
``000-template.md`` (or whatever *template_filename* matches) is
excluded unless *include_template* is true. The check is filename
based; nested directories are not traversed.
"""
if not directory.is_dir():
return []
files: list[Path] = []
for path in sorted(directory.iterdir()):
if not path.is_file() or not pattern.match(path.name):
continue
if not include_template and path.name == template_filename:
continue
files.append(path)
return files
def filter_archivable_files(
paths: Iterable[Path],
*,
include_template: bool = False,
pattern: re.Pattern[str] = DEFAULT_FILENAME_PATTERN,
template_filename: str = TEMPLATE_FILENAME,
) -> list[Path]:
"""Same predicate as :func:`iter_archivable_files`, applied to an iterable."""
result: list[Path] = []
for path in paths:
if not path.is_file() or not pattern.match(path.name):
continue
if not include_template and path.name == template_filename:
continue
result.append(path)
return result
+35
View File
@@ -0,0 +1,35 @@
"""Parse a single Markdown file: H1 heading and task-list completeness."""
from __future__ import annotations
import re
from pathlib import Path
H1_RE = re.compile(r"^#\s+(?P<title>\S.*?)\s*$")
UNCHECKED_TASK_RE = re.compile(r"^\s*[-*+]\s+\[\s\]\s")
def extract_h1(path: Path) -> str | None:
"""Return the first H1 title in *path* or ``None`` if there is none."""
try:
with path.open(encoding="utf-8") as fh:
for line in fh:
match = H1_RE.match(line.rstrip("\n"))
if match:
return match.group("title")
except OSError:
return None
return None
def count_unchecked_items(path: Path) -> int:
"""Return the number of ``- [ ]`` task-list markers anywhere in *path*.
A non-zero count means the file is not yet fully complete and MUST
NOT be archived.
"""
try:
with path.open(encoding="utf-8") as fh:
return sum(1 for line in fh if UNCHECKED_TASK_RE.match(line))
except OSError:
return 0
+76
View File
@@ -0,0 +1,76 @@
"""Read and update the ``## Archive`` section of a directory README."""
from __future__ import annotations
import re
ARCHIVE_HEADING = "## Archive"
LIST_ITEM_RE = re.compile(r"^\s*-\s+(?P<body>\S.*)$")
def existing_archive_entries(readme_text: str) -> set[str]:
"""Return the deduplicated set of list-item bodies under ``## Archive``."""
lines = readme_text.splitlines()
in_archive = False
entries: set[str] = set()
for line in lines:
stripped = line.rstrip()
if stripped == ARCHIVE_HEADING:
in_archive = True
continue
if in_archive and stripped.startswith("## "):
break
if not in_archive:
continue
match = LIST_ITEM_RE.match(stripped)
if match:
entries.add(match.group("body").strip())
return entries
def merge_archive_section(readme_text: str, new_entries: list[str]) -> str:
"""Return ``readme_text`` with *new_entries* appended under ``## Archive``.
Existing entries are preserved verbatim. If the section is missing it
is created at the end of the document.
"""
if not new_entries:
return readme_text
lines = readme_text.splitlines()
archive_index = next(
(i for i, line in enumerate(lines) if line.rstrip() == ARCHIVE_HEADING),
None,
)
if archive_index is None:
suffix = [""] if (lines and lines[-1] != "") else []
suffix.append(ARCHIVE_HEADING)
suffix.append("")
suffix.extend(f"- {entry}" for entry in new_entries)
merged = lines + suffix
return "\n".join(merged) + "\n"
section_end = next(
(i for i in range(archive_index + 1, len(lines)) if lines[i].startswith("## ")),
len(lines),
)
body_start = archive_index + 1
while body_start < section_end and not lines[body_start].strip():
body_start += 1
last_item = body_start - 1
for i in range(body_start, section_end):
if LIST_ITEM_RE.match(lines[i]):
last_item = i
insertion_point = (last_item + 1) if last_item >= body_start else body_start
if insertion_point == body_start and body_start == archive_index + 1:
new_block = ["", *[f"- {entry}" for entry in new_entries]]
else:
new_block = [f"- {entry}" for entry in new_entries]
merged = lines[:insertion_point] + new_block + lines[insertion_point:]
trailing = "\n" if readme_text.endswith("\n") else ""
return "\n".join(merged) + trailing
+115
View File
@@ -0,0 +1,115 @@
"""Orchestrator for archiving fully-checked Markdown files."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from .discovery import iter_archivable_files
from .inspect import count_unchecked_items, extract_h1
from .readme import existing_archive_entries, merge_archive_section
@dataclass(frozen=True)
class ArchivePlan:
"""Outcome of an archive analysis run.
Attributes:
archived: ``(source_path, title)`` for every file that was (or
would be) archived. Order matches the original directory
listing.
skipped_incomplete: ``(source_path, unchecked_count)`` for files
that still hold ``- [ ]`` markers.
skipped_without_h1: files that had no H1 heading to use as title.
new_entries: titles that will be appended to the README index.
existing_entries: titles already present in the README index.
"""
archived: list[tuple[Path, str]]
skipped_incomplete: list[tuple[Path, int]]
skipped_without_h1: list[Path]
new_entries: list[str]
existing_entries: set[str]
def _bucket_files(
files: list[Path],
) -> tuple[
list[tuple[Path, str]],
list[tuple[Path, int]],
list[Path],
]:
plan: list[tuple[Path, str]] = []
skipped_incomplete: list[tuple[Path, int]] = []
skipped_without_h1: list[Path] = []
for path in files:
unchecked = count_unchecked_items(path)
if unchecked > 0:
skipped_incomplete.append((path, unchecked))
continue
title = extract_h1(path)
if title is None:
skipped_without_h1.append(path)
continue
plan.append((path, title))
return plan, skipped_incomplete, skipped_without_h1
def _dedupe_titles(
plan: list[tuple[Path, str]], already_archived: set[str]
) -> list[str]:
new_entries: list[str] = []
for _path, title in plan:
if title in already_archived or title in new_entries:
continue
new_entries.append(title)
return new_entries
def run_archive(
directory: Path,
readme_path: Path,
*,
dry_run: bool = False,
include_template: bool = False,
) -> ArchivePlan:
"""Walk *directory* and archive every fully-checked file into *readme_path*.
Returns an :class:`ArchivePlan` describing the outcome. When
``dry_run`` is true no files are deleted and the README is not
rewritten — the plan still reflects what *would* happen.
Raises ``FileNotFoundError`` if *directory* or *readme_path* does
not exist.
"""
if not directory.is_dir():
raise FileNotFoundError(f"Archive directory not found: {directory}")
if not readme_path.is_file():
raise FileNotFoundError(f"README not found: {readme_path}")
files = iter_archivable_files(directory, include_template=include_template)
readme_text = readme_path.read_text(encoding="utf-8")
already_archived = existing_archive_entries(readme_text)
archived, skipped_incomplete, skipped_without_h1 = _bucket_files(files)
new_entries = _dedupe_titles(archived, already_archived)
if not dry_run and new_entries:
merged_text = merge_archive_section(readme_text, new_entries)
if merged_text != readme_text:
readme_path.write_text(merged_text, encoding="utf-8")
if not dry_run:
for path, _title in archived:
try:
path.unlink()
except FileNotFoundError:
pass
return ArchivePlan(
archived=archived,
skipped_incomplete=skipped_incomplete,
skipped_without_h1=skipped_without_h1,
new_entries=new_entries,
existing_entries=already_archived,
)
@@ -1,11 +1,50 @@
from __future__ import annotations
import os
import re
from datetime import date
from typing import Optional
from .editor import _open_editor_for_changelog
H1_RE = re.compile(r"^#\s+\S", re.MULTILINE)
H2_RE = re.compile(r"^##\s+\S", re.MULTILINE)
def _insert_after_h1(existing: str, entry: str) -> str:
"""Place *entry* after the H1 (and any intro prose), above the first H2.
If the file has no H1 we synthesise ``# Changelog`` so the resulting
document is markdown-lint-clean (MD041 first-line-h1).
If the file has no H2 yet we append *entry* after the H1 block.
Existing behaviour for legacy headerless files (file starts with
``## ``) is preserved: *entry* is prepended unchanged.
"""
if not existing.strip():
return f"# Changelog\n\n{entry}"
if not H1_RE.search(existing):
# Legacy layout: file starts with `## [version]` and has no H1.
# Synthesise the H1 so the merged file is lint-clean.
return f"# Changelog\n\n{entry}{existing.lstrip()}"
# File has an H1. Find the first H2 (existing release section).
h2_match = H2_RE.search(existing)
if h2_match is None:
# H1 + optional intro but no release entries yet — append entry
# after a single blank line.
suffix = (
""
if existing.endswith("\n\n")
else ("\n" if existing.endswith("\n") else "\n\n")
)
return f"{existing}{suffix}{entry}"
# Insert new entry just before the first H2.
head = existing[: h2_match.start()].rstrip("\n") + "\n\n"
tail = existing[h2_match.start() :]
return f"{head}{entry}{tail}"
def update_changelog(
changelog_path: str,
@@ -13,9 +52,11 @@ def update_changelog(
message: Optional[str] = None,
preview: bool = False,
) -> str:
"""
Prepend a new release section to CHANGELOG.md with the new version,
current date, and a message.
"""Insert a new release entry into CHANGELOG.md.
The entry is placed after the documents H1 heading (creating one if
missing) and above any existing release entries, so the result stays
markdown-lint-clean (MD041 first-line-h1, MD012 no-multiple-blanks).
"""
today = date.today().isoformat()
@@ -32,8 +73,7 @@ def update_changelog(
else:
message = editor_message
header = f"## [{new_version}] - {today}\n"
header += f"\n* {message}\n\n"
entry = f"## [{new_version}] - {today}\n\n* {message}\n\n"
if os.path.exists(changelog_path):
try:
@@ -45,14 +85,14 @@ def update_changelog(
else:
changelog = ""
new_changelog = header + "\n" + changelog if changelog else header
new_changelog = _insert_after_h1(changelog, entry)
print("\n================ CHANGELOG ENTRY ================")
print(header.rstrip())
print(entry.rstrip())
print("=================================================\n")
if preview:
print(f"[PREVIEW] Would prepend new entry for {new_version} to CHANGELOG.md")
print(f"[PREVIEW] Would insert new entry for {new_version} into CHANGELOG.md")
return message
with open(changelog_path, "w", encoding="utf-8") as f:
+67
View File
@@ -0,0 +1,67 @@
"""Re-deploy an existing release without modifying files or creating a new tag.
The release workflow normally bumps versions, rewrites packaging
manifests, commits, tags, pushes, and uploads to PyPI in one shot.
When a post-tag step fails mid-flight (typical examples: `git push`
rejected, `twine upload` aborted by a broken venv, `update-latest`
rejected by branch protection) the local tag still exists on HEAD but
the side effects downstream are incomplete.
`retry_release` re-runs the idempotent tail of that flow so a botched
release can be re-pushed without touching code or recreating tags:
* `git push origin <branch> <tag>` for the existing HEAD tag
* re-align the floating `latest` tag if HEAD tag is the highest
Publishing (PyPI etc.) stays the caller's responsibility — the publish
workflow is already idempotent (twine rejects duplicates per spec) and
can be invoked independently via the `publish` subcommand.
"""
from __future__ import annotations
import os
from pkgmgr.actions.publish.git_tags import head_semver_tags
from pkgmgr.core.git import GitRunError, run
from pkgmgr.core.git.queries import get_current_branch
from pkgmgr.core.version.semver import SemVer
from .git_ops import is_highest_version_tag, update_latest_tag
def retry_release(
pyproject_path: str = "pyproject.toml",
preview: bool = False,
) -> None:
"""Re-push the HEAD release without re-tagging or modifying any files."""
try:
branch = get_current_branch() or "main"
except GitRunError:
branch = "main"
print(f"Retrying release push on branch: {branch}")
tags = head_semver_tags(cwd=os.path.dirname(os.path.abspath(pyproject_path)))
if not tags:
raise RuntimeError(
"No version tag on HEAD. Nothing to retry — "
"run `pkgmgr release <type>` first to create a release."
)
tag = max(tags, key=SemVer.parse)
print(f"Re-pushing existing tag: {tag}")
run(["push", "origin", branch, tag], preview=preview)
try:
if is_highest_version_tag(tag):
update_latest_tag(tag, preview=preview)
else:
print(f"[INFO] Skipping 'latest' update (tag {tag} is not the highest).")
except GitRunError as exc:
print(f"[WARN] Failed to update floating 'latest' tag for {tag}: {exc}")
if preview:
print(f"[PREVIEW] Retry push for {tag} would now complete.")
return
print(f"Retry push completed for {tag}.")
+6
View File
@@ -26,6 +26,7 @@ from .git_ops import (
)
from .package_name import resolve_package_name
from .prompts import confirm_proceed_release, should_delete_branch
from .retry import retry_release
from .versioning import bump_semver, determine_current_version
@@ -199,7 +200,12 @@ def release(
preview: bool = False,
force: bool = False,
close: bool = False,
retry: bool = False,
) -> None:
if retry:
retry_release(pyproject_path=pyproject_path, preview=preview)
return
if preview:
_release_impl(
pyproject_path=pyproject_path,
+2
View File
@@ -1,3 +1,4 @@
from .archive import handle_archive
from .repos import handle_repos_command
from .config import handle_config
from .tools import handle_tools_command
@@ -10,6 +11,7 @@ from .branch import handle_branch
from .mirror import handle_mirror_command
__all__ = [
"handle_archive",
"handle_repos_command",
"handle_config",
"handle_tools_command",
+76
View File
@@ -0,0 +1,76 @@
from __future__ import annotations
import sys
from pathlib import Path
from pkgmgr.actions.archive import ArchivePlan, run_archive
from pkgmgr.cli.context import CLIContext
def _print_summary(
directory: Path, readme: Path, plan: ArchivePlan, dry_run: bool
) -> None:
print(f"[archive] Directory: {directory}")
print(f"[archive] README: {readme}")
print(f"[archive] Files to process: {len(plan.archived)}")
print(f"[archive] Skipped (incomplete): {len(plan.skipped_incomplete)}")
print(f"[archive] New archive entries: {len(plan.new_entries)}")
print(f"[archive] Dry-run: {dry_run}")
def _print_skips(plan: ArchivePlan, cwd: Path) -> None:
if plan.skipped_incomplete:
print(
"[archive] SKIP: files with unchecked `- [ ]` items "
"(not archived, not deleted):"
)
for path, count in plan.skipped_incomplete:
suffix = "s" if count != 1 else ""
rel = _rel_or_abs(path, cwd)
print(f" - {rel} ({count} unchecked item{suffix})")
if plan.skipped_without_h1:
print("[archive] WARN: skipped files without an H1 heading:")
for path in plan.skipped_without_h1:
print(f" - {_rel_or_abs(path, cwd)}")
def _print_actions(plan: ArchivePlan, cwd: Path, dry_run: bool) -> None:
verb = "would archive" if dry_run else "archived"
rm_verb = "would delete" if dry_run else "deleted"
for path, title in plan.archived:
rel = _rel_or_abs(path, cwd)
print(f"[archive] {verb}: {rel} -> '{title}'")
if not dry_run:
print(f"[archive] {rm_verb}: {rel}")
def _rel_or_abs(path: Path, cwd: Path) -> str:
try:
return path.resolve().relative_to(cwd).as_posix()
except ValueError:
return path.as_posix()
def handle_archive(args, _ctx: CLIContext) -> None:
directory = Path(args.directory).resolve()
readme = (
Path(args.readme).resolve()
if args.readme
else (directory / "README.md").resolve()
)
try:
plan = run_archive(
directory=directory,
readme_path=readme,
dry_run=args.dry_run,
include_template=args.include_template,
)
except FileNotFoundError as exc:
print(f"[archive] ERROR: {exc}", file=sys.stderr)
sys.exit(1)
cwd = Path.cwd().resolve()
_print_summary(directory, readme, plan, args.dry_run)
_print_skips(plan, cwd)
_print_actions(plan, cwd, args.dry_run)
+15 -2
View File
@@ -49,7 +49,19 @@ def handle_release(
print(f"[WARN] Skipping repository {identifier}: directory missing.")
continue
print(f"[pkgmgr] Running release for repository {identifier}...")
retry = bool(getattr(args, "retry", False))
if retry and args.release_type:
print(
f"[WARN] Ignoring release_type '{args.release_type}' for {identifier} — --retry skips version bumps."
)
if not retry and not args.release_type:
print(
f"[WARN] Skipping {identifier}: release_type is required unless --retry is set."
)
continue
action_label = "retry-release" if retry else "release"
print(f"[pkgmgr] Running {action_label} for repository {identifier}...")
cwd_before = os.getcwd()
try:
@@ -58,11 +70,12 @@ def handle_release(
run_release(
pyproject_path="pyproject.toml",
changelog_path="CHANGELOG.md",
release_type=args.release_type,
release_type=args.release_type or "patch",
message=args.message or None,
preview=getattr(args, "preview", False),
force=getattr(args, "force", False),
close=getattr(args, "close", False),
retry=retry,
)
if not getattr(args, "no_publish", False):
+5
View File
@@ -10,6 +10,7 @@ from pkgmgr.core.repository.selected import get_selected_repos
from pkgmgr.core.repository.dir import get_repo_dir
from pkgmgr.cli.commands import (
handle_archive,
handle_repos_command,
handle_tools_command,
handle_release,
@@ -60,6 +61,10 @@ def dispatch_command(args, ctx: CLIContext) -> None:
if maybe_handle_proxy(args, ctx):
return
if args.command == "archive":
handle_archive(args, ctx)
return
commands_with_selection = {
"install",
"update",
+2
View File
@@ -4,6 +4,7 @@ import argparse
from pkgmgr.cli.proxy import register_proxy_commands
from .archive_cmd import add_archive_subparser
from .branch_cmd import add_branch_subparsers
from .changelog_cmd import add_changelog_subparser
from .common import SortedSubParsersAction
@@ -65,6 +66,7 @@ def create_parser(description_text: str) -> argparse.ArgumentParser:
add_make_subparsers(subparsers)
add_mirror_subparsers(subparsers)
add_archive_subparser(subparsers)
register_proxy_commands(subparsers)
return parser
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import argparse
def add_archive_subparser(subparsers: argparse._SubParsersAction) -> None:
"""Register the archive subcommand.
Walks a directory of numbered ``NNN-topic.md`` files, promotes every
file whose ``- [ ]`` checklist is fully checked into the directorys
README ``## Archive`` section, then deletes the source file.
"""
parser = subparsers.add_parser(
"archive",
help=(
"Archive fully-checked Markdown spec files (NNN-topic.md) "
"into the directorys README and delete them."
),
description=(
"Walk DIR for files that match the numbered "
"NNN-topic.md naming, promote every file with zero "
"unchecked `- [ ]` items into READMEs ## Archive section, "
"then delete the source file. Files with unchecked items are "
"skipped. Use --dry-run to preview without writing."
),
)
parser.add_argument(
"directory",
nargs="?",
default="docs/requirements",
help=(
"Directory to scan for archivable Markdown files. "
"Defaults to docs/requirements (relative to the current "
"working directory)."
),
)
parser.add_argument(
"--readme",
default=None,
help=(
"Path to the README that holds the ## Archive index. "
"Defaults to <directory>/README.md."
),
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print planned changes without modifying or deleting anything.",
)
parser.add_argument(
"--include-template",
action="store_true",
help=(
"Also archive and delete 000-template.md. Off by default "
"because contributor guides typically reference it."
),
)
+17 -1
View File
@@ -24,8 +24,12 @@ def add_release_subparser(
release_parser.add_argument(
"release_type",
nargs="?",
choices=["major", "minor", "patch"],
help="Type of version increment for the release (major, minor, patch).",
help=(
"Type of version increment for the release (major, minor, patch). "
"Omit when `--retry` is set."
),
)
release_parser.add_argument(
@@ -61,3 +65,15 @@ def add_release_subparser(
action="store_true",
help="Do not run publish automatically after a successful release.",
)
release_parser.add_argument(
"--retry",
action="store_true",
help=(
"Re-deploy the existing HEAD release without re-tagging or "
"modifying any files: re-push the existing version tag, "
"re-align the floating `latest` tag, and (unless --no-publish) "
"re-run publish. Use this to recover from a release whose "
"post-tag push or PyPI upload failed mid-flight."
),
)
+1
View File
@@ -265,6 +265,7 @@ repositories:
- 44D8F11FD62F878E
- B5690EEEBB952194
- account: infinito-nexus
alias: infinito
provider: github.com
description: Infinito.nexus streamlines Linux-based system setups and Docker image administration, perfect for servers and PCs. It offers extensive solutions for system initialization, admin tools, backups, monitoring, updates, driver management, security, and VPNs.
homepage: https://infinito.nexus
Whitespace-only changes.
@@ -0,0 +1,49 @@
"""Unit tests for `pkgmgr.actions.archive.discovery`."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from pkgmgr.actions.archive.discovery import iter_archivable_files
class TestIterArchivableFiles(unittest.TestCase):
def test_only_numbered_markdown_files_are_returned_and_sorted(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
(d / "001-alpha.md").write_text("# 001 - A")
(d / "002-beta.md").write_text("# 002 - B")
(d / "README.md").write_text("# X")
(d / "notes.md").write_text("# X")
(d / "001-alpha.txt").write_text("x")
result = [p.name for p in iter_archivable_files(d, include_template=True)]
self.assertEqual(result, ["001-alpha.md", "002-beta.md"])
def test_template_is_skipped_unless_opted_in(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
(d / "000-template.md").write_text("# 000 - Template")
(d / "001-alpha.md").write_text("# 001 - A")
self.assertEqual(
[p.name for p in iter_archivable_files(d, include_template=False)],
["001-alpha.md"],
)
self.assertEqual(
[p.name for p in iter_archivable_files(d, include_template=True)],
["000-template.md", "001-alpha.md"],
)
def test_missing_directory_returns_empty(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
self.assertEqual(
iter_archivable_files(Path(tmp) / "missing", include_template=False),
[],
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,62 @@
"""Unit tests for `pkgmgr.actions.archive.inspect`."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from pkgmgr.actions.archive.inspect import count_unchecked_items, extract_h1
class TestExtractH1(unittest.TestCase):
def test_returns_first_h1_title(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "001-x.md"
path.write_text("\n# 001 - Title\n\n## Subsection\n# Later H1\n")
self.assertEqual(extract_h1(path), "001 - Title")
def test_returns_none_when_no_h1(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "001-x.md"
path.write_text("no heading here\n## h2 only\n")
self.assertIsNone(extract_h1(path))
class TestCountUncheckedItems(unittest.TestCase):
def test_zero_when_all_checked(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "001-x.md"
path.write_text(
"# 001 - Done\n\n## Acceptance Criteria\n\n- [x] A\n- [x] B\n"
)
self.assertEqual(count_unchecked_items(path), 0)
def test_counts_unchecked_anywhere_in_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "001-x.md"
path.write_text(
"# 001 - In progress\n\n"
"## Acceptance Criteria\n\n"
"- [x] A\n"
"- [ ] B\n\n"
"## Notes\n\n"
"- [ ] still tracking this one too\n"
" - [ ] nested unchecked\n"
)
self.assertEqual(count_unchecked_items(path), 3)
def test_ignores_non_task_dashes(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "001-x.md"
path.write_text(
"# 001 - X\n\n"
"- plain list item\n"
"- [x] checked\n"
"- not [ ] not a task marker\n"
)
self.assertEqual(count_unchecked_items(path), 0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,73 @@
"""Unit tests for `pkgmgr.actions.archive.readme`."""
from __future__ import annotations
import unittest
from pkgmgr.actions.archive.readme import (
existing_archive_entries,
merge_archive_section,
)
class TestExistingArchiveEntries(unittest.TestCase):
def test_parses_only_archive_section_items(self) -> None:
readme = (
"# Requirements\n\n"
"Intro paragraph.\n\n"
"## Other\n\n"
"- not an archive entry\n\n"
"## Archive\n\n"
"- 001 - One\n"
"- 002 - Two\n\n"
"## Trailing\n\n"
"- ignored too\n"
)
self.assertEqual(
existing_archive_entries(readme),
{"001 - One", "002 - Two"},
)
def test_returns_empty_when_section_missing(self) -> None:
self.assertEqual(existing_archive_entries("# Title\n\nBody.\n"), set())
class TestMergeArchiveSection(unittest.TestCase):
def test_creates_section_when_missing(self) -> None:
readme = "# Requirements\n\nIntro.\n"
merged = merge_archive_section(readme, ["001 - One", "002 - Two"])
self.assertEqual(
merged,
"# Requirements\n\nIntro.\n\n## Archive\n\n- 001 - One\n- 002 - Two\n",
)
def test_appends_to_existing_section_preserving_entries(self) -> None:
readme = "# Requirements\n\nIntro.\n\n## Archive\n\n- 001 - One\n"
merged = merge_archive_section(readme, ["002 - Two", "003 - Three"])
self.assertEqual(
merged,
(
"# Requirements\n\n"
"Intro.\n\n"
"## Archive\n\n"
"- 001 - One\n"
"- 002 - Two\n"
"- 003 - Three\n"
),
)
def test_appends_before_trailing_section(self) -> None:
readme = "## Archive\n\n- 001 - One\n\n## Trailing\n\nfooter\n"
merged = merge_archive_section(readme, ["002 - Two"])
self.assertEqual(
merged,
"## Archive\n\n- 001 - One\n- 002 - Two\n\n## Trailing\n\nfooter\n",
)
def test_empty_entries_returns_unchanged(self) -> None:
readme = "# X\n\nBody\n"
self.assertEqual(merge_archive_section(readme, []), readme)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,91 @@
"""Unit tests for `pkgmgr.actions.archive.workflow.run_archive`."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from pkgmgr.actions.archive.workflow import run_archive
class TestRunArchive(unittest.TestCase):
def test_dry_run_does_not_touch_files_or_readme(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
done = d / "001-done.md"
done.write_text("# 001 - Done\n\n- [x] ok\n")
readme = d / "README.md"
readme.write_text("# Specs\n\n## Archive\n")
plan = run_archive(d, readme, dry_run=True)
self.assertTrue(done.exists())
self.assertEqual(readme.read_text(), "# Specs\n\n## Archive\n")
self.assertEqual(plan.new_entries, ["001 - Done"])
self.assertEqual(plan.archived, [(done, "001 - Done")])
def test_real_run_archives_and_deletes_completed_file(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
done = d / "001-done.md"
done.write_text("# 001 - Done\n\n- [x] ok\n")
readme = d / "README.md"
readme.write_text("# Specs\n\n## Archive\n")
plan = run_archive(d, readme)
self.assertFalse(done.exists())
self.assertIn("- 001 - Done", readme.read_text())
self.assertEqual(plan.new_entries, ["001 - Done"])
def test_incomplete_files_are_skipped(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
wip = d / "002-wip.md"
wip.write_text("# 002 - WIP\n\n- [ ] open\n")
readme = d / "README.md"
readme.write_text("# Specs\n\n## Archive\n")
plan = run_archive(d, readme)
self.assertTrue(wip.exists())
self.assertEqual(plan.archived, [])
self.assertEqual(plan.skipped_incomplete, [(wip, 1)])
def test_already_archived_title_is_not_duplicated(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
done = d / "001-done.md"
done.write_text("# 001 - Done\n\n- [x] ok\n")
readme = d / "README.md"
readme.write_text("# Specs\n\n## Archive\n\n- 001 - Done\n")
plan = run_archive(d, readme)
self.assertEqual(plan.new_entries, [])
self.assertEqual(plan.archived, [(done, "001 - Done")])
# File still deleted; README unchanged.
self.assertFalse(done.exists())
self.assertEqual(
readme.read_text(),
"# Specs\n\n## Archive\n\n- 001 - Done\n",
)
def test_missing_directory_raises_filenotfound(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
readme = Path(tmp) / "README.md"
readme.write_text("# X\n## Archive\n")
with self.assertRaises(FileNotFoundError):
run_archive(Path(tmp) / "no-such-dir", readme)
def test_missing_readme_raises_filenotfound(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp)
(d / "001-done.md").write_text("# 001 - Done\n- [x] ok\n")
with self.assertRaises(FileNotFoundError):
run_archive(d, d / "missing.md")
if __name__ == "__main__":
unittest.main()
@@ -256,7 +256,7 @@ class TestUpdateSpecVersion(unittest.TestCase):
class TestUpdateChangelog(unittest.TestCase):
def test_update_changelog_creates_file_if_missing(self) -> None:
def test_update_changelog_creates_file_with_h1_if_missing(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
self.assertFalse(os.path.exists(path))
@@ -267,10 +267,35 @@ class TestUpdateChangelog(unittest.TestCase):
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertIn("## [1.2.3]", content)
# New file must lead with an H1 so markdownlint MD041 is happy.
self.assertTrue(content.startswith("# Changelog\n\n## [1.2.3]"))
self.assertIn("First release", content)
def test_update_changelog_prepends_entry_to_existing_content(self) -> None:
def test_update_changelog_inserts_below_existing_h1(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
existing = "# Changelog\n\n## [0.1.0] - 2024-01-01\n\n* Initial content\n"
with open(path, "w", encoding="utf-8") as f:
f.write(existing)
update_changelog(path, "1.0.0", message="Second release", preview=False)
with open(path, "r", encoding="utf-8") as f:
content = f.read()
# H1 still on top, new entry above the existing one.
self.assertTrue(content.startswith("# Changelog\n\n## [1.0.0]"))
# Exactly one H1.
self.assertEqual(
content.count("\n# Changelog\n") + content.startswith("# Changelog\n"), 1
)
# Old entry still present, after the new one.
self.assertLess(
content.index("## [1.0.0]"),
content.index("## [0.1.0]"),
)
def test_update_changelog_legacy_headerless_gets_h1_synthesized(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "CHANGELOG.md")
with open(path, "w", encoding="utf-8") as f:
@@ -281,7 +306,8 @@ class TestUpdateChangelog(unittest.TestCase):
with open(path, "r", encoding="utf-8") as f:
content = f.read()
self.assertTrue(content.startswith("## [1.0.0]"))
# An H1 is added so MD041 is satisfied even for legacy files.
self.assertTrue(content.startswith("# Changelog\n\n## [1.0.0]"))
self.assertIn("## [0.1.0] - 2024-01-01", content)
def test_update_changelog_preview_does_not_write(self) -> None:
@@ -0,0 +1,154 @@
"""Unit tests for the `release(retry=True)` re-deploy entry point.
`TestReleaseRetry` exercises the routing decision in `release()` —
that `--retry` skips the full `_release_impl` flow and dispatches to
`retry_release` instead.
`TestRetryRelease` exercises the standalone module `pkgmgr.actions.release.retry`:
HEAD-tag discovery, idempotent push, latest-tag re-alignment, and
fallback behaviour when the current branch cannot be detected.
"""
from __future__ import annotations
import unittest
from unittest.mock import patch
from pkgmgr.actions.release.retry import retry_release
from pkgmgr.actions.release.workflow import release
from pkgmgr.core.git import GitRunError
class TestReleaseRetry(unittest.TestCase):
@patch("pkgmgr.actions.release.workflow.retry_release")
@patch("pkgmgr.actions.release.workflow._release_impl")
def test_retry_routes_to_retry_release_only(
self, mock_release_impl, mock_retry_release
) -> None:
release(retry=True, preview=False)
mock_retry_release.assert_called_once()
mock_release_impl.assert_not_called()
self.assertFalse(mock_retry_release.call_args.kwargs["preview"])
@patch("pkgmgr.actions.release.workflow.retry_release")
@patch("pkgmgr.actions.release.workflow._release_impl")
def test_retry_preview_passthrough(
self, mock_release_impl, mock_retry_release
) -> None:
release(retry=True, preview=True)
mock_retry_release.assert_called_once()
mock_release_impl.assert_not_called()
self.assertTrue(mock_retry_release.call_args.kwargs["preview"])
@patch("pkgmgr.actions.release.workflow.retry_release")
@patch("pkgmgr.actions.release.workflow._release_impl")
def test_retry_ignores_release_args(
self, mock_release_impl, mock_retry_release
) -> None:
# release_type/message/close/force must NOT trigger the full release flow
# when retry=True.
release(
retry=True,
release_type="major",
message="ignored body",
preview=False,
force=True,
close=True,
)
mock_retry_release.assert_called_once()
mock_release_impl.assert_not_called()
class TestRetryRelease(unittest.TestCase):
@patch("pkgmgr.actions.release.retry.update_latest_tag")
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=True)
@patch("pkgmgr.actions.release.retry.run")
@patch("pkgmgr.actions.release.retry.head_semver_tags")
@patch(
"pkgmgr.actions.release.retry.get_current_branch",
return_value="release-fix",
)
def test_retry_pushes_existing_tag_and_updates_latest(
self,
_mock_branch,
mock_head_tags,
mock_run,
_mock_highest,
mock_update_latest,
) -> None:
mock_head_tags.return_value = ["v1.13.4"]
retry_release(pyproject_path="pyproject.toml", preview=False)
mock_run.assert_called_once_with(
["push", "origin", "release-fix", "v1.13.4"], preview=False
)
mock_update_latest.assert_called_once_with("v1.13.4", preview=False)
@patch("pkgmgr.actions.release.retry.update_latest_tag")
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=False)
@patch("pkgmgr.actions.release.retry.run")
@patch("pkgmgr.actions.release.retry.head_semver_tags")
@patch(
"pkgmgr.actions.release.retry.get_current_branch",
return_value="release-fix",
)
def test_retry_skips_latest_for_non_highest_tag(
self,
_mock_branch,
mock_head_tags,
_mock_run,
_mock_highest,
mock_update_latest,
) -> None:
mock_head_tags.return_value = ["v1.0.0"]
retry_release(pyproject_path="pyproject.toml", preview=False)
mock_update_latest.assert_not_called()
@patch(
"pkgmgr.actions.release.retry.head_semver_tags",
return_value=[],
)
@patch(
"pkgmgr.actions.release.retry.get_current_branch",
return_value="main",
)
def test_retry_raises_when_head_has_no_tag(
self, _mock_branch, _mock_head_tags
) -> None:
with self.assertRaises(RuntimeError) as cm:
retry_release(pyproject_path="pyproject.toml", preview=False)
self.assertIn("No version tag on HEAD", str(cm.exception))
@patch("pkgmgr.actions.release.retry.update_latest_tag")
@patch("pkgmgr.actions.release.retry.is_highest_version_tag", return_value=True)
@patch("pkgmgr.actions.release.retry.run")
@patch("pkgmgr.actions.release.retry.head_semver_tags")
@patch(
"pkgmgr.actions.release.retry.get_current_branch",
side_effect=GitRunError("detached"),
)
def test_retry_falls_back_to_main_branch_when_detection_fails(
self,
_mock_branch,
mock_head_tags,
mock_run,
_mock_highest,
_mock_update_latest,
) -> None:
mock_head_tags.return_value = ["v2.0.0"]
retry_release(pyproject_path="pyproject.toml", preview=False)
mock_run.assert_called_once_with(
["push", "origin", "main", "v2.0.0"], preview=False
)
if __name__ == "__main__":
unittest.main()
@@ -112,6 +112,7 @@ class TestReleaseCommand(unittest.TestCase):
preview=False,
force=True,
close=True,
retry=False,
)
@patch("pkgmgr.cli.commands.release.os.path.isdir", return_value=True)
@@ -160,6 +161,7 @@ class TestReleaseCommand(unittest.TestCase):
preview=True,
force=False,
close=False,
retry=False,
)
@patch("pkgmgr.cli.commands.release.run_release")