Compare commits
24
Commits
b22ff17b0e
..
latest
No files matched your search
@@ -15,12 +15,12 @@ jobs:
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Run hadolint (produce SARIF)
|
||||
id: hadolint
|
||||
continue-on-error: true
|
||||
uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5
|
||||
uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387
|
||||
with:
|
||||
dockerfile: ./Dockerfile
|
||||
format: sarif
|
||||
|
||||
@@ -11,10 +11,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
lint-shell:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Install ShellCheck
|
||||
run: sudo apt-get update && sudo apt-get install -y shellcheck
|
||||
- name: Run ShellCheck
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true # We need tags and main history for version comparison
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository (with tags)
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -38,13 +38,13 @@ jobs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f
|
||||
uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003
|
||||
with:
|
||||
use: true
|
||||
|
||||
- name: Login to GHCR
|
||||
if: ${{ steps.info.outputs.should_publish == 'true' }}
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Show Docker version
|
||||
run: docker version
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Show Docker version
|
||||
run: docker version
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Show commit SHA
|
||||
run: git rev-parse HEAD
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Show Docker version
|
||||
run: docker version
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Show Docker version
|
||||
run: docker version
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Show Docker version
|
||||
run: docker version
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Show Docker version
|
||||
run: docker version
|
||||
|
||||
@@ -18,13 +18,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@v16
|
||||
uses: DeterminateSystems/nix-installer-action@v23
|
||||
|
||||
- name: Update flake.lock and open a pull request
|
||||
uses: DeterminateSystems/update-flake-lock@v24
|
||||
uses: DeterminateSystems/update-flake-lock@v29
|
||||
with:
|
||||
pr-title: "chore(deps): update flake.lock"
|
||||
pr-labels: |
|
||||
|
||||
@@ -1,5 +1,72 @@
|
||||
# Changelog
|
||||
|
||||
## [2.0.4] - 2026-10-19
|
||||
|
||||
Fixed:
|
||||
|
||||
- CI: Python lint passes again on ruff 0.17 and still on 0.16
|
||||
- CI: actions bumped: *actions/checkout* v7, *hadolint-action* 3.5.0
|
||||
|
||||
Upgrade notes:
|
||||
|
||||
- 2.0.3 was never marked *stable* nor published to GHCR: its CI failed
|
||||
- Release: write list items with dash bullets when markdownlint-cli2 is absent
|
||||
|
||||
## [2.0.3] - 2026-10-18
|
||||
|
||||
Fixed:
|
||||
|
||||
- Release: the entry is linted inside the CHANGELOG.md it lands in
|
||||
- Release: a list marker that breaks the file's consistency is caught (MD004)
|
||||
- Release: findings the file already has are not blamed on the entry
|
||||
- Release: *--no-globs* keeps the target repo's other files out of the lint
|
||||
- Release: without markdownlint-cli2, list items must use dash bullets
|
||||
|
||||
Upgrade notes:
|
||||
|
||||
- Release: write list items with dash bullets when markdownlint-cli2 is absent
|
||||
|
||||
## [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
|
||||
|
||||
Breaking
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
rec {
|
||||
pkgmgr = pyPkgs.buildPythonApplication {
|
||||
pname = "package-manager";
|
||||
version = "2.0.0";
|
||||
version = "2.0.4";
|
||||
|
||||
# Use the git repo as source
|
||||
src = ./.;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Maintainer: Kevin Veen-Birkenbach <info@veen.world>
|
||||
|
||||
pkgname=package-manager
|
||||
pkgver=2.0.0
|
||||
pkgver=2.0.4
|
||||
pkgrel=1
|
||||
pkgdesc="Local-flake wrapper for Kevin's package-manager (Nix-based)."
|
||||
arch=('any')
|
||||
|
||||
@@ -1,3 +1,78 @@
|
||||
package-manager (2.0.4-1) unstable; urgency=medium
|
||||
|
||||
Fixed:
|
||||
|
||||
- CI: Python lint passes again on ruff 0.17 and still on 0.16
|
||||
- CI: actions bumped: *actions/checkout* v7, *hadolint-action* 3.5.0
|
||||
|
||||
Upgrade notes:
|
||||
|
||||
- 2.0.3 was never marked *stable* nor published to GHCR: its CI failed
|
||||
- Release: write list items with dash bullets when markdownlint-cli2 is absent
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Mon, 19 Oct 2026 10:06:02 +0200
|
||||
|
||||
package-manager (2.0.3-1) unstable; urgency=medium
|
||||
|
||||
Fixed:
|
||||
|
||||
- Release: the entry is linted inside the CHANGELOG.md it lands in
|
||||
- Release: a list marker that breaks the file's consistency is caught (MD004)
|
||||
- Release: findings the file already has are not blamed on the entry
|
||||
- Release: *--no-globs* keeps the target repo's other files out of the lint
|
||||
- Release: without markdownlint-cli2, list items must use dash bullets
|
||||
|
||||
Upgrade notes:
|
||||
|
||||
- Release: write list items with dash bullets when markdownlint-cli2 is absent
|
||||
|
||||
-- Kevin Veen-Birkenbach <kevin@veen.world> Sun, 18 Oct 2026 23:33:52 +0200
|
||||
|
||||
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
|
||||
|
||||
Breaking
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Name: package-manager
|
||||
Version: 2.0.0
|
||||
Version: 2.0.4
|
||||
Release: 1%{?dist}
|
||||
Summary: Wrapper that runs Kevin's package-manager via Nix flake
|
||||
|
||||
@@ -74,6 +74,69 @@ echo ">>> package-manager removed. Nix itself was not removed."
|
||||
/usr/lib/package-manager/
|
||||
|
||||
%changelog
|
||||
* Mon Oct 19 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.4-1
|
||||
- Fixed:
|
||||
|
||||
- - CI: Python lint passes again on ruff 0.17 and still on 0.16
|
||||
- - CI: actions bumped: *actions/checkout* v7, *hadolint-action* 3.5.0
|
||||
|
||||
- Upgrade notes:
|
||||
|
||||
- - 2.0.3 was never marked *stable* nor published to GHCR: its CI failed
|
||||
- - Release: write list items with dash bullets when markdownlint-cli2 is absent
|
||||
|
||||
* Sun Oct 18 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 2.0.3-1
|
||||
- Fixed:
|
||||
|
||||
- - Release: the entry is linted inside the CHANGELOG.md it lands in
|
||||
- - Release: a list marker that breaks the file's consistency is caught (MD004)
|
||||
- - Release: findings the file already has are not blamed on the entry
|
||||
- - Release: *--no-globs* keeps the target repo's other files out of the lint
|
||||
- - Release: without markdownlint-cli2, list items must use dash bullets
|
||||
|
||||
- Upgrade notes:
|
||||
|
||||
- - Release: write list items with dash bullets when markdownlint-cli2 is absent
|
||||
|
||||
* 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
|
||||
- Breaking
|
||||
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "kpmx"
|
||||
version = "2.0.0"
|
||||
version = "2.0.4"
|
||||
description = "Kevin's package-manager tool (pkgmgr)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -31,9 +31,7 @@ class MakefileInstaller(BaseInstaller):
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
if 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))
|
||||
return bool(re.search(r"^install\s*:", content, flags=re.MULTILINE))
|
||||
|
||||
def run(self, ctx: RepoContext) -> None:
|
||||
makefile_path = os.path.join(ctx.repo_dir, self.MAKEFILE_NAME)
|
||||
|
||||
@@ -6,11 +6,15 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
|
||||
_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]+)`")
|
||||
_FINDING = re.compile(r"\bMD\d{3}\b")
|
||||
_LOCATION = re.compile(r"^.*?:\d+(?::\d+)?\s+")
|
||||
_NON_DASH_ITEM = re.compile(r"^\s*[*+]\s")
|
||||
_MULTI_BLANK = re.compile(r"\n{3,}")
|
||||
_ENTRY_LEVEL = 3
|
||||
|
||||
|
||||
class ChangelogLintError(RuntimeError):
|
||||
@@ -20,10 +24,10 @@ class ChangelogLintError(RuntimeError):
|
||||
def transform_changelog_message(text: str) -> str:
|
||||
"""Normalise a free-form release message into the changelog house style.
|
||||
|
||||
A leading ``#`` heading becomes a bold line of its own (markdown
|
||||
headings inside an entry body would collide with the ``## [version]``
|
||||
structure), and inline ``code`` spans become ``*italic*`` so the entry
|
||||
stays free of backticks.
|
||||
Headings are pushed below the ``## [version]`` line the entry is filed
|
||||
under, so they nest instead of competing with it. Bolding them instead
|
||||
would read as a heading to markdown-lint and fail MD036. Inline ``code``
|
||||
spans become ``*italic*`` so the entry stays free of backticks.
|
||||
"""
|
||||
text = _INLINE_CODE.sub(r"*\1*", text)
|
||||
|
||||
@@ -31,9 +35,10 @@ def transform_changelog_message(text: str) -> str:
|
||||
for line in text.split("\n"):
|
||||
heading = _HEADING.match(line)
|
||||
if heading:
|
||||
level = max(len(heading.group(1)), _ENTRY_LEVEL)
|
||||
if out and out[-1].strip():
|
||||
out.append("")
|
||||
out.append(f"**{heading.group(1).strip()}**")
|
||||
out.append(f"{'#' * level} {heading.group(2).strip()}")
|
||||
out.append("")
|
||||
else:
|
||||
out.append(line)
|
||||
@@ -57,6 +62,35 @@ def lint_changelog_entry(reference_path: str, entry: str) -> list[str]:
|
||||
return _builtin_lint(document)
|
||||
|
||||
|
||||
def lint_changelog_context(reference_path: str, existing: str, merged: str) -> list[str]:
|
||||
"""Return the findings *merged* has beyond those *existing* already has.
|
||||
|
||||
Args:
|
||||
reference_path: path of the target CHANGELOG.md; its directory supplies
|
||||
the markdownlint config.
|
||||
existing: the target file before the entry is inserted.
|
||||
merged: the target file with the entry inserted.
|
||||
|
||||
Returns:
|
||||
Findings of *merged* that *existing* does not produce. Empty when
|
||||
``markdownlint-cli2`` is unavailable.
|
||||
"""
|
||||
if not shutil.which("markdownlint-cli2"):
|
||||
return []
|
||||
known = Counter(
|
||||
_LOCATION.sub("", finding)
|
||||
for finding in (_markdownlint(reference_path, existing) if existing.strip() else [])
|
||||
)
|
||||
new: list[str] = []
|
||||
for finding in _markdownlint(reference_path, merged):
|
||||
key = _LOCATION.sub("", finding)
|
||||
if known[key]:
|
||||
known[key] -= 1
|
||||
else:
|
||||
new.append(finding)
|
||||
return new
|
||||
|
||||
|
||||
def _markdownlint(reference_path: str, document: str) -> list[str]:
|
||||
directory = os.path.dirname(os.path.abspath(reference_path)) or "."
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
@@ -67,7 +101,7 @@ def _markdownlint(reference_path: str, document: str) -> list[str]:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
handle.write(document)
|
||||
proc = subprocess.run(
|
||||
["markdownlint-cli2", name],
|
||||
["markdownlint-cli2", "--no-globs", name],
|
||||
cwd=directory,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -94,6 +128,8 @@ def _builtin_lint(document: str) -> list[str]:
|
||||
errors.append(f"line {number}: trailing whitespace (MD009)")
|
||||
if "`" in line:
|
||||
errors.append(f"line {number}: backtick is not allowed (use *italic*)")
|
||||
if _NON_DASH_ITEM.match(line):
|
||||
errors.append(f"line {number}: list items use '-' (MD004)")
|
||||
if "\n\n\n" in document:
|
||||
errors.append("multiple consecutive blank lines (MD012)")
|
||||
return errors
|
||||
@@ -3,10 +3,11 @@ from __future__ import annotations
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import date
|
||||
from datetime import datetime
|
||||
|
||||
from .changelog_lint import (
|
||||
ChangelogLintError,
|
||||
lint_changelog_context,
|
||||
lint_changelog_entry,
|
||||
transform_changelog_message,
|
||||
)
|
||||
@@ -16,6 +17,11 @@ H1_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:
|
||||
"""Place *entry* after the H1 (and any intro prose), above the first H2.
|
||||
|
||||
@@ -26,7 +32,7 @@ def _insert_after_h1(existing: str, entry: str) -> str:
|
||||
``## ``) is preserved: *entry* is prepended unchanged.
|
||||
"""
|
||||
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):
|
||||
# Legacy layout: file starts with `## [version]` and has no H1.
|
||||
@@ -43,7 +49,7 @@ def _insert_after_h1(existing: str, entry: str) -> str:
|
||||
if existing.endswith("\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.
|
||||
head = existing[: h2_match.start()].rstrip("\n") + "\n\n"
|
||||
@@ -63,7 +69,7 @@ def update_changelog(
|
||||
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() # noqa: DTZ011 - changelog dates are local calendar dates
|
||||
today = datetime.now().astimezone().date().isoformat()
|
||||
|
||||
def _entry_for(raw: str) -> tuple[str, str]:
|
||||
body = transform_changelog_message(raw).strip() or f"Release {new_version}"
|
||||
@@ -75,30 +81,10 @@ def update_changelog(
|
||||
print(f" - {finding}")
|
||||
print()
|
||||
|
||||
if message is not None:
|
||||
body, entry = _entry_for(message)
|
||||
findings = lint_changelog_entry(changelog_path, entry)
|
||||
if findings:
|
||||
_print_findings(findings)
|
||||
raise ChangelogLintError(
|
||||
"Provided changelog message is not markdown-lint clean."
|
||||
)
|
||||
elif preview or not sys.stdin.isatty():
|
||||
body, entry = _entry_for(message or f"Release {new_version}")
|
||||
else:
|
||||
attempt: str | None = None
|
||||
while True:
|
||||
print(
|
||||
"\n[INFO] Provide the changelog entry — a leading '#' becomes "
|
||||
"bold, `code` becomes italic.\n"
|
||||
)
|
||||
raw = _open_editor_for_changelog(attempt)
|
||||
body, entry = _entry_for(raw or f"Release {new_version}")
|
||||
findings = lint_changelog_entry(changelog_path, entry)
|
||||
if not findings:
|
||||
break
|
||||
_print_findings(findings)
|
||||
attempt = body
|
||||
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...")
|
||||
|
||||
changelog = ""
|
||||
@@ -109,6 +95,39 @@ def update_changelog(
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
print(f"[WARN] Could not read existing CHANGELOG.md: {exc}")
|
||||
|
||||
def _findings_for(entry: str) -> list[str]:
|
||||
return lint_changelog_entry(changelog_path, entry) or lint_changelog_context(
|
||||
changelog_path, changelog, _insert_after_h1(changelog, entry)
|
||||
)
|
||||
|
||||
if message is not None:
|
||||
body, entry = _entry_for(message)
|
||||
findings = _findings_for(entry)
|
||||
if findings:
|
||||
_print_findings(findings)
|
||||
raise ChangelogLintError(
|
||||
"Provided changelog message is not markdown-lint clean."
|
||||
)
|
||||
elif preview or not sys.stdin.isatty():
|
||||
body, entry = _entry_for(message or f"Release {new_version}")
|
||||
else:
|
||||
attempt: str | None = None
|
||||
rejected: list[str] | None = None
|
||||
while True:
|
||||
print(
|
||||
"\n[INFO] Provide the changelog entry - a leading '#' becomes "
|
||||
"a sub-heading, `code` becomes italic.\n"
|
||||
)
|
||||
raw = _open_editor_for_changelog(attempt, rejected)
|
||||
body, entry = _entry_for(raw or f"Release {new_version}")
|
||||
findings = _findings_for(entry)
|
||||
if not findings:
|
||||
break
|
||||
_print_findings(findings)
|
||||
attempt = body
|
||||
rejected = findings
|
||||
_wait_for_reader()
|
||||
|
||||
new_changelog = _insert_after_h1(changelog, entry)
|
||||
|
||||
print("\n================ CHANGELOG ENTRY ================")
|
||||
|
||||
@@ -6,7 +6,14 @@ import subprocess
|
||||
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")
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
@@ -18,9 +25,15 @@ def _open_editor_for_changelog(initial_message: str | None = None) -> str:
|
||||
tmp.write(
|
||||
"; Write the changelog entry for this release.\n"
|
||||
"; Lines starting with ';' are ignored.\n"
|
||||
"; A leading '#' becomes bold; `code` becomes italic.\n"
|
||||
"; Empty result will fall back to a generic message.\n\n"
|
||||
"; A leading '#' becomes a sub-heading; `code` becomes italic.\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:
|
||||
tmp.write(initial_message.strip() + "\n")
|
||||
tmp.flush()
|
||||
|
||||
@@ -72,6 +72,8 @@ class TestDebianControlInstaller(unittest.TestCase):
|
||||
return "/usr/bin/dpkg-buildpackage"
|
||||
if name == "apt-get":
|
||||
return "/usr/bin/apt-get"
|
||||
if name == "sudo":
|
||||
return "/usr/bin/sudo"
|
||||
return None
|
||||
|
||||
mock_which.side_effect = which_side_effect
|
||||
|
||||
@@ -81,6 +81,19 @@ class TestMakefileInstaller(unittest.TestCase):
|
||||
)
|
||||
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__":
|
||||
unittest.main()
|
||||
@@ -1,23 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from pkgmgr.actions.release.files import changelog_lint
|
||||
from pkgmgr.actions.release.files.changelog_lint import (
|
||||
lint_changelog_context,
|
||||
lint_changelog_entry,
|
||||
transform_changelog_message,
|
||||
)
|
||||
from pkgmgr.actions.release.files.changelog_md import _insert_after_h1
|
||||
|
||||
_OLD_ASTERISK_FILE = "# Changelog\n\n## [1.0.0] - 2026-01-01\n\n* old bullet\n"
|
||||
|
||||
|
||||
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")
|
||||
self.assertIn("**Title**", out)
|
||||
self.assertNotIn("# Title", out)
|
||||
self.assertIn("### Title", out)
|
||||
self.assertNotIn("**Title**", out)
|
||||
|
||||
def test_multi_hash_heading_becomes_bold(self) -> None:
|
||||
self.assertEqual(
|
||||
transform_changelog_message("### Sub heading"), "**Sub heading**"
|
||||
)
|
||||
def test_a_heading_already_deep_enough_keeps_its_level(self) -> None:
|
||||
self.assertEqual(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:
|
||||
out = transform_changelog_message("use `pkgmgr release` now")
|
||||
@@ -27,18 +35,52 @@ class TestTransformChangelogMessage(unittest.TestCase):
|
||||
def test_plain_message_is_unchanged(self) -> None:
|
||||
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`")
|
||||
self.assertNotIn("`", out)
|
||||
for line in out.split("\n"):
|
||||
self.assertFalse(line.lstrip().startswith("#"))
|
||||
if line.lstrip().startswith("#"):
|
||||
self.assertTrue(line.startswith("###"), line)
|
||||
|
||||
|
||||
class TestLintChangelogEntry(unittest.TestCase):
|
||||
def test_clean_entry_has_no_findings(self) -> None:
|
||||
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), [])
|
||||
|
||||
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), [])
|
||||
|
||||
def test_the_builtin_check_refuses_a_non_dash_list_marker(self) -> None:
|
||||
entry = "## [1.0.0] - 2026-01-01\n\n* a bullet\n\n"
|
||||
with patch.object(changelog_lint.shutil, "which", return_value=None):
|
||||
findings = lint_changelog_entry("CHANGELOG.md", entry)
|
||||
self.assertTrue(any("MD004" in finding for finding in findings), findings)
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("markdownlint-cli2"), "markdownlint-cli2 is not installed")
|
||||
class TestLintChangelogContext(unittest.TestCase):
|
||||
def test_a_marker_that_breaks_the_files_consistency_is_reported(self) -> None:
|
||||
entry = "## [1.1.0] - 2026-02-01\n\n- new bullet\n\n"
|
||||
merged = _insert_after_h1(_OLD_ASTERISK_FILE, entry)
|
||||
findings = lint_changelog_context("CHANGELOG.md", _OLD_ASTERISK_FILE, merged)
|
||||
self.assertTrue(any("MD004" in finding for finding in findings), findings)
|
||||
|
||||
def test_a_finding_the_file_already_has_is_not_blamed_on_the_entry(self) -> None:
|
||||
existing = "# Changelog\n\n## [1.0.0] - 2026-01-01\n\n* old bullet \n"
|
||||
entry = "## [1.1.0] - 2026-02-01\n\n* new bullet\n\n"
|
||||
merged = _insert_after_h1(existing, entry)
|
||||
self.assertEqual(lint_changelog_context("CHANGELOG.md", existing, merged), [])
|
||||
|
||||
def test_a_consistent_entry_has_no_findings(self) -> None:
|
||||
entry = "## [1.1.0] - 2026-02-01\n\n* new bullet\n\n"
|
||||
merged = _insert_after_h1(_OLD_ASTERISK_FILE, entry)
|
||||
self.assertEqual(lint_changelog_context("CHANGELOG.md", _OLD_ASTERISK_FILE, merged), [])
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -313,13 +313,13 @@ class TestUpdateChangelog(unittest.TestCase):
|
||||
def test_update_changelog_does_not_inject_a_leading_bullet(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
path = os.path.join(tmpdir, "CHANGELOG.md")
|
||||
update_changelog(path, "1.2.3", message="* Provided bullet", preview=False)
|
||||
update_changelog(path, "1.2.3", message="- Provided bullet", preview=False)
|
||||
|
||||
with open(path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
self.assertIn("\n\n* Provided bullet\n", content)
|
||||
self.assertNotIn("* * Provided bullet", content)
|
||||
self.assertIn("\n\n- Provided bullet\n", content)
|
||||
self.assertNotIn("- - Provided bullet", content)
|
||||
|
||||
def test_update_changelog_transforms_heading_and_inline_code(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -327,16 +327,17 @@ class TestUpdateChangelog(unittest.TestCase):
|
||||
update_changelog(
|
||||
path,
|
||||
"1.2.3",
|
||||
message="# Summary\n\n* uses `foo` tool",
|
||||
message="# Summary\n\n- uses `foo` tool",
|
||||
preview=False,
|
||||
)
|
||||
|
||||
with open(path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
self.assertIn("**Summary**", content)
|
||||
self.assertIn("### Summary", content)
|
||||
self.assertIn("*foo*", content)
|
||||
self.assertNotIn("# Summary", content)
|
||||
self.assertNotIn("\n# Summary", content)
|
||||
self.assertNotIn("\n## Summary", content)
|
||||
self.assertNotIn("`foo`", content)
|
||||
|
||||
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())
|
||||
|
||||
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:
|
||||
root = Path(td)
|
||||
pkg_root = root / "site-packages" / "pkgmgr"
|
||||
pkg_root.mkdir(parents=True)
|
||||
|
||||
# only project_root config exists
|
||||
(pkg_root.parent / "config").mkdir(parents=True)
|
||||
|
||||
fake_pkgmgr = types.SimpleNamespace(__file__=str(pkg_root / "__init__.py"))
|
||||
with patch.dict(sys.modules, {"pkgmgr": fake_pkgmgr}):
|
||||
found = config_cmd._find_defaults_source_dir()
|
||||
|
||||
self.assertEqual(
|
||||
Path(found).resolve(), (pkg_root.parent / "config").resolve()
|
||||
)
|
||||
self.assertIsNone(found)
|
||||
|
||||
def test_returns_none_when_no_config_dirs_exist(self):
|
||||
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 / "b.yml").is_file())
|
||||
self.assertFalse(
|
||||
(dest_cfg_dir / "config.yaml")
|
||||
.read_text(encoding="utf-8")
|
||||
.startswith("should_not_copy")
|
||||
(dest_cfg_dir / "config.yaml").exists(),
|
||||
"the source config.yaml must not be copied over the user's own",
|
||||
)
|
||||
|
||||
# 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
|
||||
root = Path(td) / "site-packages"
|
||||
pkg_root = root / "pkgmgr"
|
||||
cfg_dir = (
|
||||
root / "config"
|
||||
) # NOTE: load.py checks multiple roots, including pkg_root.parent (=site-packages)
|
||||
cfg_dir = pkg_root / "config"
|
||||
pkg_root.mkdir(parents=True)
|
||||
cfg_dir.mkdir(parents=True)
|
||||
|
||||
|
||||
Reference in new issue
Block a user