Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f6fe52855 | |||
| 156ac595e6 | |||
| b778ad7dbe | |||
| 02384979bc | |||
| 59f470fb5e | |||
| 1e5cc3f1aa | |||
| fbe8018451 | |||
| 161d5d220b | |||
| 0d4e33f4d3 | |||
| a4b29b4d49 | |||
| 4e83779459 | |||
|
|
69dc430ca1 | ||
|
|
dd172c0c99 | ||
|
|
42a7445865 | ||
|
|
0ec74ad06f | ||
|
|
fab5a5b6af |
2
.github/workflows/lint-python.yml
vendored
2
.github/workflows/lint-python.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v7
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
|
|
||||||
|
|||||||
4
.github/workflows/publish-containers.yml
vendored
4
.github/workflows/publish-containers.yml
vendored
@@ -38,13 +38,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
|
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003
|
||||||
with:
|
with:
|
||||||
use: true
|
use: true
|
||||||
|
|
||||||
- name: Login to GHCR
|
- name: Login to GHCR
|
||||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
||||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
|
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
4
.github/workflows/update-flake-lock.yml
vendored
4
.github/workflows/update-flake-lock.yml
vendored
@@ -21,10 +21,10 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install Nix
|
- name: Install Nix
|
||||||
uses: DeterminateSystems/nix-installer-action@v16
|
uses: DeterminateSystems/nix-installer-action@v23
|
||||||
|
|
||||||
- name: Update flake.lock and open a pull request
|
- name: Update flake.lock and open a pull request
|
||||||
uses: DeterminateSystems/update-flake-lock@v24
|
uses: DeterminateSystems/update-flake-lock@v29
|
||||||
with:
|
with:
|
||||||
pr-title: "chore(deps): update flake.lock"
|
pr-title: "chore(deps): update flake.lock"
|
||||||
pr-labels: |
|
pr-labels: |
|
||||||
|
|||||||
41
CHANGELOG.md
41
CHANGELOG.md
@@ -1,5 +1,46 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.0.2] - 2026-10-01
|
||||||
|
|
||||||
|
Fixes a release flow that could reject every entry written for it.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A heading in a changelog message became bold, which markdown-lint reads
|
||||||
|
back as a heading and refuses with MD036
|
||||||
|
- Since the editor reopens on failure, no structured entry could ever pass
|
||||||
|
- Headings now move below the release heading they are filed under
|
||||||
|
- The lint findings reached nobody: the editor took over the terminal in the
|
||||||
|
same breath they were printed
|
||||||
|
- They are held until Enter and repeated inside the editor, above the text
|
||||||
|
- The Makefile installer accepted install-* as proof of an install target but
|
||||||
|
always ran plain make install
|
||||||
|
- A repository with install-python and no install died on "No rule to make
|
||||||
|
target" instead of being skipped
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- transform and lint were covered separately, so a transform producing lint
|
||||||
|
errors went unseen; one test now runs the first through the second
|
||||||
|
- Three tests were checking behaviour the code had already left behind
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- docker/login-action 3.7.0 to 4.6.0, setup-buildx-action 3.12.0 to 4.4.0
|
||||||
|
- actions/setup-python 5 to 7
|
||||||
|
- DeterminateSystems nix-installer-action 16 to 23, update-flake-lock 24 to 29
|
||||||
|
|
||||||
|
### Upgrading
|
||||||
|
|
||||||
|
- Nothing to do; a changelog entry written with headings now keeps them
|
||||||
|
|
||||||
|
## [2.0.1] - 2026-09-18
|
||||||
|
|
||||||
|
* A release no longer ends *CHANGELOG.md* on a blank line, which is MD012.
|
||||||
|
* The entry's separator only belongs there when another entry follows it.
|
||||||
|
* Affects the first entry a file receives, so it shows on an opening release.
|
||||||
|
* Four tests cover the placements in *_insert_after_h1*, one per branch.
|
||||||
|
|
||||||
## [2.0.0] - 2026-09-18
|
## [2.0.0] - 2026-09-18
|
||||||
|
|
||||||
Breaking
|
Breaking
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
rec {
|
rec {
|
||||||
pkgmgr = pyPkgs.buildPythonApplication {
|
pkgmgr = pyPkgs.buildPythonApplication {
|
||||||
pname = "package-manager";
|
pname = "package-manager";
|
||||||
version = "2.0.0";
|
version = "2.0.2";
|
||||||
|
|
||||||
# Use the git repo as source
|
# Use the git repo as source
|
||||||
src = ./.;
|
src = ./.;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
||||||
|
|
||||||
pkgname=package-manager
|
pkgname=package-manager
|
||||||
pkgver=2.0.0
|
pkgver=2.0.2
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
||||||
arch=('any')
|
arch=('any')
|
||||||
|
|||||||
@@ -1,3 +1,48 @@
|
|||||||
|
package-manager (2.0.2-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
Fixes a release flow that could reject every entry written for it.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A heading in a changelog message became bold, which markdown-lint reads
|
||||||
|
back as a heading and refuses with MD036
|
||||||
|
- Since the editor reopens on failure, no structured entry could ever pass
|
||||||
|
- Headings now move below the release heading they are filed under
|
||||||
|
- The lint findings reached nobody: the editor took over the terminal in the
|
||||||
|
same breath they were printed
|
||||||
|
- They are held until Enter and repeated inside the editor, above the text
|
||||||
|
- The Makefile installer accepted install-* as proof of an install target but
|
||||||
|
always ran plain make install
|
||||||
|
- A repository with install-python and no install died on "No rule to make
|
||||||
|
target" instead of being skipped
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- transform and lint were covered separately, so a transform producing lint
|
||||||
|
errors went unseen; one test now runs the first through the second
|
||||||
|
- Three tests were checking behaviour the code had already left behind
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- docker/login-action 3.7.0 to 4.6.0, setup-buildx-action 3.12.0 to 4.4.0
|
||||||
|
- actions/setup-python 5 to 7
|
||||||
|
- DeterminateSystems nix-installer-action 16 to 23, update-flake-lock 24 to 29
|
||||||
|
|
||||||
|
### Upgrading
|
||||||
|
|
||||||
|
- Nothing to do; a changelog entry written with headings now keeps them
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Thu, 01 Oct 2026 17:47:12 +0200
|
||||||
|
|
||||||
|
package-manager (2.0.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* A release no longer ends *CHANGELOG.md* on a blank line, which is MD012.
|
||||||
|
* The entry's separator only belongs there when another entry follows it.
|
||||||
|
* Affects the first entry a file receives, so it shows on an opening release.
|
||||||
|
* Four tests cover the placements in *_insert_after_h1*, one per branch.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 18 Sep 2026 17:15:35 +0200
|
||||||
|
|
||||||
package-manager (2.0.0-1) unstable; urgency=medium
|
package-manager (2.0.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
Breaking
|
Breaking
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
Name: package-manager
|
Name: package-manager
|
||||||
Version: 2.0.0
|
Version: 2.0.2
|
||||||
Release: 1%{?dist}
|
Release: 1%{?dist}
|
||||||
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
||||||
|
|
||||||
@@ -74,6 +74,45 @@ echo ">>> package-manager removed. Nix itself was not removed."
|
|||||||
/usr/lib/package-manager/
|
/usr/lib/package-manager/
|
||||||
|
|
||||||
%changelog
|
%changelog
|
||||||
|
* Thu Oct 01 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.2-1
|
||||||
|
- Fixes a release flow that could reject every entry written for it.
|
||||||
|
|
||||||
|
- ### Fixed
|
||||||
|
|
||||||
|
- - A heading in a changelog message became bold, which markdown-lint reads
|
||||||
|
- back as a heading and refuses with MD036
|
||||||
|
- - Since the editor reopens on failure, no structured entry could ever pass
|
||||||
|
- - Headings now move below the release heading they are filed under
|
||||||
|
- - The lint findings reached nobody: the editor took over the terminal in the
|
||||||
|
- same breath they were printed
|
||||||
|
- - They are held until Enter and repeated inside the editor, above the text
|
||||||
|
- - The Makefile installer accepted install-* as proof of an install target but
|
||||||
|
- always ran plain make install
|
||||||
|
- - A repository with install-python and no install died on "No rule to make
|
||||||
|
- target" instead of being skipped
|
||||||
|
|
||||||
|
- ### Tests
|
||||||
|
|
||||||
|
- - transform and lint were covered separately, so a transform producing lint
|
||||||
|
- errors went unseen; one test now runs the first through the second
|
||||||
|
- - Three tests were checking behaviour the code had already left behind
|
||||||
|
|
||||||
|
- ### Dependencies
|
||||||
|
|
||||||
|
- - docker/login-action 3.7.0 to 4.6.0, setup-buildx-action 3.12.0 to 4.4.0
|
||||||
|
- - actions/setup-python 5 to 7
|
||||||
|
- - DeterminateSystems nix-installer-action 16 to 23, update-flake-lock 24 to 29
|
||||||
|
|
||||||
|
- ### Upgrading
|
||||||
|
|
||||||
|
- - Nothing to do; a changelog entry written with headings now keeps them
|
||||||
|
|
||||||
|
* Fri Sep 18 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.1-1
|
||||||
|
- * A release no longer ends *CHANGELOG.md* on a blank line, which is MD012.
|
||||||
|
- * The entry's separator only belongs there when another entry follows it.
|
||||||
|
- * Affects the first entry a file receives, so it shows on an opening release.
|
||||||
|
- * Four tests cover the placements in *_insert_after_h1*, one per branch.
|
||||||
|
|
||||||
* Fri Sep 18 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.0-1
|
* Fri Sep 18 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.0-1
|
||||||
- Breaking
|
- Breaking
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "kpmx"
|
name = "kpmx"
|
||||||
version = "2.0.0"
|
version = "2.0.2"
|
||||||
description = "Kevin's package-manager tool (pkgmgr)"
|
description = "Kevin's package-manager tool (pkgmgr)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
@@ -31,9 +31,7 @@ class MakefileInstaller(BaseInstaller):
|
|||||||
except OSError:
|
except OSError:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if re.search(r"^install\s*:", content, flags=re.MULTILINE):
|
return bool(re.search(r"^install\s*:", content, flags=re.MULTILINE))
|
||||||
return True
|
|
||||||
return bool(re.search(r"^install-[a-zA-Z0-9_-]*\s*:", content, flags=re.MULTILINE))
|
|
||||||
|
|
||||||
def run(self, ctx: RepoContext) -> None:
|
def run(self, ctx: RepoContext) -> None:
|
||||||
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
|
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
_HEADING = re.compile(r"^\s*#{1,6}\s+(.*?)\s*#*\s*$")
|
_HEADING = re.compile(r"^\s*(#{1,6})\s+(.*?)\s*#*\s*$")
|
||||||
_INLINE_CODE = re.compile(r"`([^`\n]+)`")
|
_INLINE_CODE = re.compile(r"`([^`\n]+)`")
|
||||||
_FINDING = re.compile(r"\bMD\d{3}\b")
|
_FINDING = re.compile(r"\bMD\d{3}\b")
|
||||||
_MULTI_BLANK = re.compile(r"\n{3,}")
|
_MULTI_BLANK = re.compile(r"\n{3,}")
|
||||||
|
_ENTRY_LEVEL = 3
|
||||||
|
|
||||||
|
|
||||||
class ChangelogLintError(RuntimeError):
|
class ChangelogLintError(RuntimeError):
|
||||||
@@ -20,10 +21,10 @@ class ChangelogLintError(RuntimeError):
|
|||||||
def transform_changelog_message(text: str) -> str:
|
def transform_changelog_message(text: str) -> str:
|
||||||
"""Normalise a free-form release message into the changelog house style.
|
"""Normalise a free-form release message into the changelog house style.
|
||||||
|
|
||||||
A leading ``#`` heading becomes a bold line of its own (markdown
|
Headings are pushed below the ``## [version]`` line the entry is filed
|
||||||
headings inside an entry body would collide with the ``## [version]``
|
under, so they nest instead of competing with it. Bolding them instead
|
||||||
structure), and inline ``code`` spans become ``*italic*`` so the entry
|
would read as a heading to markdown-lint and fail MD036. Inline ``code``
|
||||||
stays free of backticks.
|
spans become ``*italic*`` so the entry stays free of backticks.
|
||||||
"""
|
"""
|
||||||
text = _INLINE_CODE.sub(r"*\1*", text)
|
text = _INLINE_CODE.sub(r"*\1*", text)
|
||||||
|
|
||||||
@@ -31,9 +32,10 @@ def transform_changelog_message(text: str) -> str:
|
|||||||
for line in text.split("\n"):
|
for line in text.split("\n"):
|
||||||
heading = _HEADING.match(line)
|
heading = _HEADING.match(line)
|
||||||
if heading:
|
if heading:
|
||||||
|
level = max(len(heading.group(1)), _ENTRY_LEVEL)
|
||||||
if out and out[-1].strip():
|
if out and out[-1].strip():
|
||||||
out.append("")
|
out.append("")
|
||||||
out.append(f"**{heading.group(1).strip()}**")
|
out.append(f"{'#' * level} {heading.group(2).strip()}")
|
||||||
out.append("")
|
out.append("")
|
||||||
else:
|
else:
|
||||||
out.append(line)
|
out.append(line)
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ H1_RE = re.compile(r"^#\s+\S", re.MULTILINE)
|
|||||||
H2_RE = re.compile(r"^##\s+\S", re.MULTILINE)
|
H2_RE = re.compile(r"^##\s+\S", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def _at_end_of_file(entry: str) -> str:
|
||||||
|
"""Return *entry* without the blank line that separates it from a next one."""
|
||||||
|
return entry.rstrip("\n") + "\n"
|
||||||
|
|
||||||
|
|
||||||
def _insert_after_h1(existing: str, entry: str) -> str:
|
def _insert_after_h1(existing: str, entry: str) -> str:
|
||||||
"""Place *entry* after the H1 (and any intro prose), above the first H2.
|
"""Place *entry* after the H1 (and any intro prose), above the first H2.
|
||||||
|
|
||||||
@@ -26,7 +31,7 @@ def _insert_after_h1(existing: str, entry: str) -> str:
|
|||||||
``## ``) is preserved: *entry* is prepended unchanged.
|
``## ``) is preserved: *entry* is prepended unchanged.
|
||||||
"""
|
"""
|
||||||
if not existing.strip():
|
if not existing.strip():
|
||||||
return f"# Changelog\n\n{entry}"
|
return f"# Changelog\n\n{_at_end_of_file(entry)}"
|
||||||
|
|
||||||
if not H1_RE.search(existing):
|
if not H1_RE.search(existing):
|
||||||
# Legacy layout: file starts with `## [version]` and has no H1.
|
# Legacy layout: file starts with `## [version]` and has no H1.
|
||||||
@@ -43,7 +48,7 @@ def _insert_after_h1(existing: str, entry: str) -> str:
|
|||||||
if existing.endswith("\n\n")
|
if existing.endswith("\n\n")
|
||||||
else ("\n" if existing.endswith("\n") else "\n\n")
|
else ("\n" if existing.endswith("\n") else "\n\n")
|
||||||
)
|
)
|
||||||
return f"{existing}{suffix}{entry}"
|
return f"{existing}{suffix}{_at_end_of_file(entry)}"
|
||||||
|
|
||||||
# Insert new entry just before the first H2.
|
# Insert new entry just before the first H2.
|
||||||
head = existing[: h2_match.start()].rstrip("\n") + "\n\n"
|
head = existing[: h2_match.start()].rstrip("\n") + "\n\n"
|
||||||
@@ -75,6 +80,12 @@ def update_changelog(
|
|||||||
print(f" - {finding}")
|
print(f" - {finding}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
def _wait_for_reader() -> None:
|
||||||
|
try:
|
||||||
|
input("[INFO] Press Enter to re-open the editor and fix the entry...")
|
||||||
|
except EOFError:
|
||||||
|
print("[INFO] Re-opening the editor so you can fix the entry...")
|
||||||
|
|
||||||
if message is not None:
|
if message is not None:
|
||||||
body, entry = _entry_for(message)
|
body, entry = _entry_for(message)
|
||||||
findings = lint_changelog_entry(changelog_path, entry)
|
findings = lint_changelog_entry(changelog_path, entry)
|
||||||
@@ -87,19 +98,21 @@ def update_changelog(
|
|||||||
body, entry = _entry_for(message or f"Release {new_version}")
|
body, entry = _entry_for(message or f"Release {new_version}")
|
||||||
else:
|
else:
|
||||||
attempt: str | None = None
|
attempt: str | None = None
|
||||||
|
rejected: list[str] | None = None
|
||||||
while True:
|
while True:
|
||||||
print(
|
print(
|
||||||
"\n[INFO] Provide the changelog entry — a leading '#' becomes "
|
"\n[INFO] Provide the changelog entry - a leading '#' becomes "
|
||||||
"bold, `code` becomes italic.\n"
|
"a sub-heading, `code` becomes italic.\n"
|
||||||
)
|
)
|
||||||
raw = _open_editor_for_changelog(attempt)
|
raw = _open_editor_for_changelog(attempt, rejected)
|
||||||
body, entry = _entry_for(raw or f"Release {new_version}")
|
body, entry = _entry_for(raw or f"Release {new_version}")
|
||||||
findings = lint_changelog_entry(changelog_path, entry)
|
findings = lint_changelog_entry(changelog_path, entry)
|
||||||
if not findings:
|
if not findings:
|
||||||
break
|
break
|
||||||
_print_findings(findings)
|
_print_findings(findings)
|
||||||
attempt = body
|
attempt = body
|
||||||
print("[INFO] Re-opening the editor so you can fix the entry...")
|
rejected = findings
|
||||||
|
_wait_for_reader()
|
||||||
|
|
||||||
changelog = ""
|
changelog = ""
|
||||||
if os.path.exists(changelog_path):
|
if os.path.exists(changelog_path):
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
|
|
||||||
|
|
||||||
def _open_editor_for_changelog(initial_message: str | None = None) -> str:
|
def _open_editor_for_changelog(
|
||||||
|
initial_message: str | None = None,
|
||||||
|
findings: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Args:
|
||||||
|
initial_message: the previous attempt, pre-loaded for editing.
|
||||||
|
findings: why that attempt was rejected, shown above it.
|
||||||
|
"""
|
||||||
editor = os.environ.get("EDITOR", "nano")
|
editor = os.environ.get("EDITOR", "nano")
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(
|
with tempfile.NamedTemporaryFile(
|
||||||
@@ -18,9 +25,15 @@ def _open_editor_for_changelog(initial_message: str | None = None) -> str:
|
|||||||
tmp.write(
|
tmp.write(
|
||||||
"; Write the changelog entry for this release.\n"
|
"; Write the changelog entry for this release.\n"
|
||||||
"; Lines starting with ';' are ignored.\n"
|
"; Lines starting with ';' are ignored.\n"
|
||||||
"; A leading '#' becomes bold; `code` becomes italic.\n"
|
"; A leading '#' becomes a sub-heading; `code` becomes italic.\n"
|
||||||
"; Empty result will fall back to a generic message.\n\n"
|
"; Empty result will fall back to a generic message.\n"
|
||||||
)
|
)
|
||||||
|
if findings:
|
||||||
|
tmp.write(";\n; markdown-lint rejected the previous entry:\n")
|
||||||
|
for finding in findings:
|
||||||
|
for line in str(finding).splitlines() or [""]:
|
||||||
|
tmp.write(f"; {line}\n")
|
||||||
|
tmp.write("\n")
|
||||||
if initial_message:
|
if initial_message:
|
||||||
tmp.write(initial_message.strip() + "\n")
|
tmp.write(initial_message.strip() + "\n")
|
||||||
tmp.flush()
|
tmp.flush()
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ class TestDebianControlInstaller(unittest.TestCase):
|
|||||||
return "/usr/bin/dpkg-buildpackage"
|
return "/usr/bin/dpkg-buildpackage"
|
||||||
if name == "apt-get":
|
if name == "apt-get":
|
||||||
return "/usr/bin/apt-get"
|
return "/usr/bin/apt-get"
|
||||||
|
if name == "sudo":
|
||||||
|
return "/usr/bin/sudo"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
mock_which.side_effect = which_side_effect
|
mock_which.side_effect = which_side_effect
|
||||||
|
|||||||
@@ -81,6 +81,19 @@ class TestMakefileInstaller(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
mock_run_command.assert_not_called()
|
mock_run_command.assert_not_called()
|
||||||
|
|
||||||
|
@patch("pkgmgr.actions.install.installers.makefile.run_command")
|
||||||
|
@patch(
|
||||||
|
"builtins.open",
|
||||||
|
new_callable=mock_open,
|
||||||
|
read_data="install-python:\n\tpip install -r requirements.txt\n",
|
||||||
|
)
|
||||||
|
@patch("os.path.exists", return_value=True)
|
||||||
|
def test_run_skips_a_makefile_that_only_has_a_prefixed_target(
|
||||||
|
self, mock_exists, mock_file, mock_run_command
|
||||||
|
):
|
||||||
|
self.installer.run(self.ctx)
|
||||||
|
mock_run_command.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -9,15 +9,16 @@ from pkgmgr.actions.release.files.changelog_lint import (
|
|||||||
|
|
||||||
|
|
||||||
class TestTransformChangelogMessage(unittest.TestCase):
|
class TestTransformChangelogMessage(unittest.TestCase):
|
||||||
def test_heading_becomes_bold(self) -> None:
|
def test_heading_is_pushed_below_the_release_heading(self) -> None:
|
||||||
out = transform_changelog_message("# Title\n\nbody")
|
out = transform_changelog_message("# Title\n\nbody")
|
||||||
self.assertIn("**Title**", out)
|
self.assertIn("### Title", out)
|
||||||
self.assertNotIn("# Title", out)
|
self.assertNotIn("**Title**", out)
|
||||||
|
|
||||||
def test_multi_hash_heading_becomes_bold(self) -> None:
|
def test_a_heading_already_deep_enough_keeps_its_level(self) -> None:
|
||||||
self.assertEqual(
|
self.assertEqual(transform_changelog_message("### Sub heading"), "### Sub heading")
|
||||||
transform_changelog_message("### Sub heading"), "**Sub heading**"
|
|
||||||
)
|
def test_a_deeper_heading_is_left_alone(self) -> None:
|
||||||
|
self.assertEqual(transform_changelog_message("#### Deeper"), "#### Deeper")
|
||||||
|
|
||||||
def test_inline_code_becomes_italic(self) -> None:
|
def test_inline_code_becomes_italic(self) -> None:
|
||||||
out = transform_changelog_message("use `pkgmgr release` now")
|
out = transform_changelog_message("use `pkgmgr release` now")
|
||||||
@@ -27,11 +28,12 @@ class TestTransformChangelogMessage(unittest.TestCase):
|
|||||||
def test_plain_message_is_unchanged(self) -> None:
|
def test_plain_message_is_unchanged(self) -> None:
|
||||||
self.assertEqual(transform_changelog_message("just text"), "just text")
|
self.assertEqual(transform_changelog_message("just text"), "just text")
|
||||||
|
|
||||||
def test_no_heading_or_backtick_survives(self) -> None:
|
def test_no_backtick_survives_and_no_heading_competes(self) -> None:
|
||||||
out = transform_changelog_message("# Heading\n\n* item with `code`")
|
out = transform_changelog_message("# Heading\n\n* item with `code`")
|
||||||
self.assertNotIn("`", out)
|
self.assertNotIn("`", out)
|
||||||
for line in out.split("\n"):
|
for line in out.split("\n"):
|
||||||
self.assertFalse(line.lstrip().startswith("#"))
|
if line.lstrip().startswith("#"):
|
||||||
|
self.assertTrue(line.startswith("###"), line)
|
||||||
|
|
||||||
|
|
||||||
class TestLintChangelogEntry(unittest.TestCase):
|
class TestLintChangelogEntry(unittest.TestCase):
|
||||||
@@ -39,6 +41,13 @@ class TestLintChangelogEntry(unittest.TestCase):
|
|||||||
entry = "## [1.0.0] - 2026-01-01\n\n* a clean bullet\n\n"
|
entry = "## [1.0.0] - 2026-01-01\n\n* a clean bullet\n\n"
|
||||||
self.assertEqual(lint_changelog_entry("CHANGELOG.md", entry), [])
|
self.assertEqual(lint_changelog_entry("CHANGELOG.md", entry), [])
|
||||||
|
|
||||||
|
def test_a_structured_message_survives_its_own_transform(self) -> None:
|
||||||
|
body = transform_changelog_message(
|
||||||
|
"# Security\n\n* a bullet\n\n## Fixed\n\n* another bullet"
|
||||||
|
)
|
||||||
|
entry = f"## [1.0.0] - 2026-01-01\n\n{body}\n\n"
|
||||||
|
self.assertEqual(lint_changelog_entry("CHANGELOG.md", entry), [])
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
41
tests/unit/pkgmgr/actions/release/test_changelog_md.py
Normal file
41
tests/unit/pkgmgr/actions/release/test_changelog_md.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from pkgmgr.actions.release.files.changelog_md import _insert_after_h1
|
||||||
|
|
||||||
|
ENTRY = "## [1.0.0] - 2026-09-18\n\nOfficial Release\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
class TestInsertAfterH1(unittest.TestCase):
|
||||||
|
def _assert_lint_clean(self, document: str) -> None:
|
||||||
|
self.assertNotIn(
|
||||||
|
"\n\n\n",
|
||||||
|
document,
|
||||||
|
f"MD012: multiple consecutive blank lines in\n{document!r}",
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
document.endswith("\n") and not document.endswith("\n\n"),
|
||||||
|
f"MD012: blank line at end of file in\n{document!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_an_empty_changelog_gets_one_trailing_newline(self) -> None:
|
||||||
|
self._assert_lint_clean(_insert_after_h1("", ENTRY))
|
||||||
|
|
||||||
|
def test_a_first_entry_under_a_bare_h1_gets_one_trailing_newline(self) -> None:
|
||||||
|
self._assert_lint_clean(_insert_after_h1("# Changelog\n", ENTRY))
|
||||||
|
|
||||||
|
def test_a_second_entry_stays_separated_from_the_first(self) -> None:
|
||||||
|
existing = "# Changelog\n\n## [0.9.0] - 2026-09-01\n\nOlder\n"
|
||||||
|
document = _insert_after_h1(existing, ENTRY)
|
||||||
|
self._assert_lint_clean(document)
|
||||||
|
self.assertIn("Official Release\n\n## [0.9.0]", document)
|
||||||
|
|
||||||
|
def test_a_legacy_headerless_changelog_gains_an_h1(self) -> None:
|
||||||
|
document = _insert_after_h1("## [0.9.0] - 2026-09-01\n\nOlder\n", ENTRY)
|
||||||
|
self._assert_lint_clean(document)
|
||||||
|
self.assertTrue(document.startswith("# Changelog\n\n## [1.0.0]"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
60
tests/unit/pkgmgr/actions/release/test_editor.py
Normal file
60
tests/unit/pkgmgr/actions/release/test_editor.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from pkgmgr.actions.release.files.editor import _open_editor_for_changelog
|
||||||
|
|
||||||
|
|
||||||
|
class TestOpenEditorForChangelog(unittest.TestCase):
|
||||||
|
def _buffer(self, **kwargs: object) -> str:
|
||||||
|
"""Returns: what the editor was handed, with the editor itself a no-op."""
|
||||||
|
seen: dict[str, str] = {}
|
||||||
|
|
||||||
|
def record(argv: list[str]) -> int:
|
||||||
|
with open(argv[1], encoding="utf-8") as handle:
|
||||||
|
seen["text"] = handle.read()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"pkgmgr.actions.release.files.editor.subprocess.call", side_effect=record
|
||||||
|
):
|
||||||
|
_open_editor_for_changelog(**kwargs)
|
||||||
|
return seen["text"]
|
||||||
|
|
||||||
|
def test_the_rejection_reaches_the_editor_the_entry_is_fixed_in(self) -> None:
|
||||||
|
text = self._buffer(
|
||||||
|
initial_message="### Security\n\n- a bullet",
|
||||||
|
findings=["changelog entry:3 error MD036/no-emphasis-as-heading"],
|
||||||
|
)
|
||||||
|
self.assertIn("MD036/no-emphasis-as-heading", text)
|
||||||
|
self.assertIn("### Security", text)
|
||||||
|
|
||||||
|
def test_every_finding_line_is_commented_out(self) -> None:
|
||||||
|
text = self._buffer(findings=["first line\nsecond line"])
|
||||||
|
for line in text.splitlines():
|
||||||
|
if "line" in line and "ignored" not in line:
|
||||||
|
self.assertTrue(line.startswith(";"), line)
|
||||||
|
|
||||||
|
def test_a_finding_never_lands_in_the_entry(self) -> None:
|
||||||
|
seen: dict[str, str] = {}
|
||||||
|
|
||||||
|
def rewrite(argv: list[str]) -> int:
|
||||||
|
with open(argv[1], encoding="utf-8") as handle:
|
||||||
|
seen["text"] = handle.read()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"pkgmgr.actions.release.files.editor.subprocess.call", side_effect=rewrite
|
||||||
|
):
|
||||||
|
kept = _open_editor_for_changelog(
|
||||||
|
initial_message="- a bullet", findings=["MD036 somewhere"]
|
||||||
|
)
|
||||||
|
self.assertEqual(kept, "- a bullet")
|
||||||
|
|
||||||
|
def test_without_findings_the_header_stays_as_it_was(self) -> None:
|
||||||
|
self.assertNotIn("markdown-lint rejected", self._buffer())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
unittest.main()
|
||||||
@@ -334,9 +334,10 @@ class TestUpdateChangelog(unittest.TestCase):
|
|||||||
with open(path, encoding="utf-8") as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
|
|
||||||
self.assertIn("**Summary**", content)
|
self.assertIn("### Summary", content)
|
||||||
self.assertIn("*foo*", content)
|
self.assertIn("*foo*", content)
|
||||||
self.assertNotIn("# Summary", content)
|
self.assertNotIn("\n# Summary", content)
|
||||||
|
self.assertNotIn("\n## Summary", content)
|
||||||
self.assertNotIn("`foo`", content)
|
self.assertNotIn("`foo`", content)
|
||||||
|
|
||||||
def test_update_changelog_preview_does_not_write(self) -> None:
|
def test_update_changelog_preview_does_not_write(self) -> None:
|
||||||
|
|||||||
@@ -29,22 +29,19 @@ class FindDefaultsSourceDirTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(Path(found).resolve(), (pkg_root / "config").resolve())
|
self.assertEqual(Path(found).resolve(), (pkg_root / "config").resolve())
|
||||||
|
|
||||||
def test_falls_back_to_project_root_config(self):
|
def test_a_config_dir_beside_the_package_is_not_taken(self):
|
||||||
with tempfile.TemporaryDirectory() as td:
|
with tempfile.TemporaryDirectory() as td:
|
||||||
root = Path(td)
|
root = Path(td)
|
||||||
pkg_root = root / "site-packages" / "pkgmgr"
|
pkg_root = root / "site-packages" / "pkgmgr"
|
||||||
pkg_root.mkdir(parents=True)
|
pkg_root.mkdir(parents=True)
|
||||||
|
|
||||||
# only project_root config exists
|
|
||||||
(pkg_root.parent / "config").mkdir(parents=True)
|
(pkg_root.parent / "config").mkdir(parents=True)
|
||||||
|
|
||||||
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
||||||
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
||||||
found = config_cmd._find_defaults_source_dir()
|
found = config_cmd._find_defaults_source_dir()
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertIsNone(found)
|
||||||
Path(found).resolve(), (pkg_root.parent / "config").resolve()
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_returns_none_when_no_config_dirs_exist(self):
|
def test_returns_none_when_no_config_dirs_exist(self):
|
||||||
with tempfile.TemporaryDirectory() as td:
|
with tempfile.TemporaryDirectory() as td:
|
||||||
@@ -88,9 +85,8 @@ class UpdateDefaultConfigsTests(unittest.TestCase):
|
|||||||
self.assertTrue((dest_cfg_dir / "a.yaml").is_file())
|
self.assertTrue((dest_cfg_dir / "a.yaml").is_file())
|
||||||
self.assertTrue((dest_cfg_dir / "b.yml").is_file())
|
self.assertTrue((dest_cfg_dir / "b.yml").is_file())
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
(dest_cfg_dir / "config.yaml")
|
(dest_cfg_dir / "config.yaml").exists(),
|
||||||
.read_text(encoding="utf-8")
|
"the source config.yaml must not be copied over the user's own",
|
||||||
.startswith("should_not_copy")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Ensure config.yaml was not overwritten (it may exist, but should remain original if we create it)
|
# Ensure config.yaml was not overwritten (it may exist, but should remain original if we create it)
|
||||||
|
|||||||
@@ -242,9 +242,7 @@ class LoadConfigIntegrationUnitTests(unittest.TestCase):
|
|||||||
# Provide package defaults via fake pkgmgr + pkg_root/config
|
# Provide package defaults via fake pkgmgr + pkg_root/config
|
||||||
root = Path(td) / "site-packages"
|
root = Path(td) / "site-packages"
|
||||||
pkg_root = root / "pkgmgr"
|
pkg_root = root / "pkgmgr"
|
||||||
cfg_dir = (
|
cfg_dir = pkg_root / "config"
|
||||||
root / "config"
|
|
||||||
) # NOTE: load.py checks multiple roots, including pkg_root.parent (=site-packages)
|
|
||||||
pkg_root.mkdir(parents=True)
|
pkg_root.mkdir(parents=True)
|
||||||
cfg_dir.mkdir(parents=True)
|
cfg_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user