Compare commits

...

16 Commits

Author SHA1 Message Date
1f6fe52855 Release version 2.0.2
Some checks are pending
CI / security-codeql (push) Waiting to run
CI / test-unit (push) Waiting to run
CI / test-integration (push) Waiting to run
CI / test-env-virtual (push) Waiting to run
CI / test-env-nix (push) Waiting to run
CI / test-e2e (push) Waiting to run
CI / test-virgin-user (push) Waiting to run
CI / test-virgin-root (push) Waiting to run
CI / lint-shell (push) Waiting to run
CI / lint-python (push) Waiting to run
CI / lint-docker (push) Waiting to run
Mark stable commit / mark-stable (push) Waiting to run
2026-10-01 17:47:12 +02:00
156ac595e6 test: hold three tests against the code they are supposed to cover
All three failed on behaviour that is correct, which is why they had to move
rather than the code.

Two date from aa48981, which moved the default configs into the package. The
loader test still wrote them beside it, into site-packages, and read its own
stale comment as proof that the loader looks there. The cli-update test
demanded a fallback onto that same directory; on a wheel install it belongs
to other packages, so the test now pins the opposite and keeps the boundary.

The third read a config.yaml in the destination to show the source one was
not copied over it. The code never writes that file, so the read died before
it could show anything; the absence is the assertion.

The debian installer test stubbed which() for apt-get and dpkg-buildpackage
but not for sudo, so _privileged_prefix returned None and the installer
warned and left before reaching the apt-get calls the test was waiting for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:45:43 +02:00
b778ad7dbe fix(install): treat install-* as what it is, not as an install target
The detection accepted any target starting with install-, while the run
below it has always called plain make install. A repository carrying
install-python and no install therefore pulled the installer in and died on
"No rule to make target 'install'" instead of being skipped, which is what
the surrounding branch is there for. meta-infinite-graph, universal-logout
and file-dedupe all failed this way, and with them two e2e tests.

775c301 added the parsing to run make install only where an install target
exists. The prefix arm undid that, so it goes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:45:42 +02:00
02384979bc fix(release): stop rejecting the changelog entry pkgmgr itself produced
A heading in the message became a bold line, and markdown-lint reads a bold
line on its own as a heading: MD036. Every structured entry therefore failed
the check it had just been normalised for, and since the loop reopens the
editor on failure there was no text that could end it.

Headings now move below the "## [version]" line the entry is filed under.
A "###" under a "##" is the hierarchy, not a competitor; only "#" and "##"
would have collided, and those are the ones raised.

The findings never reached the person either. They were printed and the
editor took the terminal over in the same breath, so the reason was gone
before it could be read. They are held on screen until Enter, and written
into the editor buffer as ';' lines above the rejected text, where the fix
is actually made; the buffer already drops those lines when it is read back.

transform_changelog_message and lint_changelog_entry were each tested alone,
which is how a transform that produces lint errors went unnoticed. A test now
runs the output of the first through the second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-01 17:45:42 +02:00
59f470fb5e Merge pull request #5 from kevinveenbirkenbach/dependabot/github_actions/docker/login-action-4.6.0
build(deps): bump docker/login-action from 3.7.0 to 4.6.0
2026-10-01 16:28:28 +02:00
1e5cc3f1aa Merge pull request #4 from kevinveenbirkenbach/dependabot/github_actions/DeterminateSystems/nix-installer-action-23
build(deps): bump DeterminateSystems/nix-installer-action from 16 to 23
2026-10-01 16:28:14 +02:00
fbe8018451 Merge pull request #3 from kevinveenbirkenbach/dependabot/github_actions/docker/setup-buildx-action-4.4.0
build(deps): bump docker/setup-buildx-action from 3.12.0 to 4.4.0
2026-10-01 16:27:57 +02:00
161d5d220b Merge pull request #2 from kevinveenbirkenbach/dependabot/github_actions/actions/setup-python-7
build(deps): bump actions/setup-python from 5 to 7
2026-10-01 16:27:32 +02:00
0d4e33f4d3 Merge pull request #1 from kevinveenbirkenbach/dependabot/github_actions/DeterminateSystems/update-flake-lock-29
build(deps): bump DeterminateSystems/update-flake-lock from 24 to 29
2026-10-01 16:27:01 +02:00
a4b29b4d49 Release version 2.0.1
Some checks failed
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) Has been cancelled
Update flake.lock / Update flake.lock (push) Has been cancelled
2026-09-18 17:15:35 +02:00
4e83779459 fix(release): do not end a changelog on a blank line
An entry carries a trailing blank line so that a `## [version]` below it is
separated. Two of the four placements in _insert_after_h1 put the entry at the
end of the file instead, where that separator becomes a blank line after the
final newline and markdownlint reports MD012. Both now pass the entry through
_at_end_of_file.

