Compare commits
24
Commits
ec7b959893
...
v2.0.2
No files matched your search
@@ -0,0 +1,23 @@
|
|||||||
|
version: 2
|
||||||
|
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "pip"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
time: "00:00"
|
||||||
|
rebase-strategy: "auto"
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
|
- "python"
|
||||||
|
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
day: "monday"
|
||||||
|
time: "00:00"
|
||||||
|
labels:
|
||||||
|
- "dependencies"
|
||||||
|
- "ci"
|
||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -24,10 +24,8 @@ jobs:
|
|||||||
- name: Show Docker version
|
- name: Show Docker version
|
||||||
run: docker version
|
run: docker version
|
||||||
|
|
||||||
- name: Build virgin container (${{ matrix.distro }})
|
- name: Pull base image (${{ matrix.distro }})
|
||||||
run: |
|
run: docker pull "ghcr.io/kevinveenbirkenbach/base-${{ matrix.distro }}:latest"
|
||||||
set -euo pipefail
|
|
||||||
PKGMGR_DISTRO="${{ matrix.distro }}" make build-missing-virgin
|
|
||||||
|
|
||||||
- name: Virgin ${{ matrix.distro }} pkgmgr test (root)
|
- name: Virgin ${{ matrix.distro }} pkgmgr test (root)
|
||||||
run: |
|
run: |
|
||||||
@@ -39,7 +37,7 @@ jobs:
|
|||||||
-v pkgmgr_pip_cache:/root/.cache/pip \
|
-v pkgmgr_pip_cache:/root/.cache/pip \
|
||||||
-e NIX_CONFIG="${NIX_CONFIG}" \
|
-e NIX_CONFIG="${NIX_CONFIG}" \
|
||||||
-w /opt/src/pkgmgr \
|
-w /opt/src/pkgmgr \
|
||||||
"pkgmgr-${{ matrix.distro }}-virgin" \
|
"ghcr.io/kevinveenbirkenbach/base-${{ matrix.distro }}:latest" \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,8 @@ jobs:
|
|||||||
- name: Show Docker version
|
- name: Show Docker version
|
||||||
run: docker version
|
run: docker version
|
||||||
|
|
||||||
- name: Build virgin container (${{ matrix.distro }})
|
- name: Pull base image (${{ matrix.distro }})
|
||||||
run: |
|
run: docker pull "ghcr.io/kevinveenbirkenbach/base-${{ matrix.distro }}:latest"
|
||||||
set -euo pipefail
|
|
||||||
PKGMGR_DISTRO="${{ matrix.distro }}" make build-missing-virgin
|
|
||||||
|
|
||||||
- name: Virgin ${{ matrix.distro }} pkgmgr test (user)
|
- name: Virgin ${{ matrix.distro }} pkgmgr test (user)
|
||||||
run: |
|
run: |
|
||||||
@@ -37,7 +35,7 @@ jobs:
|
|||||||
-v "$PWD":/opt/src/pkgmgr \
|
-v "$PWD":/opt/src/pkgmgr \
|
||||||
-e NIX_CONFIG="${NIX_CONFIG}" \
|
-e NIX_CONFIG="${NIX_CONFIG}" \
|
||||||
-w /opt/src/pkgmgr \
|
-w /opt/src/pkgmgr \
|
||||||
"pkgmgr-${{ matrix.distro }}-virgin" \
|
"ghcr.io/kevinveenbirkenbach/base-${{ matrix.distro }}:latest" \
|
||||||
bash -lc '
|
bash -lc '
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
name: Update flake.lock
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lock:
|
||||||
|
name: Update flake.lock
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Nix
|
||||||
|
uses: DeterminateSystems/nix-installer-action@v23
|
||||||
|
|
||||||
|
- name: Update flake.lock and open a pull request
|
||||||
|
uses: DeterminateSystems/update-flake-lock@v29
|
||||||
|
with:
|
||||||
|
pr-title: "chore(deps): update flake.lock"
|
||||||
|
pr-labels: |
|
||||||
|
dependencies
|
||||||
|
nix
|
||||||
@@ -1,5 +1,85 @@
|
|||||||
# 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
|
||||||
|
|
||||||
|
Breaking
|
||||||
|
|
||||||
|
* The *virgin* and *slim* image variants are gone; both tag suffixes with them.
|
||||||
|
* Building an image now requires the base images to exist in the registry.
|
||||||
|
* *make build-missing-virgin* is removed; *build* targets build the one image.
|
||||||
|
* The five *BASE_IMAGE_** overrides are gone; *BASE_IMAGES_TAG* replaces them.
|
||||||
|
* *scripts/build/image.sh* loses its *--target* axis.
|
||||||
|
* A repository is installed by one hook, no longer by pip and *make* both.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
|
||||||
|
* *INSTALLERS* is a preference order led by *make install*, first success wins.
|
||||||
|
* A failed hook falls back to the next; all failing exits with what was tried.
|
||||||
|
* The *Dockerfile* builds *FROM ${BASE_IMAGE}*, resolved by *base.sh*.
|
||||||
|
* Publishing pushes *--platform* manifest lists for amd64 and arm64.
|
||||||
|
* *manjaro* can be built; *os_resolver.sh* already maps it onto arch.
|
||||||
|
* The virgin workflows pull the base image instead of building it.
|
||||||
|
* The Nix dev shell moves to Python 3.12 for *portfolio*'s *>=3.12* floor.
|
||||||
|
* It gains *pkg-config*, *openldap* and *cyrus_sasl* for *python-ldap*.
|
||||||
|
* *requires-python* for pkgmgr itself is unchanged at *>=3.9*.
|
||||||
|
* The e2e sweep drops *--silent*, which hid the non-zero exit, not the output.
|
||||||
|
* The README drops the *Virgin* type and the Nix claim the image never met.
|
||||||
|
* It now lists the tags *compute_publish_tags()* pushes, alias included.
|
||||||
|
* Dependabot covers pip and github-actions weekly on Monday.
|
||||||
|
* A new *update-flake-lock* workflow opens a PR for *flake.lock* the same day.
|
||||||
|
* *test_distro_dependency_scripts_install_gpg_tools* moves to base-images.
|
||||||
|
* *scripts/docker/slim.sh* is deleted with the stage that ran it.
|
||||||
|
|
||||||
|
## [1.16.1] - 2026-07-29
|
||||||
|
|
||||||
|
* *pkgmgr repos create* now creates *main*-based repositories: *git init* is run with an explicit *-b main* instead of inheriting the host's *init.defaultBranch*, and a failed initial push no longer renames the branch back to *master*. *resolve_base_branch()* keeps its main → master fallback for existing repositories.
|
||||||
|
* Failures are no longer swallowed: 46 blind *except Exception* handlers were narrowed to the exception types the guarded code can actually raise (*OSError*, *UnicodeDecodeError*, TOML/YAML decode errors, *ValueError*, *ImportError*, *subprocess.SubprocessError*, *GitRunError*, *PackageNotFoundError*). Three handlers stay open by design — the per-repository loops in *install* and *update* are batch boundaries where one failing repository must not abort the run.
|
||||||
|
* Keyring backend errors now surface as the concrete *KeyringOperationError*, raised at the boundary in *KeyringTokenProvider.get/set* with the original exception preserved as *__cause__*, and kept distinct from *KeyringUnavailableError* ("no usable backend" vs. "backend present, call failed"). Both degradation paths are unchanged.
|
||||||
|
* The *code-scanning* report stamps *generated_at* and its fallback output directory in UTC instead of a naive local timestamp.
|
||||||
|
* New *make lint* target runs *ruff* on *src* and *tests* with the same invocation as the *lint-python* workflow, and *make test* now depends on it. Repository-wide formatting and typing modernisation (PEP 604 unions with *from __future__ import annotations*, builtin generics, shebangs stripped from 72 importable modules) — *ruff check src tests* passes.
|
||||||
|
* New *tests/integration/test_error_path_degradation.py* drives 44 of the narrowed handlers into their except branch with real injected faults and asserts the documented degradation.
|
||||||
|
|
||||||
## [1.16.0] - 2026-06-28
|
## [1.16.0] - 2026-06-28
|
||||||
|
|
||||||
* New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
|
* New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
|
||||||
|
|||||||
+5
-47
@@ -1,67 +1,25 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# hadolint ignore=DL3006,InvalidDefaultArgInFrom
|
||||||
# Base image selector — overridden by build args / Makefile
|
# BASE_IMAGE carries no default on purpose: a default would build one
|
||||||
# ------------------------------------------------------------
|
# distribution's image under another distribution's tag whenever the build arg
|
||||||
|
# is forgotten. scripts/build/base.sh is the only place that resolves it.
|
||||||
ARG BASE_IMAGE
|
ARG BASE_IMAGE
|
||||||
|
FROM ${BASE_IMAGE}
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Target: virgin
|
|
||||||
# - installs distro deps (incl. make)
|
|
||||||
# - no pkgmgr build
|
|
||||||
# - no entrypoint
|
|
||||||
# ============================================================
|
|
||||||
FROM ${BASE_IMAGE} AS virgin
|
|
||||||
SHELL ["/bin/bash", "-lc"]
|
SHELL ["/bin/bash", "-lc"]
|
||||||
|
|
||||||
RUN echo "BASE_IMAGE=${BASE_IMAGE}" && cat /etc/os-release || true
|
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
|
|
||||||
# Copy scripts first so dependency installation can be cached
|
|
||||||
COPY scripts/installation/ scripts/installation/
|
|
||||||
|
|
||||||
# Install distro-specific build dependencies (including make)
|
|
||||||
RUN bash scripts/installation/dependencies.sh
|
|
||||||
|
|
||||||
# Virgin default
|
|
||||||
CMD ["bash"]
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Target: full
|
|
||||||
# - inherits from virgin
|
|
||||||
# - builds + installs pkgmgr
|
|
||||||
# - sets entrypoint + default cmd
|
|
||||||
# - NOTE: does NOT run slim.sh (that is done in slim stage)
|
|
||||||
# ============================================================
|
|
||||||
FROM virgin AS full
|
|
||||||
|
|
||||||
WORKDIR /build
|
|
||||||
|
|
||||||
# Copy full repository for build
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build and install distro-native package-manager package
|
|
||||||
RUN set -eu; \
|
RUN set -eu; \
|
||||||
echo "Building and installing package-manager via make install..."; \
|
echo "Building and installing package-manager via make install..."; \
|
||||||
make install; \
|
make install; \
|
||||||
rm -rf /build
|
rm -rf /build
|
||||||
|
|
||||||
# Entry point
|
|
||||||
COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh
|
COPY scripts/docker/entry.sh /usr/local/bin/docker-entry.sh
|
||||||
|
|
||||||
WORKDIR /opt/src/pkgmgr
|
WORKDIR /opt/src/pkgmgr
|
||||||
ENTRYPOINT ["/usr/local/bin/docker-entry.sh"]
|
ENTRYPOINT ["/usr/local/bin/docker-entry.sh"]
|
||||||
CMD ["pkgmgr", "--help"]
|
CMD ["pkgmgr", "--help"]
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# Target: slim
|
|
||||||
# - based on full
|
|
||||||
# - runs slim.sh
|
|
||||||
# ============================================================
|
|
||||||
FROM full AS slim
|
|
||||||
|
|
||||||
COPY scripts/docker/slim.sh /usr/local/bin/slim.sh
|
|
||||||
RUN chmod +x /usr/local/bin/slim.sh && /usr/local/bin/slim.sh
|
|
||||||
@@ -17,20 +17,9 @@ export NIX_CONFIG
|
|||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# Base images
|
# Base images
|
||||||
# (kept for documentation/reference; actual build logic is in scripts/build)
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
BASE_IMAGE_ARCH := archlinux:latest
|
BASE_IMAGES_TAG ?= latest
|
||||||
BASE_IMAGE_DEBIAN := debian:stable-slim
|
export BASE_IMAGES_TAG
|
||||||
BASE_IMAGE_UBUNTU := ubuntu:latest
|
|
||||||
BASE_IMAGE_FEDORA := fedora:latest
|
|
||||||
BASE_IMAGE_CENTOS := quay.io/centos/centos:stream9
|
|
||||||
|
|
||||||
# Make them available in scripts
|
|
||||||
export BASE_IMAGE_ARCH
|
|
||||||
export BASE_IMAGE_DEBIAN
|
|
||||||
export BASE_IMAGE_UBUNTU
|
|
||||||
export BASE_IMAGE_FEDORA
|
|
||||||
export BASE_IMAGE_CENTOS
|
|
||||||
|
|
||||||
# PYthon Unittest Pattern
|
# PYthon Unittest Pattern
|
||||||
TEST_PATTERN := test_*.py
|
TEST_PATTERN := test_*.py
|
||||||
@@ -63,17 +52,12 @@ setup-nix:
|
|||||||
# Docker build targets (delegated to scripts/build)
|
# Docker build targets (delegated to scripts/build)
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
build:
|
build:
|
||||||
@bash scripts/build/image.sh --target virgin
|
|
||||||
@bash scripts/build/image.sh
|
@bash scripts/build/image.sh
|
||||||
|
|
||||||
build-missing-virgin:
|
build-missing:
|
||||||
@bash scripts/build/image.sh --target virgin --missing
|
|
||||||
|
|
||||||
build-missing: build-missing-virgin
|
|
||||||
@bash scripts/build/image.sh --missing
|
@bash scripts/build/image.sh --missing
|
||||||
|
|
||||||
build-no-cache:
|
build-no-cache:
|
||||||
@bash scripts/build/image.sh --target virgin --no-cache
|
|
||||||
@bash scripts/build/image.sh --no-cache
|
@bash scripts/build/image.sh --no-cache
|
||||||
|
|
||||||
build-no-cache-all:
|
build-no-cache-all:
|
||||||
|
|||||||
@@ -169,26 +169,31 @@ nix run github:kevinveenbirkenbach/package-manager#pkgmgr -- --help
|
|||||||
|
|
||||||
PKGMGR can be executed **inside Docker containers** for CI, testing and isolated
|
PKGMGR can be executed **inside Docker containers** for CI, testing and isolated
|
||||||
workflows.
|
workflows.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### Container types
|
#### Container images
|
||||||
|
|
||||||
Two container types are available.
|
One image per distribution, each installing PKGMGR as a distro-native package
|
||||||
|
on top of the matching base image from
|
||||||
|
[base-images](https://github.com/kevinveenbirkenbach/base-images). Every image
|
||||||
|
is a manifest list covering `linux/amd64` and `linux/arm64`.
|
||||||
|
|
||||||
|
All images live under `ghcr.io/kevinveenbirkenbach/`:
|
||||||
|
|
||||||
| Image type | Contains | Typical use |
|
| Tag | Contains |
|
||||||
| ---------- | ----------------------------- | ----------------------- |
|
| --------------------------- | ---------------------------------- |
|
||||||
| **Virgin** | Base OS + system dependencies | Clean test environments |
|
| `pkgmgr-<distro>:latest` | newest build for that distribution |
|
||||||
| **Stable** | PKGMGR + Nix (flakes enabled) | Ready-to-use workflows |
|
| `pkgmgr-<distro>:<version>` | one release |
|
||||||
|
| `pkgmgr-<distro>:stable` | newest release marked stable |
|
||||||
|
|
||||||
Example images:
|
`<distro>` is one of `arch`, `debian`, `ubuntu`, `fedora`, `centos`. The Arch
|
||||||
|
image is published without the suffix as well, so `pkgmgr:stable` and
|
||||||
|
`pkgmgr-arch:stable` are the same image.
|
||||||
|
|
||||||
* Virgin: `pkgmgr-arch-virgin`
|
For a clean environment that carries the system dependencies but no PKGMGR,
|
||||||
* Stable: `ghcr.io/kevinveenbirkenbach/pkgmgr:stable`
|
pull `base-<distro>` from
|
||||||
|
[base-images](https://github.com/kevinveenbirkenbach/base-images) directly.
|
||||||
|
|
||||||
Use **virgin images** for isolated test runs,
|
|
||||||
use the **stable image** for fast, reproducible execution.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -26,13 +26,13 @@
|
|||||||
packages = forAllSystems (system:
|
packages = forAllSystems (system:
|
||||||
let
|
let
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
pkgs = nixpkgs.legacyPackages.${system};
|
||||||
python = pkgs.python311;
|
python = pkgs.python312;
|
||||||
pyPkgs = pkgs.python311Packages;
|
pyPkgs = pkgs.python312Packages;
|
||||||
in
|
in
|
||||||
rec {
|
rec {
|
||||||
pkgmgr = pyPkgs.buildPythonApplication {
|
pkgmgr = pyPkgs.buildPythonApplication {
|
||||||
pname = "package-manager";
|
pname = "package-manager";
|
||||||
version = "1.16.0";
|
version = "2.0.2";
|
||||||
|
|
||||||
# Use the git repo as source
|
# Use the git repo as source
|
||||||
src = ./.;
|
src = ./.;
|
||||||
@@ -79,8 +79,8 @@
|
|||||||
if pkgs ? ansible-core then pkgs.ansible-core
|
if pkgs ? ansible-core then pkgs.ansible-core
|
||||||
else pkgs.ansible;
|
else pkgs.ansible;
|
||||||
|
|
||||||
# Use the same Python version as the package (3.11)
|
# Use the same Python version as the package
|
||||||
python = pkgs.python311;
|
python = pkgs.python312;
|
||||||
|
|
||||||
pythonWithDeps = python.withPackages (ps: [
|
pythonWithDeps = python.withPackages (ps: [
|
||||||
ps.pip
|
ps.pip
|
||||||
@@ -95,6 +95,9 @@
|
|||||||
pkgs.git
|
pkgs.git
|
||||||
pkgs.gnupg
|
pkgs.gnupg
|
||||||
ansiblePkg
|
ansiblePkg
|
||||||
|
pkgs.pkg-config
|
||||||
|
pkgs.openldap
|
||||||
|
pkgs.cyrus_sasl
|
||||||
];
|
];
|
||||||
|
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
|
|||||||
@@ -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=1.16.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,91 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
* The *virgin* and *slim* image variants are gone; both tag suffixes with them.
|
||||||
|
* Building an image now requires the base images to exist in the registry.
|
||||||
|
* *make build-missing-virgin* is removed; *build* targets build the one image.
|
||||||
|
* The five *BASE_IMAGE_** overrides are gone; *BASE_IMAGES_TAG* replaces them.
|
||||||
|
* *scripts/build/image.sh* loses its *--target* axis.
|
||||||
|
* A repository is installed by one hook, no longer by pip and *make* both.
|
||||||
|
|
||||||
|
Changed
|
||||||
|
|
||||||
|
* *INSTALLERS* is a preference order led by *make install*, first success wins.
|
||||||
|
* A failed hook falls back to the next; all failing exits with what was tried.
|
||||||
|
* The *Dockerfile* builds *FROM ${BASE_IMAGE}*, resolved by *base.sh*.
|
||||||
|
* Publishing pushes *--platform* manifest lists for amd64 and arm64.
|
||||||
|
* *manjaro* can be built; *os_resolver.sh* already maps it onto arch.
|
||||||
|
* The virgin workflows pull the base image instead of building it.
|
||||||
|
* The Nix dev shell moves to Python 3.12 for *portfolio*'s *>=3.12* floor.
|
||||||
|
* It gains *pkg-config*, *openldap* and *cyrus_sasl* for *python-ldap*.
|
||||||
|
* *requires-python* for pkgmgr itself is unchanged at *>=3.9*.
|
||||||
|
* The e2e sweep drops *--silent*, which hid the non-zero exit, not the output.
|
||||||
|
* The README drops the *Virgin* type and the Nix claim the image never met.
|
||||||
|
* It now lists the tags *compute_publish_tags()* pushes, alias included.
|
||||||
|
* Dependabot covers pip and github-actions weekly on Monday.
|
||||||
|
* A new *update-flake-lock* workflow opens a PR for *flake.lock* the same day.
|
||||||
|
* *test_distro_dependency_scripts_install_gpg_tools* moves to base-images.
|
||||||
|
* *scripts/docker/slim.sh* is deleted with the stage that ran it.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Fri, 18 Sep 2026 16:29:32 +0200
|
||||||
|
|
||||||
|
package-manager (1.16.1-1) unstable; urgency=medium
|
||||||
|
|
||||||
|
* *pkgmgr repos create* now creates *main*-based repositories: *git init* is run with an explicit *-b main* instead of inheriting the host's *init.defaultBranch*, and a failed initial push no longer renames the branch back to *master*. *resolve_base_branch()* keeps its main → master fallback for existing repositories.
|
||||||
|
* Failures are no longer swallowed: 46 blind *except Exception* handlers were narrowed to the exception types the guarded code can actually raise (*OSError*, *UnicodeDecodeError*, TOML/YAML decode errors, *ValueError*, *ImportError*, *subprocess.SubprocessError*, *GitRunError*, *PackageNotFoundError*). Three handlers stay open by design — the per-repository loops in *install* and *update* are batch boundaries where one failing repository must not abort the run.
|
||||||
|
* Keyring backend errors now surface as the concrete *KeyringOperationError*, raised at the boundary in *KeyringTokenProvider.get/set* with the original exception preserved as *__cause__*, and kept distinct from *KeyringUnavailableError* ("no usable backend" vs. "backend present, call failed"). Both degradation paths are unchanged.
|
||||||
|
* The *code-scanning* report stamps *generated_at* and its fallback output directory in UTC instead of a naive local timestamp.
|
||||||
|
* New *make lint* target runs *ruff* on *src* and *tests* with the same invocation as the *lint-python* workflow, and *make test* now depends on it. Repository-wide formatting and typing modernisation (PEP 604 unions with *from __future__ import annotations*, builtin generics, shebangs stripped from 72 importable modules) — *ruff check src tests* passes.
|
||||||
|
* New *tests/integration/test_error_path_degradation.py* drives 44 of the narrowed handlers into their except branch with real injected faults and asserts the documented degradation.
|
||||||
|
|
||||||
|
-- Kevin Veen-Birkenbach <kevin@veen.world> Wed, 29 Jul 2026 16:41:21 +0200
|
||||||
|
|
||||||
package-manager (1.16.0-1) unstable; urgency=medium
|
package-manager (1.16.0-1) unstable; urgency=medium
|
||||||
|
|
||||||
* New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
|
* New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
Name: package-manager
|
Name: package-manager
|
||||||
Version: 1.16.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,82 @@ 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
|
||||||
|
- Breaking
|
||||||
|
|
||||||
|
- * The *virgin* and *slim* image variants are gone; both tag suffixes with them.
|
||||||
|
- * Building an image now requires the base images to exist in the registry.
|
||||||
|
- * *make build-missing-virgin* is removed; *build* targets build the one image.
|
||||||
|
- * The five *BASE_IMAGE_** overrides are gone; *BASE_IMAGES_TAG* replaces them.
|
||||||
|
- * *scripts/build/image.sh* loses its *--target* axis.
|
||||||
|
- * A repository is installed by one hook, no longer by pip and *make* both.
|
||||||
|
|
||||||
|
- Changed
|
||||||
|
|
||||||
|
- * *INSTALLERS* is a preference order led by *make install*, first success wins.
|
||||||
|
- * A failed hook falls back to the next; all failing exits with what was tried.
|
||||||
|
- * The *Dockerfile* builds *FROM ${BASE_IMAGE}*, resolved by *base.sh*.
|
||||||
|
- * Publishing pushes *--platform* manifest lists for amd64 and arm64.
|
||||||
|
- * *manjaro* can be built; *os_resolver.sh* already maps it onto arch.
|
||||||
|
- * The virgin workflows pull the base image instead of building it.
|
||||||
|
- * The Nix dev shell moves to Python 3.12 for *portfolio*'s *>=3.12* floor.
|
||||||
|
- * It gains *pkg-config*, *openldap* and *cyrus_sasl* for *python-ldap*.
|
||||||
|
- * *requires-python* for pkgmgr itself is unchanged at *>=3.9*.
|
||||||
|
- * The e2e sweep drops *--silent*, which hid the non-zero exit, not the output.
|
||||||
|
- * The README drops the *Virgin* type and the Nix claim the image never met.
|
||||||
|
- * It now lists the tags *compute_publish_tags()* pushes, alias included.
|
||||||
|
- * Dependabot covers pip and github-actions weekly on Monday.
|
||||||
|
- * A new *update-flake-lock* workflow opens a PR for *flake.lock* the same day.
|
||||||
|
- * *test_distro_dependency_scripts_install_gpg_tools* moves to base-images.
|
||||||
|
- * *scripts/docker/slim.sh* is deleted with the stage that ran it.
|
||||||
|
|
||||||
|
* Wed Jul 29 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.16.1-1
|
||||||
|
- * *pkgmgr repos create* now creates *main*-based repositories: *git init* is run with an explicit *-b main* instead of inheriting the host's *init.defaultBranch*, and a failed initial push no longer renames the branch back to *master*. *resolve_base_branch()* keeps its main → master fallback for existing repositories.
|
||||||
|
- * Failures are no longer swallowed: 46 blind *except Exception* handlers were narrowed to the exception types the guarded code can actually raise (*OSError*, *UnicodeDecodeError*, TOML/YAML decode errors, *ValueError*, *ImportError*, *subprocess.SubprocessError*, *GitRunError*, *PackageNotFoundError*). Three handlers stay open by design — the per-repository loops in *install* and *update* are batch boundaries where one failing repository must not abort the run.
|
||||||
|
- * Keyring backend errors now surface as the concrete *KeyringOperationError*, raised at the boundary in *KeyringTokenProvider.get/set* with the original exception preserved as *__cause__*, and kept distinct from *KeyringUnavailableError* ("no usable backend" vs. "backend present, call failed"). Both degradation paths are unchanged.
|
||||||
|
- * The *code-scanning* report stamps *generated_at* and its fallback output directory in UTC instead of a naive local timestamp.
|
||||||
|
- * New *make lint* target runs *ruff* on *src* and *tests* with the same invocation as the *lint-python* workflow, and *make test* now depends on it. Repository-wide formatting and typing modernisation (PEP 604 unions with *from __future__ import annotations*, builtin generics, shebangs stripped from 72 importable modules) — *ruff check src tests* passes.
|
||||||
|
- * New *tests/integration/test_error_path_degradation.py* drives 44 of the narrowed handlers into their except branch with real injected faults and asserts the documented degradation.
|
||||||
|
|
||||||
* Sun Jun 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.16.0-1
|
* Sun Jun 28 2026 Kevin Veen-Birkenbach <kevin@veen.world> - 1.16.0-1
|
||||||
- * New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
|
- * New *code-scanning* command: *pkgmgr code-scanning* downloads a repository's GitHub code scanning alerts and analysis metadata via the *gh* CLI into a timestamped directory (default */tmp/<repo>/code-scanner/<timestamp>*), together with a readable summary, for offline analysis.
|
||||||
- * The release flow now lints and normalises the changelog message before writing it: a leading heading becomes bold, inline code becomes italic, and the entry is validated against the repository's markdown rules, re-prompting interactively until it is clean.
|
- * The release flow now lints and normalises the changelog message before writing it: a leading heading becomes bold, inline code becomes italic, and the entry is validated against the repository's markdown rules, re-prompting interactively until it is clean.
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "kpmx"
|
name = "kpmx"
|
||||||
version = "1.16.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"
|
||||||
|
|||||||
+22
-10
@@ -1,20 +1,32 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
: "${BASE_IMAGE_ARCH:=archlinux:latest}"
|
# The distribution bases this repository builds on. They are published by
|
||||||
: "${BASE_IMAGE_DEBIAN:=debian:stable-slim}"
|
# https://github.com/kevinveenbirkenbach/base-images, one multi-platform image
|
||||||
: "${BASE_IMAGE_UBUNTU:=ubuntu:latest}"
|
# per distribution, and carry the build dependencies the package build needs.
|
||||||
: "${BASE_IMAGE_FEDORA:=fedora:latest}"
|
#
|
||||||
: "${BASE_IMAGE_CENTOS:=quay.io/centos/centos:latest}"
|
# Env overrides exist for testing an unpublished base; the registry namespace
|
||||||
|
# follows the images repository, not this one.
|
||||||
|
|
||||||
|
: "${BASE_IMAGES_REGISTRY:=ghcr.io}"
|
||||||
|
: "${BASE_IMAGES_OWNER:=kevinveenbirkenbach}"
|
||||||
|
: "${BASE_IMAGES_TAG:=latest}"
|
||||||
|
|
||||||
resolve_base_image() {
|
resolve_base_image() {
|
||||||
local PKGMGR_DISTRO="$1"
|
local PKGMGR_DISTRO="$1"
|
||||||
case "$PKGMGR_DISTRO" in
|
case "$PKGMGR_DISTRO" in
|
||||||
arch) echo "$BASE_IMAGE_ARCH" ;;
|
arch|manjaro|debian|ubuntu|fedora|centos)
|
||||||
debian) echo "$BASE_IMAGE_DEBIAN" ;;
|
echo "${BASE_IMAGES_REGISTRY}/${BASE_IMAGES_OWNER}/base-${PKGMGR_DISTRO}:${BASE_IMAGES_TAG}"
|
||||||
ubuntu) echo "$BASE_IMAGE_UBUNTU" ;;
|
;;
|
||||||
fedora) echo "$BASE_IMAGE_FEDORA" ;;
|
*) echo "ERROR: Unknown distro '$PKGMGR_DISTRO'" >&2; exit 1 ;;
|
||||||
centos) echo "$BASE_IMAGE_CENTOS" ;;
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Platforms each distribution's base offers; see base-images/scripts/build/distros.sh.
|
||||||
|
resolve_platforms() {
|
||||||
|
local PKGMGR_DISTRO="$1"
|
||||||
|
case "$PKGMGR_DISTRO" in
|
||||||
|
arch|manjaro|debian|ubuntu|fedora|centos) echo "linux/amd64,linux/arm64" ;;
|
||||||
*) echo "ERROR: Unknown distro '$PKGMGR_DISTRO'" >&2; exit 1 ;;
|
*) echo "ERROR: Unknown distro '$PKGMGR_DISTRO'" >&2; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
+3
-26
@@ -10,7 +10,6 @@ source "${SCRIPT_DIR}/base.sh"
|
|||||||
|
|
||||||
NO_CACHE=0
|
NO_CACHE=0
|
||||||
MISSING_ONLY=0
|
MISSING_ONLY=0
|
||||||
TARGET=""
|
|
||||||
IMAGE_TAG="" # local image name or base tag (without registry)
|
IMAGE_TAG="" # local image name or base tag (without registry)
|
||||||
PUSH=0 # if 1 -> use buildx and push (requires docker buildx)
|
PUSH=0 # if 1 -> use buildx and push (requires docker buildx)
|
||||||
PUBLISH=0 # if 1 -> push with semantic tags (latest/version/stable + arch aliases)
|
PUBLISH=0 # if 1 -> push with semantic tags (latest/version/stable + arch aliases)
|
||||||
@@ -23,9 +22,6 @@ DEFAULT_DISTRO="arch"
|
|||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
local default_tag="pkgmgr-${PKGMGR_DISTRO}"
|
local default_tag="pkgmgr-${PKGMGR_DISTRO}"
|
||||||
if [[ -n "${TARGET:-}" ]]; then
|
|
||||||
default_tag="${default_tag}-${TARGET}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Usage: PKGMGR_DISTRO=<distro> $0 [options]
|
Usage: PKGMGR_DISTRO=<distro> $0 [options]
|
||||||
@@ -33,7 +29,6 @@ Usage: PKGMGR_DISTRO=<distro> $0 [options]
|
|||||||
Build options:
|
Build options:
|
||||||
--missing Build only if the image does not already exist (local build only)
|
--missing Build only if the image does not already exist (local build only)
|
||||||
--no-cache Build with --no-cache
|
--no-cache Build with --no-cache
|
||||||
--target <name> Build a specific Dockerfile target (e.g. virgin, slim)
|
|
||||||
--tag <image> Override the output image tag (default: ${default_tag})
|
--tag <image> Override the output image tag (default: ${default_tag})
|
||||||
|
|
||||||
Publish options:
|
Publish options:
|
||||||
@@ -47,7 +42,7 @@ Publish options:
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- --publish implies --push and requires --registry, --owner, and --version.
|
- --publish implies --push and requires --registry, --owner, and --version.
|
||||||
- Local build (no --push) uses "docker build" and creates local images like "pkgmgr-arch" / "pkgmgr-arch-virgin" / "pkgmgr-arch-slim".
|
- Local build (no --push) uses "docker build" and creates a local image like "pkgmgr-arch".
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,11 +50,6 @@ while [[ $# -gt 0 ]]; do
|
|||||||
case "$1" in
|
case "$1" in
|
||||||
--no-cache) NO_CACHE=1; shift ;;
|
--no-cache) NO_CACHE=1; shift ;;
|
||||||
--missing) MISSING_ONLY=1; shift ;;
|
--missing) MISSING_ONLY=1; shift ;;
|
||||||
--target)
|
|
||||||
TARGET="${2:-}"
|
|
||||||
[[ -n "${TARGET}" ]] || { echo "ERROR: --target requires a value (e.g. virgin|slim)"; exit 2; }
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--tag)
|
--tag)
|
||||||
IMAGE_TAG="${2:-}"
|
IMAGE_TAG="${2:-}"
|
||||||
[[ -n "${IMAGE_TAG}" ]] || { echo "ERROR: --tag requires a value"; exit 2; }
|
[[ -n "${IMAGE_TAG}" ]] || { echo "ERROR: --tag requires a value"; exit 2; }
|
||||||
@@ -104,9 +94,6 @@ done
|
|||||||
# Derive default local tag if not provided
|
# Derive default local tag if not provided
|
||||||
if [[ -z "${IMAGE_TAG}" ]]; then
|
if [[ -z "${IMAGE_TAG}" ]]; then
|
||||||
IMAGE_TAG="${REPO_PREFIX}-${PKGMGR_DISTRO}"
|
IMAGE_TAG="${REPO_PREFIX}-${PKGMGR_DISTRO}"
|
||||||
if [[ -n "${TARGET}" ]]; then
|
|
||||||
IMAGE_TAG="${IMAGE_TAG}-${TARGET}"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
BASE_IMAGE="$(resolve_base_image "$PKGMGR_DISTRO")"
|
BASE_IMAGE="$(resolve_base_image "$PKGMGR_DISTRO")"
|
||||||
@@ -143,7 +130,7 @@ echo "------------------------------------------------------------"
|
|||||||
echo "[build] Building image"
|
echo "[build] Building image"
|
||||||
echo "distro = ${PKGMGR_DISTRO}"
|
echo "distro = ${PKGMGR_DISTRO}"
|
||||||
echo "BASE_IMAGE = ${BASE_IMAGE}"
|
echo "BASE_IMAGE = ${BASE_IMAGE}"
|
||||||
if [[ -n "${TARGET}" ]]; then echo "target = ${TARGET}"; fi
|
echo "platforms = $(resolve_platforms "$PKGMGR_DISTRO")"
|
||||||
if [[ "${NO_CACHE}" == "1" ]]; then echo "cache = disabled"; fi
|
if [[ "${NO_CACHE}" == "1" ]]; then echo "cache = disabled"; fi
|
||||||
if [[ "${PUSH}" == "1" ]]; then echo "push = enabled"; fi
|
if [[ "${PUSH}" == "1" ]]; then echo "push = enabled"; fi
|
||||||
if [[ "${PUBLISH}" == "1" ]]; then
|
if [[ "${PUBLISH}" == "1" ]]; then
|
||||||
@@ -162,23 +149,12 @@ if [[ "${NO_CACHE}" == "1" ]]; then
|
|||||||
build_args+=(--no-cache)
|
build_args+=(--no-cache)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "${TARGET}" ]]; then
|
|
||||||
build_args+=(--target "${TARGET}")
|
|
||||||
fi
|
|
||||||
|
|
||||||
compute_publish_tags() {
|
compute_publish_tags() {
|
||||||
local distro_tag_base="${REGISTRY}/${OWNER}/${REPO_PREFIX}-${PKGMGR_DISTRO}"
|
local distro_tag_base="${REGISTRY}/${OWNER}/${REPO_PREFIX}-${PKGMGR_DISTRO}"
|
||||||
local alias_tag_base=""
|
local alias_tag_base=""
|
||||||
|
|
||||||
if [[ -n "${TARGET}" ]]; then
|
|
||||||
distro_tag_base="${distro_tag_base}-${TARGET}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "${PKGMGR_DISTRO}" == "${DEFAULT_DISTRO}" ]]; then
|
if [[ "${PKGMGR_DISTRO}" == "${DEFAULT_DISTRO}" ]]; then
|
||||||
alias_tag_base="${REGISTRY}/${OWNER}/${REPO_PREFIX}"
|
alias_tag_base="${REGISTRY}/${OWNER}/${REPO_PREFIX}"
|
||||||
if [[ -n "${TARGET}" ]]; then
|
|
||||||
alias_tag_base="${alias_tag_base}-${TARGET}"
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local tags=()
|
local tags=()
|
||||||
@@ -211,6 +187,7 @@ if [[ "${PUSH}" == "1" ]]; then
|
|||||||
bx_args+=(-t "${IMAGE_TAG}")
|
bx_args+=(-t "${IMAGE_TAG}")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
bx_args+=(--platform "$(resolve_platforms "$PKGMGR_DISTRO")")
|
||||||
bx_args+=("${build_args[@]}")
|
bx_args+=("${build_args[@]}")
|
||||||
bx_args+=(.)
|
bx_args+=(.)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Publish all distro images (full + virgin + slim) to a registry via image.sh --publish
|
# Publish one image per distro, each covering every platform its base offers.
|
||||||
#
|
#
|
||||||
# Required env:
|
# Required env:
|
||||||
# OWNER (e.g. GITHUB_REPOSITORY_OWNER)
|
# OWNER (e.g. GITHUB_REPOSITORY_OWNER)
|
||||||
@@ -11,9 +11,6 @@ set -euo pipefail
|
|||||||
# REGISTRY (default: ghcr.io)
|
# REGISTRY (default: ghcr.io)
|
||||||
# IS_STABLE (default: false)
|
# IS_STABLE (default: false)
|
||||||
# DISTROS (default: "arch debian ubuntu fedora centos")
|
# DISTROS (default: "arch debian ubuntu fedora centos")
|
||||||
#
|
|
||||||
# Notes:
|
|
||||||
# - This expects Dockerfile targets: virgin, full (default), slim
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
@@ -36,41 +33,12 @@ for d in ${DISTROS}; do
|
|||||||
echo "[publish] PKGMGR_DISTRO=${d}"
|
echo "[publish] PKGMGR_DISTRO=${d}"
|
||||||
echo "============================================================"
|
echo "============================================================"
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
# virgin
|
|
||||||
# -> ghcr.io/<owner>/pkgmgr-<distro>-virgin:{latest,<version>,stable?}
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
|
||||||
--publish \
|
|
||||||
--registry "${REGISTRY}" \
|
|
||||||
--owner "${OWNER}" \
|
|
||||||
--version "${VERSION}" \
|
|
||||||
--stable "${IS_STABLE}" \
|
|
||||||
--target virgin
|
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
# full (default target)
|
|
||||||
# -> ghcr.io/<owner>/pkgmgr-<distro>:{latest,<version>,stable?}
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
||||||
--publish \
|
--publish \
|
||||||
--registry "${REGISTRY}" \
|
--registry "${REGISTRY}" \
|
||||||
--owner "${OWNER}" \
|
--owner "${OWNER}" \
|
||||||
--version "${VERSION}" \
|
--version "${VERSION}" \
|
||||||
--stable "${IS_STABLE}"
|
--stable "${IS_STABLE}"
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
# slim
|
|
||||||
# -> ghcr.io/<owner>/pkgmgr-<distro>-slim:{latest,<version>,stable?}
|
|
||||||
# + alias for default distro: ghcr.io/<owner>/pkgmgr-slim:{...}
|
|
||||||
# ----------------------------------------------------------
|
|
||||||
PKGMGR_DISTRO="${d}" bash "${SCRIPT_DIR}/image.sh" \
|
|
||||||
--publish \
|
|
||||||
--registry "${REGISTRY}" \
|
|
||||||
--owner "${OWNER}" \
|
|
||||||
--version "${VERSION}" \
|
|
||||||
--stable "${IS_STABLE}" \
|
|
||||||
--target slim
|
|
||||||
done
|
done
|
||||||
|
|
||||||
echo
|
echo
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
log() { echo "[cleanup] $*"; }
|
|
||||||
warn() { echo "[cleanup][WARN] $*" >&2; }
|
|
||||||
|
|
||||||
MODE="${MODE:-safe}" # safe | aggressive
|
|
||||||
# safe: caches/logs/tmp only
|
|
||||||
# aggressive: safe + docs/man/info (optional)
|
|
||||||
|
|
||||||
ID="unknown"
|
|
||||||
if [ -f /etc/os-release ]; then
|
|
||||||
# shellcheck disable=SC1091
|
|
||||||
. /etc/os-release
|
|
||||||
ID="${ID:-unknown}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Starting image cleanup"
|
|
||||||
log "Mode: ${MODE}"
|
|
||||||
log "Detected OS: ${ID}"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Package manager caches (SAFE)
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
case "${ID}" in
|
|
||||||
alpine)
|
|
||||||
log "Cleaning apk cache"
|
|
||||||
if [ -d /var/cache/apk ]; then
|
|
||||||
du -sh /var/cache/apk || true
|
|
||||||
rm -rvf /var/cache/apk/* || true
|
|
||||||
else
|
|
||||||
log "apk cache directory not present (already clean)"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
arch)
|
|
||||||
log "Cleaning pacman cache"
|
|
||||||
du -sh /var/cache/pacman/pkg 2>/dev/null || true
|
|
||||||
pacman -Scc --noconfirm || true
|
|
||||||
rm -rvf /var/cache/pacman/pkg/* || true
|
|
||||||
;;
|
|
||||||
debian|ubuntu)
|
|
||||||
log "Cleaning apt cache"
|
|
||||||
du -sh /var/lib/apt/lists 2>/dev/null || true
|
|
||||||
apt-get clean || true
|
|
||||||
rm -rvf /var/lib/apt/lists/* || true
|
|
||||||
;;
|
|
||||||
fedora)
|
|
||||||
log "Cleaning dnf cache"
|
|
||||||
du -sh /var/cache/dnf 2>/dev/null || true
|
|
||||||
dnf clean all || true
|
|
||||||
rm -rvf /var/cache/dnf/* || true
|
|
||||||
;;
|
|
||||||
centos|rhel)
|
|
||||||
log "Cleaning yum/dnf cache"
|
|
||||||
du -sh /var/cache/yum /var/cache/dnf 2>/dev/null || true
|
|
||||||
(command -v dnf >/dev/null 2>&1 && dnf clean all) || true
|
|
||||||
(command -v yum >/dev/null 2>&1 && yum clean all) || true
|
|
||||||
rm -rvf /var/cache/yum/* /var/cache/dnf/* || true
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
warn "Unknown distro '${ID}' — skipping package manager cleanup"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Python caches (SAFE)
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
log "Cleaning pip cache"
|
|
||||||
du -sh /root/.cache/pip 2>/dev/null || true
|
|
||||||
rm -rvf /root/.cache/pip 2>/dev/null || true
|
|
||||||
rm -rvf /home/*/.cache/pip 2>/dev/null || true
|
|
||||||
|
|
||||||
log "Cleaning __pycache__ directories"
|
|
||||||
find /opt /usr /root /home -type d -name "__pycache__" -print -prune 2>/dev/null || true
|
|
||||||
find /opt /usr /root /home -type d -name "__pycache__" -prune -exec rm -rvf {} + 2>/dev/null || true
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Logs (SAFE)
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
log "Truncating log files (keeping paths intact)"
|
|
||||||
if [ -d /var/log ]; then
|
|
||||||
find /var/log -type f -name "*.log" -print 2>/dev/null || true
|
|
||||||
find /var/log -type f -name "*.log" -exec sh -lc ': > "$1" 2>/dev/null || true' _ {} \; 2>/dev/null || true
|
|
||||||
|
|
||||||
find /var/log -type f -name "*.out" -print 2>/dev/null || true
|
|
||||||
find /var/log -type f -name "*.out" -exec sh -lc ': > "$1" 2>/dev/null || true' _ {} \; 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if command -v journalctl >/dev/null 2>&1; then
|
|
||||||
log "Vacuuming journald logs"
|
|
||||||
journalctl --disk-usage || true
|
|
||||||
journalctl --vacuum-size=10M || true
|
|
||||||
journalctl --vacuum-time=1s || true
|
|
||||||
journalctl --disk-usage || true
|
|
||||||
else
|
|
||||||
log "journald not present (skipping)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Temporary files (SAFE)
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
log "Cleaning temporary directories"
|
|
||||||
if [ -d /tmp ]; then
|
|
||||||
du -sh /tmp 2>/dev/null || true
|
|
||||||
rm -rvf /tmp/* || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -d /var/tmp ]; then
|
|
||||||
du -sh /var/tmp 2>/dev/null || true
|
|
||||||
rm -rvf /var/tmp/* || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Generic caches (SAFE)
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
log "Cleaning generic caches"
|
|
||||||
du -sh /root/.cache 2>/dev/null || true
|
|
||||||
rm -rvf /root/.cache/* 2>/dev/null || true
|
|
||||||
rm -rvf /home/*/.cache/* 2>/dev/null || true
|
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
# Optional aggressive extras (still safe for runtime)
|
|
||||||
# ------------------------------------------------------------
|
|
||||||
if [[ "${MODE}" == "aggressive" ]]; then
|
|
||||||
log "Aggressive mode enabled: removing docs/man/info"
|
|
||||||
du -sh /usr/share/doc /usr/share/man /usr/share/info 2>/dev/null || true
|
|
||||||
rm -rvf /usr/share/doc/* /usr/share/man/* /usr/share/info/* 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "Cleanup finished successfully"
|
|
||||||
@@ -38,13 +38,14 @@ from pkgmgr.core.repository.verify import verify_repository
|
|||||||
Repository = dict[str, Any]
|
Repository = dict[str, Any]
|
||||||
|
|
||||||
INSTALLERS = [
|
INSTALLERS = [
|
||||||
|
MakefileInstaller(),
|
||||||
ArchPkgbuildInstaller(),
|
ArchPkgbuildInstaller(),
|
||||||
DebianControlInstaller(),
|
DebianControlInstaller(),
|
||||||
RpmSpecInstaller(),
|
RpmSpecInstaller(),
|
||||||
NixFlakeInstaller(),
|
NixFlakeInstaller(),
|
||||||
PythonInstaller(),
|
PythonInstaller(),
|
||||||
MakefileInstaller(),
|
|
||||||
]
|
]
|
||||||
|
"""Preference order: the pipeline runs the first supported hook and stops."""
|
||||||
|
|
||||||
|
|
||||||
def _ensure_repo_dir(
|
def _ensure_repo_dir(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -81,13 +81,15 @@ class InstallationPipeline:
|
|||||||
else:
|
else:
|
||||||
repo.pop("command", None)
|
repo.pop("command", None)
|
||||||
|
|
||||||
provided_capabilities: set[str] = set()
|
attempted: list[str] = []
|
||||||
|
|
||||||
for installer in self._installers:
|
for installer in self._installers:
|
||||||
layer_name = getattr(installer, "layer", None)
|
layer_name = getattr(installer, "layer", None)
|
||||||
|
|
||||||
if layer_name is None:
|
if layer_name is None:
|
||||||
self._run_installer(installer, ctx, identifier, repo_dir, quiet)
|
if self._run_installer(installer, ctx, identifier, repo_dir, quiet):
|
||||||
|
return
|
||||||
|
attempted.append(installer.__class__.__name__)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -120,15 +122,6 @@ class InstallationPipeline:
|
|||||||
if not installer.supports(ctx):
|
if not installer.supports(ctx):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
caps = installer.discover_capabilities(ctx)
|
|
||||||
if caps and caps.issubset(provided_capabilities):
|
|
||||||
if not quiet:
|
|
||||||
print(
|
|
||||||
f"Skipping installer {installer.__class__.__name__} "
|
|
||||||
f"for {identifier} – capabilities {caps} already provided."
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not quiet:
|
if not quiet:
|
||||||
if (
|
if (
|
||||||
ctx.force_update
|
ctx.force_update
|
||||||
@@ -142,13 +135,17 @@ class InstallationPipeline:
|
|||||||
else:
|
else:
|
||||||
print(
|
print(
|
||||||
f"[pkgmgr] Running installer {installer.__class__.__name__} "
|
f"[pkgmgr] Running installer {installer.__class__.__name__} "
|
||||||
f"for {identifier} in '{repo_dir}' "
|
f"for {identifier} in '{repo_dir}'..."
|
||||||
f"(new capabilities: {caps or set()})..."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self._run_installer(installer, ctx, identifier, repo_dir, quiet)
|
if not self._run_installer(installer, ctx, identifier, repo_dir, quiet):
|
||||||
|
attempted.append(installer.__class__.__name__)
|
||||||
provided_capabilities.update(caps)
|
if not quiet:
|
||||||
|
print(
|
||||||
|
f"[pkgmgr] {installer.__class__.__name__} installed nothing "
|
||||||
|
f"for {identifier}; falling back to the next hook."
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
new_state = resolver.resolve()
|
new_state = resolver.resolve()
|
||||||
if new_state.command:
|
if new_state.command:
|
||||||
@@ -165,6 +162,13 @@ class InstallationPipeline:
|
|||||||
repo.pop("command", None)
|
repo.pop("command", None)
|
||||||
|
|
||||||
state = new_state
|
state = new_state
|
||||||
|
return
|
||||||
|
|
||||||
|
if attempted:
|
||||||
|
raise SystemExit(
|
||||||
|
f"every installation hook failed for {identifier}: "
|
||||||
|
f"{', '.join(attempted)}"
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _run_installer(
|
def _run_installer(
|
||||||
@@ -173,9 +177,11 @@ class InstallationPipeline:
|
|||||||
identifier: str,
|
identifier: str,
|
||||||
repo_dir: str,
|
repo_dir: str,
|
||||||
quiet: bool,
|
quiet: bool,
|
||||||
) -> None:
|
) -> bool:
|
||||||
|
"""Run one hook. Returns whether it installed anything."""
|
||||||
try:
|
try:
|
||||||
installer.run(ctx)
|
installer.run(ctx)
|
||||||
|
return True
|
||||||
except SystemExit as exc:
|
except SystemExit as exc:
|
||||||
exit_code = exc.code if isinstance(exc.code, int) else str(exc.code)
|
exit_code = exc.code if isinstance(exc.code, int) else str(exc.code)
|
||||||
print(
|
print(
|
||||||
@@ -193,4 +199,4 @@ class InstallationPipeline:
|
|||||||
f" pkgmgr install {identifier} "
|
f" pkgmgr install {identifier} "
|
||||||
"--clone-mode shallow --no-verification"
|
"--clone-mode shallow --no-verification"
|
||||||
)
|
)
|
||||||
raise
|
return False
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -93,7 +93,6 @@ class TestIntegrationUpdateAllshallowNoSystem(unittest.TestCase):
|
|||||||
"--clone-mode",
|
"--clone-mode",
|
||||||
"shallow",
|
"shallow",
|
||||||
"--no-verification",
|
"--no-verification",
|
||||||
"--silent",
|
|
||||||
]
|
]
|
||||||
self._run_cmd(["pkgmgr", *args], label="pkgmgr", env=env)
|
self._run_cmd(["pkgmgr", *args], label="pkgmgr", env=env)
|
||||||
pkgmgr_help_debug()
|
pkgmgr_help_debug()
|
||||||
@@ -108,7 +107,6 @@ class TestIntegrationUpdateAllshallowNoSystem(unittest.TestCase):
|
|||||||
"--clone-mode",
|
"--clone-mode",
|
||||||
"shallow",
|
"shallow",
|
||||||
"--no-verification",
|
"--no-verification",
|
||||||
"--silent",
|
|
||||||
]
|
]
|
||||||
self._run_cmd(
|
self._run_cmd(
|
||||||
["nix", "run", ".#pkgmgr", "--", *args],
|
["nix", "run", ".#pkgmgr", "--", *args],
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -25,33 +24,6 @@ class TestGitVerificationRuntimeDependencies(unittest.TestCase):
|
|||||||
self.assertIn("pkgs.git", flake_text)
|
self.assertIn("pkgs.git", flake_text)
|
||||||
self.assertIn("pkgs.gnupg", flake_text)
|
self.assertIn("pkgs.gnupg", flake_text)
|
||||||
|
|
||||||
def test_distro_dependency_scripts_install_gpg_tools(self) -> None:
|
|
||||||
repo_root = _find_repo_root()
|
|
||||||
expected_packages = {
|
|
||||||
"arch": "gnupg",
|
|
||||||
"debian": "gnupg",
|
|
||||||
"ubuntu": "gnupg",
|
|
||||||
"fedora": "gnupg2",
|
|
||||||
"centos": "gnupg2",
|
|
||||||
}
|
|
||||||
|
|
||||||
missing: list[str] = []
|
|
||||||
for distro, package_name in expected_packages.items():
|
|
||||||
script_path = (
|
|
||||||
repo_root / "scripts" / "installation" / distro / "dependencies.sh"
|
|
||||||
)
|
|
||||||
content = script_path.read_text(encoding="utf-8")
|
|
||||||
if not re.search(rf"\b{re.escape(package_name)}\b", content):
|
|
||||||
missing.append(
|
|
||||||
f"{distro}: expected package {package_name} in {script_path}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if missing:
|
|
||||||
self.fail(
|
|
||||||
"Git signature verification runtime dependencies are incomplete:\n"
|
|
||||||
+ "\n".join(f" - {item}" for item in missing)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Integration tests for recursive capability resolution and installer shadowing.
|
Integration tests for installer selection across repository layouts.
|
||||||
|
|
||||||
These tests verify that, given different repository layouts (Makefile, pyproject,
|
|
||||||
flake.nix, PKGBUILD), only the expected installers are executed based on the
|
|
||||||
capabilities provided by higher layers.
|
|
||||||
|
|
||||||
Layer order (strongest → weakest):
|
Layer order (strongest → weakest):
|
||||||
|
|
||||||
@@ -129,10 +125,8 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
|||||||
"With only a Makefile, the MakefileInstaller should run exactly once.",
|
"With only a Makefile, the MakefileInstaller should run exactly once.",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_python_and_makefile_both_run_when_caps_disjoint(self) -> None:
|
def test_only_the_first_supported_hook_runs(self) -> None:
|
||||||
"""
|
"""A repository with two usable hooks is installed by the first one."""
|
||||||
If Python and Makefile have disjoint capabilities, both installers run.
|
|
||||||
"""
|
|
||||||
repo_dir = self._new_repo()
|
repo_dir = self._new_repo()
|
||||||
|
|
||||||
# pyproject.toml without any explicit "make install" hint
|
# pyproject.toml without any explicit "make install" hint
|
||||||
@@ -153,9 +147,8 @@ class TestRecursiveCapabilitiesIntegration(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
called,
|
called,
|
||||||
["python", "makefile"],
|
["python"],
|
||||||
"PythonInstaller and MakefileInstaller should both run when their "
|
"Only the first supported hook may install the repository.",
|
||||||
"capabilities are disjoint.",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_python_shadows_makefile_when_pyproject_mentions_make_install(self) -> None:
|
def test_python_shadows_makefile_when_pyproject_mentions_make_install(self) -> None:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -21,11 +21,13 @@ class DummyInstaller(BaseInstaller):
|
|||||||
layer: str | None = None,
|
layer: str | None = None,
|
||||||
supports_result: bool = True,
|
supports_result: bool = True,
|
||||||
capabilities: set[str] | None = None,
|
capabilities: set[str] | None = None,
|
||||||
|
fails: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._name = name
|
self._name = name
|
||||||
self.layer = layer # type: ignore[assignment]
|
self.layer = layer # type: ignore[assignment]
|
||||||
self._supports_result = supports_result
|
self._supports_result = supports_result
|
||||||
self._capabilities = capabilities or set()
|
self._capabilities = capabilities or set()
|
||||||
|
self._fails = fails
|
||||||
self.ran = False
|
self.ran = False
|
||||||
|
|
||||||
def supports(self, ctx: RepoContext) -> bool: # type: ignore[override]
|
def supports(self, ctx: RepoContext) -> bool: # type: ignore[override]
|
||||||
@@ -33,6 +35,8 @@ class DummyInstaller(BaseInstaller):
|
|||||||
|
|
||||||
def run(self, ctx: RepoContext) -> None: # type: ignore[override]
|
def run(self, ctx: RepoContext) -> None: # type: ignore[override]
|
||||||
self.ran = True
|
self.ran = True
|
||||||
|
if self._fails:
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
def discover_capabilities(self, ctx: RepoContext) -> set[str]: # type: ignore[override]
|
def discover_capabilities(self, ctx: RepoContext) -> set[str]: # type: ignore[override]
|
||||||
return set(self._capabilities)
|
return set(self._capabilities)
|
||||||
@@ -117,15 +121,12 @@ class TestInstallationPipeline(unittest.TestCase):
|
|||||||
|
|
||||||
@patch("pkgmgr.actions.install.pipeline.create_ink")
|
@patch("pkgmgr.actions.install.pipeline.create_ink")
|
||||||
@patch("pkgmgr.actions.install.pipeline.resolve_command_for_repo")
|
@patch("pkgmgr.actions.install.pipeline.resolve_command_for_repo")
|
||||||
def test_capabilities_prevent_duplicate_installers(
|
def test_only_one_installation_hook_runs(
|
||||||
self,
|
self,
|
||||||
mock_resolve_command_for_repo: MagicMock,
|
mock_resolve_command_for_repo: MagicMock,
|
||||||
mock_create_ink: MagicMock,
|
mock_create_ink: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""A repository is installed once, by the first hook that supports it."""
|
||||||
If one installer has already provided a set of capabilities,
|
|
||||||
a second installer advertising the same capabilities should be skipped.
|
|
||||||
"""
|
|
||||||
mock_resolve_command_for_repo.return_value = None # no CLI initially
|
mock_resolve_command_for_repo.return_value = None # no CLI initially
|
||||||
|
|
||||||
ctx = _minimal_context()
|
ctx = _minimal_context()
|
||||||
@@ -148,9 +149,103 @@ class TestInstallationPipeline(unittest.TestCase):
|
|||||||
self.assertTrue(first.ran, "First installer should run.")
|
self.assertTrue(first.ran, "First installer should run.")
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
second.ran,
|
second.ran,
|
||||||
"Second installer with identical capabilities must be skipped.",
|
"A second installation hook must not run after the first one did.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@patch("pkgmgr.actions.install.pipeline.create_ink")
|
||||||
|
@patch("pkgmgr.actions.install.pipeline.resolve_command_for_repo")
|
||||||
|
def test_a_differing_second_hook_is_not_run_either(
|
||||||
|
self,
|
||||||
|
mock_resolve_command_for_repo: MagicMock,
|
||||||
|
mock_create_ink: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""The rule is one hook, not one hook per capability."""
|
||||||
|
mock_resolve_command_for_repo.return_value = None
|
||||||
|
|
||||||
|
ctx = _minimal_context()
|
||||||
|
first = DummyInstaller(
|
||||||
|
"python-installer",
|
||||||
|
layer=CliLayer.PYTHON.value,
|
||||||
|
supports_result=True,
|
||||||
|
capabilities={"python-runtime"},
|
||||||
|
)
|
||||||
|
second = DummyInstaller(
|
||||||
|
"makefile-installer",
|
||||||
|
layer=CliLayer.MAKEFILE.value,
|
||||||
|
supports_result=True,
|
||||||
|
capabilities={"make-install"},
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline = InstallationPipeline([first, second])
|
||||||
|
pipeline.run(ctx)
|
||||||
|
|
||||||
|
self.assertTrue(first.ran, "The first supported hook should run.")
|
||||||
|
self.assertFalse(
|
||||||
|
second.ran,
|
||||||
|
"A hook advertising a different capability must not run either: "
|
||||||
|
"the project is already installed.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("pkgmgr.actions.install.pipeline.create_ink")
|
||||||
|
@patch("pkgmgr.actions.install.pipeline.resolve_command_for_repo")
|
||||||
|
def test_a_failed_hook_falls_back_to_the_next(
|
||||||
|
self,
|
||||||
|
mock_resolve_command_for_repo: MagicMock,
|
||||||
|
mock_create_ink: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""A hook that fails installed nothing, so the next one gets its turn."""
|
||||||
|
mock_resolve_command_for_repo.return_value = None
|
||||||
|
|
||||||
|
ctx = _minimal_context()
|
||||||
|
broken = DummyInstaller(
|
||||||
|
"makefile-installer",
|
||||||
|
layer=CliLayer.MAKEFILE.value,
|
||||||
|
supports_result=True,
|
||||||
|
fails=True,
|
||||||
|
)
|
||||||
|
working = DummyInstaller(
|
||||||
|
"python-installer",
|
||||||
|
layer=CliLayer.PYTHON.value,
|
||||||
|
supports_result=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline = InstallationPipeline([broken, working])
|
||||||
|
pipeline.run(ctx)
|
||||||
|
|
||||||
|
self.assertTrue(broken.ran, "The preferred hook should be tried first.")
|
||||||
|
self.assertTrue(
|
||||||
|
working.ran,
|
||||||
|
"The next hook must run when the preferred one installed nothing.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("pkgmgr.actions.install.pipeline.create_ink")
|
||||||
|
@patch("pkgmgr.actions.install.pipeline.resolve_command_for_repo")
|
||||||
|
def test_a_repository_whose_hooks_all_fail_is_reported(
|
||||||
|
self,
|
||||||
|
mock_resolve_command_for_repo: MagicMock,
|
||||||
|
mock_create_ink: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Falling back must not turn a broken repository into a silent pass."""
|
||||||
|
mock_resolve_command_for_repo.return_value = None
|
||||||
|
|
||||||
|
ctx = _minimal_context()
|
||||||
|
first = DummyInstaller(
|
||||||
|
"makefile-installer",
|
||||||
|
layer=CliLayer.MAKEFILE.value,
|
||||||
|
supports_result=True,
|
||||||
|
fails=True,
|
||||||
|
)
|
||||||
|
second = DummyInstaller(
|
||||||
|
"python-installer",
|
||||||
|
layer=CliLayer.PYTHON.value,
|
||||||
|
supports_result=True,
|
||||||
|
fails=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
pipeline = InstallationPipeline([first, second])
|
||||||
|
with self.assertRaises(SystemExit):
|
||||||
|
pipeline.run(ctx)
|
||||||
|
|
||||||
|
|
||||||
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()
|
||||||
@@ -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()
|
||||||
@@ -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