Only the first entry a file ever receives is affected, which is why it appears
on a repository's opening release and never again: every later entry is
inserted above an existing H2, and there the separator is correct. The
base-images repository hit it on 1.0.0 and its lint job went red on the first
push it ever had.

The other two placements are deliberately untouched. In the legacy branch the
old content follows the entry, and before the first H2 the previous release
section does, so the blank line has to stay.

Verified: four tests, one per placement, assert both that no document holds
three consecutive newlines and that it ends on exactly one; two of them also
assert the separator survives where it belongs, which fails if the trim is
applied too widely. 74 tests pass under tests/unit/pkgmgr/actions/release.

Unrelated and pre-existing: test_update_changelog_transforms_heading_and_inline_code
fails wherever markdownlint-cli2 is installed, because transform_changelog_message
renders a leading `#` as bold and markdownlint calls that MD036.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 17:14:11 +02:00
dependabot[bot]
69dc430ca1 build(deps): bump docker/login-action from 3.7.0 to 4.6.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 3.7.0 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](c94ce9fb46...dbcb813823)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 14:26:19 +00:00
dependabot[bot]
dd172c0c99 build(deps): bump DeterminateSystems/nix-installer-action from 16 to 23
Bumps [DeterminateSystems/nix-installer-action](https://github.com/determinatesystems/nix-installer-action) from 16 to 23.
- [Release notes](https://github.com/determinatesystems/nix-installer-action/releases)
- [Commits](https://github.com/determinatesystems/nix-installer-action/compare/v16...v23)

---
updated-dependencies:
- dependency-name: DeterminateSystems/nix-installer-action
  dependency-version: '23'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 14:25:10 +00:00
dependabot[bot]
42a7445865 build(deps): bump docker/setup-buildx-action from 3.12.0 to 4.4.0
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 3.12.0 to 4.4.0.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](8d2750c68a...594f3bf428)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-version: 4.4.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 14:25:03 +00:00
dependabot[bot]
0ec74ad06f build(deps): bump actions/setup-python from 5 to 7
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 7.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v7)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 14:24:58 +00:00
dependabot[bot]
fab5a5b6af build(deps): bump DeterminateSystems/update-flake-lock from 24 to 29
Bumps [DeterminateSystems/update-flake-lock](https://github.com/determinatesystems/update-flake-lock) from 24 to 29.
- [Release notes](https://github.com/determinatesystems/update-flake-lock/releases)
- [Commits](https://github.com/determinatesystems/update-flake-lock/compare/v24...v29)

---
updated-dependencies:
- dependency-name: DeterminateSystems/update-flake-lock
  dependency-version: '29'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-18 14:24:52 +00:00
21 changed files with 320 additions and 49 deletions

View File

@@ -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"

View File

@@ -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 }}

View File

@@ -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: |

View File

@@ -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

View File

@@ -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 = ./.;

View File

@@ -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')

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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)

View File

@@ -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)

View File

@@ -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):

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

View File

@@ -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()

View 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()

View 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()

View File

@@ -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:

View File

@@ -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)

View File

@@ -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)