Skip to content

Changelog

Changelog

All notable changes to this project are documented here. The format is based on Keep a Changelog and this project adheres to Semantic Versioning.

Unreleased

0.3.0 - 2026-10-22

Upgrading from 0.2.0

Every entry under Removed is also a break; its replacement is named there. - Install PyYAML for a yaml DB: pip install 'pkgforge[yaml]'. - Re-create every 0.2.0 file DB: a record lacks kind, caps and package, so loading it fails. Run pkgforge initdb and stage again. - Call pkgforge.cli.main(argv) where you called pkgforge.main(argv), or use pkgforge.Project from a build script; import the entry functions from pkgforge.entry and the exceptions from pkgforge.exceptions. - Rename JsonlDb, YamlDb, SqliteDb, RpmSpecFiles, RpmSpecFilesPre419, Debian and pkgforge.dbdump.Entries to JSONLinesDb, YAMLDb, SQLiteDb, RPMSpecFiles, RPMSpecFilesPre419, DebianFiles and pkgforge.FileEntries, and lookup(name) to get(name). - Port a dump format of your own from dump(entries, output, logger) to render(entries, context), check_output(output) and write(rendered, output, *, force=False). - Rename a third-party backend or format whose NAME, alias or suffix clashes with another class's: the second one raises ValueError. - Rewrite an absolute install/scan -X pattern that names the install path (-X /opt/app/build/tmp) relative to the source (-X /tmp), and PathMatch(stmts, installroot=...) as PathMatch(stmts, root=...). - Replace dbdump --stdin with dbdump --db -, DUHO_TRACEBACK with PKGFORGE_TRACEBACK, and -f rpmspec (the %files lines) with -f rpmspecfiles or -f rpm: rpmspec is the spec format. - Pass --db-format for a file DB whose suffix does not name its format: its content is not sniffed. - Run pkgforge initdb before dbdump or compact on a DB that does not exist yet: both exit 2 for it. - Record owners and groups by account name, not number, and declare every account outside Debian's base-passwd set in [[users]] or [[groups]] of a pkgforge.cfg (create = false for one that exists on the target). - Replace -O rpmprefix=... with -k/--kind and --caps. - For dbdump -f debian, add a pkgforge.cfg with [package] name, delete the nameless install, dirs, permissions and fixperms files the dump wrote into debian/, and run sh debian/fixperms debian (the debian directory, not the package's) from override_dh_fixperms. - Remove any tail after -- from a command line (pkgforge initdb -- junk): it is now a usage error, exit 2, where it was ignored.

Added

  • pkgforge.cfg, a TOML project file read from --config/-c FILE (before the subcommand), else PKGFORGE_CONFIG (an empty value means no file), else ./pkgforge.cfg. db, db_format and buildroot in it are defaults below the command line and the environment, and relative paths are relative to the file. ${package.name} and any other key of the file can be reused in its text values ($${ is a literal ${). A YAML file's !include and !load are refused. The whole file is validated before any command runs: a problem is one pkgforge: error: line, status 2. pkgforge.config holds the schema (ProjectConfig and one dataclass per table) and load(path); Project(..., config=, config_path=), Project.config, Project.config_path and Project.from_config(path) use it.
  • [package] (name, version, release, arch, summary, license, homepage, maintainer, description, each checked when the file is read; name is a lowercase package name [a-z0-9][a-z0-9+.-]+) with [package.rpm] requires, [package.debian] depends, section and priority, [package.pacman] depends and [package.apk] depends, and [[packages]], the subpackages built from the same source.
  • [defaults] and [[rules]] fill the mode, owner, group, kind, caps and package an entry leaves at - when it is recorded: the first rule whose match (-X statements) fits the DB path, then [defaults] (dir_mode, exec_mode or file_mode by type and execute bit, owner, group). A flag wins. install without -m stages the file with the resolved mode, and install --record-tree (and record_tree = true in a [[stage]] step) chmods each child to its resolved mode, or records the child's mode from disk when no rule or default gives one.
  • own in pkgforge.cfg and --own PREFIX (repeatable) name the directories a package owns: recording an entry at or below one also records each missing ancestor directory down from the prefix. dbdump --dirs owned (Project.dump(dirs=), own= of pkgforge.dbdump.dump) keeps only the directories at or below an owned prefix, so a manifest claims no directory the distribution owns.
  • Entry kinds and file capabilities: an entry records a kind (file, config, config-noreplace, doc, license or ghost; unset is file) and caps (one setcap clause such as cap_net_bind_service=ep), set with install -k/--kind and --caps, mkdir -k, symlink -k, [[rules]] and [[stage]]. pkgforge.entry gains FileKind, normalize_kind and normalize_caps. rpmspecfiles starts a line with %config, %config(noreplace), %doc, %license or %ghost and writes %caps(CAPS); dbdump -f debian writes P.conffiles (config files outside /etc), not-installed (ghost files, so dh_missing --fail-missing accepts them), a setcap line per capability in P.postinst, and ${pkgforge:Depends} in P.substvars naming libcap2-bin. Ghost entries are left out of install, dirs and fixperms.
  • An entry records the package it ships in: -P/--package NAME on install, mkdir and symlink, package in [[rules]] and [[stage]], else the [package] name. A name the configuration does not declare exits 2 before anything is staged; pkgforge.entry.normalize_package checks one.
  • [[users]] and [[groups]] declare the accounts the package's files belong to (UserAccount, GroupAccount): name, and for a user description, home, shell and supplementary groups; create = false marks an account that exists on the target. pkgforge.entry.BASE_USERS and BASE_GROUPS are Debian's base-passwd names, which need no declaration.
  • pkgforge sysfiles (Project.sysfiles) writes and records /usr/lib/sysusers.d/NAME.conf for the accounts the package creates and /usr/lib/tmpfiles.d/NAME.conf for its ghost directories; stage runs it. rpm 4.19+ and debhelper act on the files when the package is installed, so a file owned by a service user is staged without that user existing at build time. dbdump -f debian leaves an entry owned by a created account out of fixperms: P.postinst applies it with a guarded dpkg-statoverride --update --add (an administrator's override is kept) and P.postrm removes it on purge. The Debian recipes need dh $@ --with installsysusers (debhelper 13.10 or later).
  • pkgforge stage [--init] (Project.stage) runs the [[stage]] steps in order: install a source at a destination, or record a directory, an empty file or a symlink. Every source is checked before the first step runs.
  • pkgforge mkdir [-p] DEST... and pkgforge symlink TARGET LINK (Project.mkdir, Project.symlink) record a directory or a symbolic link that has no source.
  • pkgforge verify [-X STMT]... [-f text|json] (Project.verify, returning VerifyIssue tuples) checks the build root against the file DB before rpmbuild or dh_missing runs: a file or symlink no entry records (unrecorded), an entry whose path is gone (missing, never for a ghost), a changed type (type) and a retargeted symlink (target). It exits 1 when it finds any, and 2 with no file DB or no build root.
  • dbdump -f rpmspec (RPMSpec) and rpmspec-pre419 (RPMSpecPre419, for rpm older than 4.19) write the whole spec from pkgforge.cfg: the preamble, a %package -n block per [[packages]] element, a %install that runs pkgforge stage --init and writes one %files list per package, the %post that creates the tmpfiles.d paths (and, below rpm 4.19, the %pre that creates the accounts), %systemd_post, %systemd_preun and %systemd_postun_with_restart for the systemd units a package records (*.service, *.socket, *.timer and *.path directly in /usr/lib/systemd/system; they expand to nothing without systemd-rpm-macros, EL8: systemd), and one %files -f per package. Build it with rpmbuild -bb --define "pkgforge_config PATH" NAME.spec. It needs [package] name, version, summary and license and a summary per subpackage, and exits 2 naming what is missing; a description line whose first non-blank character is % or # exits 2, since rpm would read it as a section, a conditional or a comment.
  • dbdump -f debian --skeleton (skeleton=True of Project.dump and pkgforge.dbdump.dump) also writes control, changelog, rules (a debian/tmp recipe that runs pkgforge stage and the dump) and source/format, with Build-Depends: debhelper-compat (= 13), dh-sequence-installsysusers and a stanza per package. It needs a directory OUTPUT, the path of pkgforge.cfg and [package] version, summary and maintainer; a ${NAME} in the summary, description, homepage or maintainer of a package exits 2, since dpkg-gencontrol would replace it and nothing escapes it.
  • A spec or skeleton file that exists is kept, with a WARNING, so edits survive regenerating; --force (force=True) replaces it. --force with a format that writes no such file, and --skeleton with a format that has no skeleton or without a directory OUTPUT, exit 2. DumpFormat.SKELETON and DumpFormat.KEEP_EXISTING mark such a format.
  • pkgforge package -f FORMAT [-o DIR] [--arch NAME] [--compression KIND] (Project.package, pkgforge.package.build) builds one package per declared package straight from the file DB and the build root, with the DB's modes and owner names, and prints each path:
  • deb: NAME_VERSION[-RELEASE]_ARCH.deb, without dpkg-deb, debhelper or fakeroot, with the control file, conffiles, md5sums and the postinst, prerm and postrm scripts debhelper would build from the same pkgforge.cfg (sysusers, tmpfiles, the systemd units, dpkg-statoverride for an account created at install time, setcap); --compression xz (default), gz, zst or none. A compressed member over 9999999999 bytes, more than an ar header holds, exits 1.
  • rpm: NAME-VERSION-RELEASE.ARCH.rpm through rpmbuild, from the spec dbdump -f rpmspec writes (rpmspec-pre419 below rpm 4.19); --arch is passed as --target, and without the systemd rpm macros it logs a warning and leaves the unit scriptlets out.
  • arch: NAME-VERSION-PKGREL-ARCH.pkg.tar.zst (or .xz, .gz), without makepkg or fakeroot. pacman ignores the owner names of a tar, so every header is root:root and .INSTALL creates the accounts and applies each other owner, group, setuid mode and capability.
  • apk: NAME-VERSION-rREL.apk (apk v2, unsigned: apk add --allow-untrusted), without abuild; its scripts create the accounts and ghost directories and set capabilities.
  • tar: NAME-VERSION[-RELEASE].tar[.gz|.xz|.zst], --compression gz, xz, zst or none.

A recorded path that is missing or of another type, or whose parent is a symlink leading outside the build root, exits 2 before anything is written. The files of a hardlinked set ship as one file and hardlinks to it. Payloads are streamed, so memory does not grow with them (about 100 MiB of compressor state for xz or zstd). A build is all or nothing: files are written beside their final names and renamed into place once every package is built, and a failure (Ctrl-C included) removes what the build created. With SOURCE_DATE_EPOCH set, two deb, arch, apk or tar builds of one build root are byte-identical. A format is a PackageFormat subclass, selected by NAME or advertised in the pkgforge.package_formats entry-point group, that writes through BuildContext.writer, a PackageWriter. - A zstd extra (pip install 'pkgforge[zstd]') provides zstd compression below Python 3.14, which has compression.zstd. - pkgforge.Project, the Python API for build scripts: Project(buildroot, db=None, *, db_format=None, config=None, config_path=None) with init, load, entries, compact, install, scan, mkdir, symlink, stage, sysfiles, dump, verify and package. It does what the commands do, takes and returns plain Python values, prints nothing and returns the records it wrote (with no file DB, those are the only result). install(type=) takes a FileType and exclude= statement text. pkgforge.FileRecords and pkgforge.StrPath are its types. - pkgforge.dbdump.dump(db, fmt, output=None, *, exclude=(), own=None, config=None, config_path=None, skeleton=False, force=False) renders an already-loaded DB; pkgforge.dbdump.Rendered is the result type and pkgforge.dbdump.DumpContext the context a format renders with. - pkgforge.db.jsonl_line(path, entry) and pkgforge.db.parse_jsonl(text, source="text") write and read the JSON Lines records the jsonl backend stores. - dbdump --db - reads the file DB as JSON Lines from standard input, so pkgforge install --db - ... | pkgforge dbdump --db - -f rpm works. Stdin must be a pipe, a socket or a regular file: a terminal, a closed stdin or a device such as /dev/null exits 2 instead of dumping an empty manifest. compact and scan --drop-stale refuse --db - (exit 2). - Third-party DB backends, dump formats and package formats are found through the entry-point groups pkgforge.dbs, pkgforge.dump_formats and pkgforge.package_formats (name = module:Class): installing the package is enough for --db-format NAME, a --db suffix, dbdump -f NAME and package -f NAME to select it. names() includes advertised names without loading them. - pkgforge.testing: check_db_provider(cls, directory) and check_dump_format(cls, directory) run the checks every DB backend and dump format must pass, so a third-party class is tested against the same contract as the built-in ones. DB_PROVIDER_CHECKS and DUMP_FORMAT_CHECKS hold the individual checks. - --debug prints the traceback of an error before its one-line pkgforge: error: message, like PKGFORGE_TRACEBACK. - The example examples/pkgforge.cfg declares two packages, hello and hello-doc; examples/hello.spec and examples/debian/ are generated from it. A guide page, "From pkgforge.cfg to packages", builds them with rpmbuild, dpkg-buildpackage and pkgforge package, and tabulates what each format needs, writes and runs at install time. - --mode, --owner, --group, --type and --dir-mode take the next word as their value even when it is --, so -o -- t /l reads the owner from disk like --owner=--. - The agent help (AGENT_HELP=1 pkgforge --help) lists the exit codes 0, 1, 2 and 130, and a failed MCP tool call reports its code, so verify with differences ends as exit code: 1.

Changed

  • Requires duho>=0.7.8,<0.8 (was >=0.6.0,<0.7) and anycfg>=0.15.1,<0.16, which reads pkgforge.cfg and is imported only when a file is read. PyYAML is the optional yaml extra instead of a core dependency: pip install 'pkgforge[yaml]' to keep a yaml DB, which otherwise exits 1 with a message naming the extra. The command line keeps its flags, its --help text and its 0, 1 and 2 exit statuses.
  • Ctrl-C ends the command with status 130 and no output, or raises KeyboardInterrupt under PKGFORGE_TRACEBACK.
  • PKGFORGE_TRACEBACK replaces DUHO_TRACEBACK as the switch that prints the traceback before the pkgforge: error: line, and DUHO_TRACEBACK has no effect on pkgforge: set PKGFORGE_TRACEBACK=1 where you set DUHO_TRACEBACK=1. A closed output pipe is silent with status 1 either way.
  • A bad -O value reports duho's usage message, which still reads expected KEY=VALUE, got 'novalue'.
  • The console script is pkgforge.cli:main (was pkgforge:main); python -m pkgforge runs the same. pkgforge.cli is the command line, not library API: from Python, use pkgforge.Project.
  • The package root exports the library names only: Project, VerifyIssue, FileEntry, FileType, FileEntries, FileRecords, StrPath, DbProvider, open_db, DumpFormat, PackageFormat, the exceptions and __version__. Import apply_entry, entry_from_path, normalize_mode and resolve_entry from pkgforge.entry. import pkgforge loads neither duho nor the command line.
  • Every exception class is in pkgforge.exceptions (and pkgforge), not in pkgforge.db, pkgforge.dbdump or pkgforge.exclude. ExcludeSyntaxError is a UsageError only (it was also an argparse.ArgumentTypeError).
  • Classes with an acronym in the name spell it in capitals: JsonlDb is JSONLinesDb, YamlDb YAMLDb, SqliteDb SQLiteDb, RpmSpecFiles RPMSpecFiles, RpmSpecFilesPre419 RPMSpecFilesPre419, and the debian format class Debian is DebianFiles. Format and DB names, aliases and suffixes are the same. pkgforge.dbdump.Entries is pkgforge.FileEntries.
  • PathMatchStmt subclasses argparse.Namespace (was duho.NS), and PathMatch takes PathMatch(stmts, root=None): the installroot argument is gone, and a path outside root raises ValueError.
  • On install and scan, an absolute -X pattern is relative to the input, like tar --exclude, where 0.2.0 matched it against the install path: install -X /tmp ./build /opt/app excludes ./build/tmp, install -X '/docs/**' app-1.0.tgz /opt excludes the archive's docs/ members, and scan -X /cache /usr/share/app excludes /usr/share/app/cache. A pattern that carries the destination (-X /opt/app/build/tmp) matches nothing: drop that prefix. A pattern without a leading / matches at any depth, and dbdump matches the DB paths, as in 0.2.0.
  • -X rejects a path in microseconds for glob patterns that took seconds (eight ** segments, or many * in one segment), does not warn about &&, ~~ or || inside a [...] class, and matches faster over a large DB in dbdump.
  • A dump format implements render(entries, context), check_output(output) and write(rendered, output, *, force=False), where 0.2.0 had DumpFormat.dump(entries, output, logger) and render(entries). context is a pkgforge.dbdump.DumpContext holding the configuration, its path and the OUTPUT, and - (standard output) is handled by the dbdump command. A format of your own moves its dump code to write, takes the context argument, accepts force and drops its - case; PerEntryFormat groups the entries per package and its check_output accepts a directory.
  • A DB backend or dump format whose NAME, alias or suffix another class holds raises ValueError when the class is created and is not registered, where it replaced the earlier class (for a suffix, the newer class won): rename the new one. DbProvider.compact() drops removed paths, and the contract suite fails a third-party compact that keeps them.
  • A file DB record has three more fields, kind, caps and package, and the sqlite table their columns. A 0.2.0 DB fails to load with missing field kind; this DB was written by an older pkgforge: run initdb and record again: run pkgforge initdb and stage again.
  • Every DB loader (jsonl, yaml, sqlite) enforces one strict record schema and fails with one pkgforge: error: <file>: <path>: ... line, status 1, where 0.2.0 coerced or defaulted: a number for a mode, owner or group, a missing field, a null type and an unknown field are refused. Re-record the DB.
  • The file DB format is --db-format, else the --db suffix, else jsonl; 0.2.0 also read an unrecognized text file as YAML. A file whose content is not in that format fails to load with an error that names the format and suggests --db-format: pass --db-format for a file with another suffix.
  • dbdump and compact exit 2 when no DB is configured or --db names a file that does not exist (no file DB at PATH; run pkgforge initdb), where they logged a WARNING and exited 0. An existing empty DB exits 0 with a WARNING. Run pkgforge initdb first, or pass the right path.
  • dbdump refuses a DB entry that is not well formed, with one pkgforge: error: line naming the entry, status 2, and writes nothing: a key that is not an absolute path with no ./../empty segment, a type other than file, directory or symlink, a mode that is not 1-4 octal digits, - or --, an owner or group that is not an account name, - or --, and a kind, caps or package value that is not valid. Fix or re-record the entry.
  • install and scan refuse an --owner/--group that is not an account name ([A-Za-z_][A-Za-z0-9_.-]*, - or --), with status 2 before anything is staged or recorded: a numeric owner such as 1000 is refused, so use the account name.
  • An owner or group outside Debian's base-passwd set must be declared in [[users]] or [[groups]] of pkgforge.cfg, or recording it is exit 2; 0.2.0 recorded any name. The check covers -o/-g, [defaults], [[rules]], [[stage]] and the names that -- reads from disk, so scan --owner=-- over files owned by an ordinary account needs that account declared (create = false).
  • install and scan with no --db (or --db -) print their JSON Lines records once the operation has succeeded, instead of one by one as they are written.
  • rpmspec is the name of the spec format, where 0.2.0 took it as an alias of rpmspecfiles: use -f rpmspecfiles (or -f rpm) for the %files lines. A per-entry format (rpmspecfiles, rpmspecfiles-pre419) writes the entries of one package into a file or -, and refuses a DB holding entries of two packages (exit 2); given an existing directory it writes files-PACKAGE.txt for every package of pkgforge.cfg.
  • dbdump -f debian writes one set of files per package, named for it, where 0.2.0 wrote a nameless install, dirs, permissions and fixperms: PKG.install, PKG.dirs, PKG.conffiles, PKG.postinst, PKG.postrm and PKG.substvars for [package] and each [[packages]] name, and once not-installed and fixperms. It needs [package] name in pkgforge.cfg (exit 2 without it). Delete the nameless files the dump wrote into debian/: debhelper reads debian/PKG.NAME first and ignores the nameless file beside it, and compat 15 refuses it for a source with several binary packages. fixperms takes the debian directory, not the package's: run sh debian/fixperms debian.
  • dbdump -f debian refuses, before it writes anything (exit 2), to replace a hand-written debhelper file: an existing install, dirs, not-installed, postinst, postrm or fixperms without the line # Generated by pkgforge dbdump -f debian, a nameless install, dirs, conffiles, postinst or postrm that debhelper would ignore, and a PKG.conffiles listing a path the dump would drop. Move such lines into pkgforge.cfg or the package-named file and delete the stray one.

Removed

  • pkgforge.main, pkgforge.command and the pkgforge.install, pkgforge.scan, pkgforge.initdb and pkgforge.compact modules, with PkgForgeCmd, PkgForge, FileEntryArgs, FileEntryBaseArgs, entry_from_args, parsepath, ExcludeArgs and pkgforge.dbdump.DbDump. The commands are in pkgforge.cli (not library API) and the operations are Project methods: replace pkgforge.main(argv) with pkgforge.cli.main(argv), or call Project from a build script.
  • pkgforge.errors: import the exceptions from pkgforge.exceptions or pkgforge.
  • The pkgforge.install.archive, decompress, staging, transfer, record and _tool modules and the Install staging methods (_resolve, _stage and the like); staging is Project.install.
  • The backend submodules pkgforge.db.jsonl, pkgforge.db.yaml, pkgforge.db.sqlite, pkgforge.dbdump.rpm and pkgforge.dbdump.debian: import the classes from pkgforge.db and pkgforge.dbdump.
  • DbProvider.lookup(name) and DumpFormat.lookup(name): call get(name).
  • DumpFormat.dump(entries, output, logger): a format implements render, check_output and write (see Changed).
  • The FileEntry.from_args, from_path, resolve_for and apply class aliases: call entry_from_path, resolve_entry and apply_entry.
  • The List[str] form of normalize_mode and normalize_name, and an int mode in normalize_mode: both take a str, and an int raises UsageError.
  • FileType._AUTO: use the AUTO string. list(FileType) is file, directory, symlink.
  • PathTest.GENERATORS, PathTest.factory, FilterTestRe, filetypetest and metatest in pkgforge.exclude, and NAME_PATTERN, MODE_PATTERN, CONTROL_PATTERN and RPM_EXPANSION_PATTERN in pkgforge.entry.
  • PathMatch.unreachable(), log_unreachable() and the warning that an absolute -X pattern could match no install path.
  • The RPMSpecFiles._quote and RPMSpecFilesPre419._quote class hooks, which render never called.
  • The sniff_format function, the DbProvider.sniff hook and the for_read argument of open_db: open_db(path, fmt=None) resolves by format name or suffix only (see Changed).
  • dbdump --stdin: use --db -.
  • scan --type/-t, which was hidden and ignored: scan exits 2 for it.
  • The --decompress tool-name aliases gzip, gunzip, xz, unxz, bzip2, bunzip2, zstd, unzstd, lzma and unlzma: a kind is exactly gz, xz, bz2, zst or lzma, in any case.
  • The archive destination-name fallback that cut a name at its last tar or iso segment; .tar.zst, .tzst, .tar.lz4, .tar.lzma, .tlz, .tar.lz and .cpio are listed suffixes.
  • The permissions artifact of dbdump -f debian (and its section in dbdump -f debian -): nothing consumed it, and fixperms applies the same pins.
  • meta.rpmprefix: a -O rpmprefix=... or a meta argument holding the key is refused (exit 2). Use -k/--kind and --caps, or kind and caps in pkgforge.cfg.
  • The import-time binding of PKGFORGE_DB, PKGFORGE_DB_FORMAT, PKGFORGE_ROOT, PKGFORGE_INSTALL_METHOD and PKGFORGE_INSTALL_RECORD_TREE as class defaults: pkgforge.cli.main() reads them when it parses, and a command class built directly in Python ignores the environment.

Fixed

  • On Windows, a drive-qualified path (mkdir D:/x) raised ValueError: Paths don't have the same drive. It is a usage error, exit 2, saying the path resolves outside --buildroot.
  • install --method move of a directory onto another filesystem failed with Invalid cross-device link (and a rollback could fail the same way). It moves the tree file by file, keeping modes, mtimes and symlinks, and removes the source.
  • install --remove-source --method link kept a file source and removed a directory source. Both are removed; the staged hardlinks keep the data. The source is kept only when it is the destination itself.
  • A failed install -p left the parent directories it had created. It removes them before exiting with status 1; directories that already existed are never touched.
  • An Install(source=["/x"], destination="/usr/bin", ...)() command built from Python with str paths raised AttributeError. str and path-like values are accepted, and anything else raises UsageError.
  • scan --drop-stale into a sqlite DB committed once per removed row; it holds one connection for all its removals.
  • A %(command) or a newline in a recorded owner, group or mode reached rpmbuild's %files list, where it ran a command or injected a line; dbdump refuses the entry (exit 2).
  • A DB key such as /../../x, or a relative one, reached the debian fixperms script, where it chmod-ed or chown-ed a path outside the package directory; dbdump refuses it (exit 2).
  • An entry with no type (or -, or an unknown value) rendered as a plain %files path, which made rpm ship the whole subtree; dbdump refuses it.
  • A corrupt or truncated .tar, .tar.gz, .tar.xz or .tar.bz2 archive ended in a traceback. It is one pkgforge: error: cannot extract ... line, status 1.
  • A failing bsdtar or decompressor (install -x) ended in a CalledProcessError traceback. It is one pkgforge: error: <tool> failed (exit N): <its last message> line, status 1, and the tool reads standard input only when the source is -.
  • A SQLite file DB that cannot be opened, written or read (an unwritable directory, a locked or non-SQLite file, a schema mismatch, a corrupt meta_json) ended in a traceback. It is one pkgforge: error: <path>: ... line, status 1.
  • A JSON Lines or YAML file DB nested too deeply to parse ended in a RecursionError traceback. It is a one-line error naming the file.
  • Under MCP, dbdump --stdin overwrote an existing OUTPUT with an empty manifest and install - failed with an AttributeError, both reading the protocol channel. dbdump --db - and install - return an error result there.
  • A text DB was split on every Unicode line break, so a record holding U+2028, U+2029 or U+0085 failed to load, and a .jsonl starting with a byte-order mark was appended to as YAML. Records split on \n only and a leading BOM is ignored.
  • rpmspecfiles wrote a symlink's recorded mode in %attr, and rpmbuild warned Explicit %attr() mode not applicable to symlink; the mode is -.
  • A failed dbdump could leave a truncated or mixed OUTPUT. Each file is written to a sibling .<name>.pkgforge-tmp and renamed into place once every write has succeeded.

0.2.0 - 2026-09-29

Upgrading from 0.1.2

  • pkgforge.common and its dict-based registries are removed. Import PkgForgeError/UsageError from pkgforge.errors; FileType, FileEntry, FileEntryArgs, AUTO, DEFAULT, mode_to_octal, normalize_mode and the entry functions from pkgforge.entry; PkgForgeCmd, PkgForge, parsepath from pkgforge.command (or straight from pkgforge, which still exports every name in pkgforge.__all__). pkgforge.db.register_provider()/ PROVIDERS/SUFFIX_FORMATS/_SNIFFERS and pkgforge.dbdump.PER_ENTRY_FORMATS/ MULTI_ARTIFACT_FORMATS/dump_formats()/PerEntryDumper are gone too -- subclass DbProvider/PerEntryFormat/MultiArtifactFormat with your own NAME instead, and look one up with DbProvider.lookup(name)/ DumpFormat.lookup(name). pkgforge.dbdump.rpmspecfile() is gone -- call RpmSpecFiles().render_entry(path, entry).
  • pkgforge.db and pkgforge.dbdump are now packages (one module per backend/format), and so is pkgforge.install (TAR_SUFFIXES/ ARCHIVE_SUFFIXES/BSDTAR_EXTRACT_FLAGS moved to pkgforge.install.archive); only a direct import pkgforge.db/.dbdump expecting a plain module needs updating. DbProvider.format is renamed to DbProvider.NAME.
  • -X/--exclude on every command now matches the same install path (the /-rooted path the entry has, or will have, in the DB) instead of a per-command root. Rewrite an absolute pattern written for the old anchor as an install path (install -X /tmp build /opt/app -> -X /opt/app/build/tmp) or make it relative; install/scan log a warning for a pattern that can no longer match. PathMatchStmt.rebased() is removed -- pass installroot= to PathMatch instead.
  • ** in --exclude now matches any number of directories on every Python version; some patterns now exclude more than before -- review any -X value containing **.
  • install copies file sources by default instead of hardlinking them, so -m/--chown no longer mutate the source and a build root on another filesystem no longer fails. For the old hardlink behavior, or to have install consume the source, pass --method link or --method move (env PKGFORGE_INSTALL_METHOD).
  • scan -m now applies to regular files only; directories take the new --dir-mode. Add --dir-mode if you relied on -m for directories.
  • Suffix stripping without -T is wider: .tgz, .tbz2, .tbz, .txz, .zip, .iso and upper-case suffixes now land at DESTINATION/app-1.0 (previously only a literal .tar/.tar.gz/.tar.bz2/.tar.xz dot-segment was stripped). Update any script relying on the old paths.
  • Commands now log as pkgforge.<command> (was <command>) -- update any --loglevel selector, e.g. --loglevel pkgforge.scan:WARNING. Usage, errors, --version and shell completion now name the command pkgforge (was PkgForge) -- regenerate installed completions.
  • install, scan and dbdump now exit 2 (instead of a traceback, exit 1, or silently doing the wrong thing) for bad input: an out-of-buildroot DESTINATION/PATH, no --buildroot/PKGFORGE_ROOT at all (pass --buildroot / to do that on purpose), an unknown --db-format/-t/-m value, a malformed -X statement, or a bad dbdump -f/OUTPUT combination. Scripts that depended on exit 1 or a traceback for these should check for exit 2 instead.
  • dbdump -f debian output gains two new sections: dirs (dh_installdirs lines) and fixperms (a script to run from override_dh_fixperms, after dh_fixperms, applying every pinned owner/group/mode). Wire both into debian/rules if your packaging pipeline parses dbdump -f debian - directly.
  • Requires duho>=0.6.0,<0.7 (was >=0.5.0,<0.6) and pyyaml>=6.0,<7 (was unbounded); building from source now requires hatchling>=1.27; the docs extra is bounded to mkdocs 1.x/mkdocs-material 9.x/mkdocstrings below 2; twine and hatchling are no longer in the dev extra (install them directly if you used them from it).
  • DUHO_TRACEBACK now follows duho's own boolean tokens: n/f turn it off (they used to turn it on).

Added

  • dbdump -f rpmspecfiles-pre419 (alias rpm-pre419) writes %files lines for rpm older than 4.19 (measured on rpm 4.14, 4.16 and 4.18): the path is written completely unquoted, and a space, a glob character (* ? [ ] { }), % (any form), or a non-UTF-8 byte raises an error instead of packaging the wrong file. Choose it when the rpm that will build the package is older than 4.19; with rpm 4.19 or newer, use rpmspecfiles (unchanged) instead.
  • dbdump -f accepts rpm/rpmspec as aliases for rpmspecfiles, and deb as an alias for debian.
  • debian also writes fixperms, a POSIX sh script that applies every pinned owner, group and mode to a package directory (sh debian/fixperms debian/<pkg>, after dh_fixperms); dbdump -f debian - gains a # === fixperms === section. permissions is unchanged.
  • dbdump --stdin reads the file DB as JSON Lines from standard input instead of --db, e.g. install ... | pkgforge dbdump --stdin -f FORMAT to render a manifest without ever writing a DB file. No environment variable enables it, so an existing unattended script keeps its current behavior unchanged; --db - (or PKGFORGE_DB=-) is still write-only and reads as an empty DB. --stdin ignores --db/--db-format entirely. A terminal or already-closed stdin exits 2 immediately instead of waiting; otherwise it reads to EOF (blocking on a pipe that is never closed, same as any other stdin source). Only JSON Lines is accepted.
  • scan --drop-stale records a removal for each DB entry below PATH whose file is gone from the build root, so dbdump stops listing deleted files. Entries matching -X are kept. Needs a --db file.
  • install --record-tree (env PKGFORGE_INSTALL_RECORD_TREE) also records every path below a directory or archive DESTINATION that the DB does not already hold: owner/group/-O from the install, mode and type from disk (symlinks -), honouring -X. It replaces the scan --missing step after a tree install; without it an install still records only DESTINATION.
  • install gains --method {copy,link,move} (env PKGFORGE_INSTALL_METHOD), applying only to a real file/directory source (a - stdin, -x, archive or symlink source is unaffected). copy (the default) is unchanged. link hardlinks instead of copying -- fast and free of disk use, but the staged copy then shares the source's inode, so -m/-o/-g/--chown change the source too; a file/entry os.link can't span falls back to a copy automatically. move consumes the source (a rename, or a copy-then-delete across filesystems for a file; a whole-tree rename when the destination doesn't exist yet and there's no -X, otherwise file-by-file, leaving excluded files behind); a failure applying/recording the entry afterwards restores the source where that's still a single reversible unit. --remove-source is a no-op with move (the source is already gone).
  • The unattended-build guide shows how to install shell completion (--print-completion).
  • The command guide documents -O/--meta KEY=VALUE (rpmprefix=%config(noreplace), a symlink target) and scan's entry options.
  • PKGFORGE_MCP=stdio serves pkgforge's commands as MCP tools over stdio (from duho 0.6). Unset, nothing changes.
  • Python 3.14 classifier.
  • The header documents __version__.
  • PkgForgeError, UsageError (a ValueError). Errors now print one pkgforge: error: ... line and exit 2 for a usage mistake or 1 for a runtime failure, instead of a Python traceback (DUHO_TRACEBACK=1 adds the traceback back); a closed output pipe now exits 1 silently. An unknown --chown owner or group now raises UsageError instead of a raw KeyError.
  • PathMatch(stmts, root=None, installroot="/") and PathMatch.unreachable(): the former's new installroot keyword names the install path a matched entry will have; the latter lists anchored statements that can never match under it.
  • pkgforge.db.DbProvider and pkgforge.dbdump.DumpFormat (plus its two subclasses PerEntryFormat and MultiArtifactFormat) are now the documented extension points: add a backend or a dump format by subclassing one of these with your own NAME (and, optionally, ALIASES/SUFFIXES/sniff) -- no registration call needed.
  • pkgforge.dbdump.UnsupportedOutputError: raised for OUTPUT of the wrong shape for the chosen dump format (a file for a multi-artifact format, or an existing directory for a per-entry one). Also a UsageError (exit 2, unchanged) and, per its name, a NotImplementedError.
  • pkgforge.dbdump.DumpError, raised for a DB entry a dump format's own tooling cannot represent.
  • pkgforge.DbError: a malformed jsonl/yaml file DB fails with one clear line naming the file (and, for jsonl, the line) instead of a traceback.
  • pkgforge.exclude.ExcludeSyntaxError, a UsageError and argparse.ArgumentTypeError, raised for a malformed --exclude statement.
  • entry_from_args, entry_from_path, resolve_entry, apply_entry, typed forms of the FileEntry helpers (entries are dicts; .resolve_for/.apply on an entry never worked).
  • normalize_mode(): normalizes a mode value (an int, an octal string, or the -/--/auto sentinels) to the octal permission string the file DB stores.
  • pkgforge.DbProvider.batch(): an optional context manager a backend can override to batch many writes; the default does nothing, so existing and third-party providers are unaffected. scan into a sqlite DB now uses it: one connection, committed every 1000 rows (and once more at the end), instead of connecting, creating the schema and committing once per file.

Changed

  • pkgforge.db and pkgforge.dbdump are now packages, one module per backend/format (db/{jsonl,yaml,sqlite}.py, dbdump/{rpm,debian}.py), instead of one flat module each. Import from pkgforge.db/pkgforge.dbdump as before; only a direct import pkgforge.db (or .dbdump) expecting a plain module, rather than a package, would need updating.
  • DbProvider.format is renamed to DbProvider.NAME.
  • pkgforge.install is a package: TAR_SUFFIXES, ARCHIVE_SUFFIXES and BSDTAR_EXTRACT_FLAGS moved to pkgforge.install.archive; pkgforge.install.Install is unchanged.
  • The API reference has one page per module, covers every public name and no longer shows private helpers.
  • -X matches the install path — the /-rooted path the entry has in the DB — on every command. install anchored an absolute pattern at the source directory and scan at <buildroot>/PATH; a relative pattern now also matches DESTINATION/PATH segments above the copied or scanned tree, as dbdump already did. Rewrite such patterns as install paths (install -X /tmp build /opt/app -> -X /opt/app/build/tmp) or as relative ones; an absolute pattern that can no longer match logs a warning.
  • install, scan and dbdump take --exclude/-X from a shared pkgforge.exclude.ExcludeArgs base instead of each declaring it separately; its position in install --help moves earlier (right after --buildroot), with no other visible change.
  • install -X with only file or symlink sources logs a warning (nothing to filter); a directory or archive source is always filtered.
  • scan logs one INFO summary; per-path Updating file entry for: lines need -v.
  • Commands log as pkgforge.<command> (was <command>, e.g. scan). Use the new name in --loglevel, e.g. --loglevel pkgforge.scan:WARNING; in Python, logging.getLogger("pkgforge") controls all of them.
  • scan -m applies to regular files only. Directories take the new --dir-mode (default: from disk with --mode=--, else -); symlinks always record mode -. If you relied on -m for directories, add --dir-mode.
  • scan and install look up user and group names only for fields set to --, and cache each lookup for the process's life.
  • app-1.0.tgz, .tbz2, .tbz, .txz, .zip, .iso and upper-case suffixes installed without -T now land at DESTINATION/app-1.0 (previously only a literal .tar/.tar.gz/.tar.bz2/.tar.xz dot-segment was stripped); a directory source keeps its full name unchanged (conf.tar.d stays conf.tar.d, never conf). Update any script relying on the old paths.
  • A tar archive writing through any symlink, even one inside the destination, is refused (exit 1), as bsdtar already did; a hardlink member naming another archive member by an absolute-looking path (e.g. /a) links to that member, never to a real host path that happens to exist there; escaping members or links, and device nodes or FIFOs, exit 1 with one message, not a traceback.
  • The yaml backend uses PyYAML's libyaml-backed loader/dumper when the installed PyYAML build has them; the on-disk file format is unchanged.
  • dbdump writes entries sorted by path, so a staged tree gives byte-identical manifests on any filesystem and backend (was insertion order).
  • compact (jsonl/yaml) now replaces the DB with a new inode (a temp file renamed into place) instead of rewriting it in place. A --db hardlinked elsewhere is detached from that hardlink by the next compact; a symlinked --db keeps its link (the link's target is what gets replaced), and the file's mode (and owner/group, best-effort) are carried over unchanged.
  • The documentation site is now rebuilt from main whenever the docs change, and from the release tag after each final release. A pre-release tag leaves it unchanged.
  • Pre-release tags (vX.Y.Z-rc.N) now produce a GitHub pre-release only and are no longer uploaded to PyPI. Install a pre-release from the wheel attached to its GitHub release.
  • Requires duho>=0.6.0,<0.7 (was >=0.5.0,<0.6). DUHO_TRACEBACK now follows duho's boolean tokens: n and f turn it off (they used to turn it on). --loglevel accepts [NAME:]LEVEL[,...] and rejects a malformed value with exit 2; -v/-q gain --verbose/--quiet; log output is colored only on a terminal and never when NO_COLOR is set.
  • pyyaml is declared as >=6.0,<7; it was unbounded.
  • Building from source requires hatchling 1.27 or later.
  • The docs extra is bounded to mkdocs 1.x, mkdocs-material 9.x and mkdocstrings below 2.
  • twine and hatchling are no longer in the dev extra; install them directly if you used them from it.
  • The dev extra installs black on Python 3.10 and later.
  • The sdist no longer includes benchmarks/.
  • benchmarks/run.py times the scan command end to end per DB backend (scan.cmd_jsonl, scan.cmd_yaml, scan.cmd_sqlite, scan.cmd_auto_owner). scan.walk, which timed only os.walk, is renamed fs.walk_baseline. The result schema is documented in benchmarks/README.md.

Removed

  • pkgforge.common: import PkgForgeError/UsageError from pkgforge.errors; FileType, FileEntry, FileEntryArgs, AUTO, DEFAULT, mode_to_octal, normalize_mode and the entry functions from pkgforge.entry; PkgForgeCmd, PkgForge, parsepath from pkgforge.command. Names in pkgforge.__all__ still import from pkgforge.
  • pkgforge.db.register_provider(), PROVIDERS, SUFFIX_FORMATS and _SNIFFERS: subclass DbProvider with your own NAME (and, optionally, ALIASES/SUFFIXES/sniff) instead; look one up by name with DbProvider.lookup(name) (list every registered name with DbProvider.names()).
  • pkgforge.dbdump.PER_ENTRY_FORMATS, MULTI_ARTIFACT_FORMATS, dump_formats() and PerEntryDumper: subclass PerEntryFormat or MultiArtifactFormat instead; look one up with DumpFormat.lookup(name) (list every registered name with DumpFormat.names()).
  • pkgforge.dbdump.rpmspecfile(): use RpmSpecFiles().render_entry(path, entry) instead (from pkgforge.dbdump import RpmSpecFiles).
  • PathMatchStmt.rebased(): pass installroot= to PathMatch instead.

Fixed

  • The formats guide now states, measured, what rpm older than 4.19 does with a rpmspecfiles line: a dquote/backslash/non-UTF-8 name fails the build, a star/qmark name silently packages an extra sibling file, and a bracket/brace name silently packages the wrong file -- it previously only said rpmspecfiles "targets rpm 4.19+" with no detail below that version.
  • The formats guide's override_dh_fixperms example read permissions fields in the wrong order (it passed the group to chmod); it now runs the generated fixperms script.
  • The docs no longer call debian's permissions dpkg-statoverride input (that tool takes user group mode path and rejects -); they now say how to apply it from an override_dh_fixperms target, and that dh_install's sources need PKGFORGE_ROOT=debian/tmp or --sourcedir=$(PKGFORGE_ROOT).
  • The README quick start, the docs landing page and the unattended guide no longer run scan --missing over /usr or /etc: every directory a scan walks becomes an RPM %dir ownership claim, and Fedora/RHEL's filesystem package ships /usr/bin as 0555, so the built RPM conflicted with it and rpm -U refused to install. Pass scan only a directory your own package owns.
  • README links and badges work on PyPI and in the installed pkgforge/README.md.
  • The wheel and sdist never include files named *.local.* or CLAUDE*, even when built from a tree without .gitignore.
  • examples/stage_and_package.sh is executable, so ./examples/stage_and_package.sh runs in a fresh checkout.
  • examples/stage_and_package.sh stages its tree at /usr/share/tool and records its files.
  • dbdump ... - writes through sys.stdout instead of a raw file descriptor: it now works when sys.stdout is redirected (or otherwise not backed by a real file descriptor), and keeps its place after text the caller already printed but had not yet flushed.
  • dbdump checks -f/OUTPUT before reading the DB: an unknown format, a file OUTPUT for debian, or a directory OUTPUT for rpmspecfiles now exits 2 with one line naming the problem (was exit 1 or a raw traceback, possibly after loading the whole DB).
  • rpmspecfiles escapes \ and " for rpm 4.19+ and writes non-ASCII names as UTF-8 (was \uXXXX, which rpmbuild could not find). A control character in a path stops dbdump with an error naming the problem.
  • debian install lines are escaped for dh_install (\ * ? [ ] { } and a leading # get a backslash; space becomes ${Space} and a literal ${ in a destination directory becomes ${Dollar}{; both need debhelper compat 13): before this, a space aborted dh_install, a literal ${ aborted it too, and glob characters shipped the wrong files. A control character in a path, or whitespace in a mode/owner/group, now stops dbdump with an error. Non-UTF-8 names are written as their original bytes (was UnicodeEncodeError).
  • debian also writes dirs (dh_installdirs lines, one per directory entry), so an empty directory recorded with install -d reaches the package (previously only rpmspecfiles' %dir covered it). dbdump -f debian DIR now also writes (and replaces) DIR/dirs, and - gains a # === dirs === section.
  • debian's install destination is split with POSIX path rules on every platform: on Windows, a source name containing a literal backslash (a valid POSIX filename character) was previously split in the wrong place, since the native path module there also treats a backslash as a separator.
  • scan no longer records the file DB (or a SQLite DB's -journal/-wal/-shm sidecars) found inside the scanned tree, and warns once instead; other files there, such as an earlier dbdump output, are still recorded -- keep the DB and any dump output outside --buildroot.
  • scan of a PATH that does not exist under the build root exits 2 with one message, instead of a raw FileNotFoundError traceback.
  • scan records a symlink PATH as one symlink entry instead of walking its target: previously, scanning a link to a directory (an absolute target included, e.g. /etc) recorded the target's own contents under the link's path -- for an absolute target, the build host's filesystem. PATH / is unaffected and is still walked, even through a symlinked --buildroot.
  • install -X filters an archive source's members like a directory copy (every member used to be extracted regardless of -X): excluded members and directories never reach DESTINATION. An archive holding a device node, FIFO or socket is still refused even when -X matches it.
  • ** in --exclude matches any number of directories on every Python version: /**/*.pyc excludes .pyc files at any depth, /opt/app/** excludes everything below /opt/app but not /opt/app itself. Some patterns now exclude more than before; review any -X value containing **.
  • install -X and scan -X read file metadata only for a statement that actually has an inline test ((?type:...)/(?meta:...)), so a glob-only -X '*.fifo' now skips a FIFO or socket instead of failing on it; a special file scan cannot record any other way now stops it with a one-line error naming the path and the -X remedy, instead of a traceback or a silently truncated DB.
  • (?meta:k=v) in install -X and scan -X now sees that run's -O values; it never matched there before, and (?!meta:k=v) excluded every matching path regardless of -O.
  • PathMatch.match(path, entry, **overrides) no longer writes the **overrides into the caller's entry dict (reachable only from the Python API, e.g. filtering a loaded DB in dbdump).
  • scan -X does not descend into an excluded directory, so its contents are no longer recorded (as with install). To leave only the directory out of a manifest, exclude it in dbdump instead: dbdump -X '(?type:directory)/usr'.
  • A malformed -X statement (an unknown inline test, an invalid (?type:...)/(?meta:...) argument, or an unterminated (?...) now exits 2 naming the problem, instead of a traceback (unknown test) or silently becoming a literal glob that excluded nothing (an unterminated test). -O/--meta without = reports expected KEY=VALUE, got '...' instead of invalid <lambda> value. Every command's --help now shows the -X grammar.
  • A directory install whose source contains the build root, destination or file DB skips them (their parents are created empty) instead of recursing into RecursionError.
  • Tar archives holding absolute symlinks, or symlinks that climb above the destination, extract with their targets kept exactly as written (instead of tarfile.AbsoluteLinkError/LinkOutsideDestinationError), and re-extract cleanly over an earlier run instead of failing.
  • Re-running a directory install whose source holds symlinks no longer fails with shutil.Error; a stale symlink left at the destination (including a directory-source's own symlink retargeted between runs) is replaced instead, and a regular-file source is never written through a leftover destination symlink.
  • install and scan exit 2 when DESTINATION or PATH leaves the build root through a .. that climbs above it, or a symlinked path component leading outside it, instead of writing or recording outside --buildroot. An in-root .. is normalized (/usr/share/../lib/x records /usr/lib/x).
  • With no --buildroot or PKGFORGE_ROOT, install and scan run from / exit 2 instead of using the live filesystem as the build root; pass --buildroot / (or PKGFORGE_ROOT=/) to do that on purpose.
  • A file install no longer fails on macOS for a source with a BSD file flag set (e.g. a system binary), where copying the source's flags along with its content used to raise a permission error even though the source is only read. The staged copy carries the source's content, permission bits and modification time; it never carries file flags or extended attributes (e.g. an SELinux label).
  • -d with -t/--type exits 2 instead of -d silently overriding -t and turning a file source into a bsdtar-style directory extraction.
  • install checks --chown's owner/group names, the --db directory and (without -p) the destination directory before staging (exit 2 with nothing on disk); refuses --remove-source up front (exit 2) when a directory source contains the resolved destination or the --db file; and removes the source only after the entry is recorded, never when it is itself the staged destination (which is now skipped with a warning instead of deleting the only copy).
  • A failed install leaves no empty or partial file (or extracted directory) behind, and keeps an earlier staged copy at that destination exactly as it was, instead of truncating or clobbering it. Re-running a symlink install now replaces the link (including a dangling in-place link, and a target retargeted between runs) instead of failing with FileExistsError; a stale host symlink at the destination is replaced by the real staged file instead of being silently kept.
  • Sources resolving to one non-directory destination in a multi-source install exit 2 before staging instead of the last one silently overwriting the earlier ones (directory and archive sources sharing a destination still merge, as before). A symlink source's recorded meta.target no longer leaks into every entry recorded after it, in the same install, in a later Python-API call, or in a later CLI invocation built from the same process.
  • A - source on a terminal or closed stdin, or a terminal character-device path, exits 2 instead of staging an empty file or waiting; install keeps stdin open instead of closing fd 0, and only one - source is allowed per invocation. <(cmd), /dev/stdin and named pipes now stage as files (previously a dangling symlink to the pipe, or a crash for a plain FIFO).
  • -x/--decompress takes gz, xz, bz2, zst, lzma or a decompressor tool name (gzip/gunzip, xz/unxz, bzip2/bunzip2, zstd/unzstd, lzma/unlzma), matched case-insensitively; any other kind, or a bare -x on a source suffix that names none of them, exits 2 before anything is staged, instead of silently compressing the file, leaving the old suffix on the destination, or running an arbitrary word on PATH as a command. -x gzip now decompresses (it used to compress); a resolved kind whose tool is missing from PATH is named in one error line.
  • Install(...) built directly from Python (not through the CLI parser) and given no decompress= argument now stages the source unchanged, instead of silently inferring a decompressor from its suffix and running it as a subprocess (which, for a .sh source, executed it).
  • install copies file sources instead of hardlinking them: -m/--chown no longer change the source, and a build root on another filesystem (e.g. a tmpfs /tmp) or a source the caller doesn't own no longer fails. -o --/-g -- (AUTO) now record the staged copy's owner, not the source's.
  • install -m on a symlink, and any install -m on glibc older than 2.32 (e.g. RHEL/Rocky 8), no longer fails with NotImplementedError. A symlink's mode is recorded but never applied on disk (Linux ignores it).
  • --chown no longer strips an existing setuid/setgid bit from the staged file: owner/group are now applied before mode, and a setuid/setgid/sticky bit already on disk is restored if the entry leaves mode at its default.
  • -m/--mode (on both install and scan) is now validated before anything is staged: it accepts 1-4 octal digits (normalized, so 0644 is stored as 644), -, -- or auto, and rejects everything else, including an explicit empty value, with exit 2. Previously a Python-literal spelling like 0o644 was recorded verbatim and made rpm silently package the file with mode 000, and scan -m accepted any string at all with no check.
  • An empty mode, owner or group is now treated like - (the documented default) everywhere it is read: applying the entry, and both the RPM and Debian dump formats. Previously the RPM format emitted %attr(,-,-), which rpmbuild rejects, for an empty mode.
  • install -t/--type now accepts file, directory or symlink in any case and rejects anything else with exit 2 instead of a Python traceback; --type=-- (or the Python API's FileType._AUTO) now auto-detects from the source instead of raising NotImplementedError. scan --type is hidden from --help (scan always records each path's own on-disk type) and now warns instead of silently doing nothing when given a value.
  • --help describes every option, names the PKGFORGE_* variables and the DB and dump formats, and no longer shows developer notes.
  • An unknown --db-format or PKGFORGE_DB_FORMAT exits 2 with one line naming the valid formats, before anything is staged (was a traceback and exit 1, after install had staged the file), also when --db is unset (was ignored). Constructing a command with an unknown db_format raises UsageError (a ValueError) immediately.
  • Usage, errors, --version and completion name the command pkgforge (was PkgForge), so completion binds. Regenerate installed ones.
  • PKGFORGE_ROOT/PKGFORGE_DB/PKGFORGE_DB_FORMAT are now read when pkgforge.main() (or duho.parse) runs, instead of once when the package is imported. A build driver or test that sets one of these after importing pkgforge (e.g. via monkeypatch.setenv, or a long-lived process that changes its own environment) is now honored; a --db/--buildroot/ --db-format on the command line still wins over the environment. A command constructed directly in Python, not through main(), is unaffected and keeps using the value from import time, as before.
  • An empty PKGFORGE_DB_FORMAT (a common CI idiom for "unset") no longer makes every DB command fail with unknown db format ''; it is now treated as unset, same as an empty PKGFORGE_DB/PKGFORGE_ROOT already was.
  • PkgForgeCmd.localpath() accepts a str as well as a Path, and a relative input is now taken as already build-relative instead of silently dropping its first path component.
  • import pkgforge, --help and the jsonl backend no longer require Python's sqlite3 module; choosing the sqlite backend on an interpreter that lacks it fails with one clear line instead of an ImportError from inside the stdlib. The yaml backend now imports PyYAML lazily too, with the same one-line error if it's ever missing.
  • jsonl/yaml DBs are read and written as UTF-8 under any locale; before, a DB containing literal (non-ASCII-escaped) UTF-8 -- a hand edit or a third-party writer -- could be mis-decoded under a non-UTF-8 locale, and compact would then make that damage permanent.
  • An append to a jsonl/yaml DB whose last line lacks a trailing newline (a hand edit, a printf/echo -n writer, a torn write) starts a new line instead of fusing the new record onto the old one and silently corrupting the DB.
  • The yaml backend reads scalars as written: mode: 0755 is "0755" (previously the int 493, dumped as %attr(493,...) and packaged as mode 004). A missing meta loads as {}, a missing mode/owner/group as -, and a jsonl int mode 755 loads as "755". A bool or float mode, a non-octal-digit int mode, a non-string type, or a record that is not a mapping now raises DbError instead of silently mispackaging or crashing later with a raw KeyError.
  • A flow-style YAML DB ({/usr/bin/x: ...}, {}) is still read as YAML. Appending to one now raises DbError instead of silently corrupting it; pkgforge --db FILE compact rewrites it in block style first.
  • compact (jsonl/yaml) no longer truncates the DB before writing the compacted result: a failure partway through (a full disk, a kill) now leaves the original file completely unchanged instead of a torn or emptied one.
  • jsonl/yaml writers (install, scan, compact itself) now serialize through an advisory lock on the DB file, so running compact while another pkgforge process is appending to the same DB no longer silently drops the concurrent append.
  • Reading a sqlite DB never writes to it any more: pointing dbdump (or any read) at an empty or foreign SQLite file no longer adds an entries table to it. A SQLite file without an entries table now raises DbError naming the file, unless it has no tables at all, in which case it loads as an empty DB. A path, or a mode/owner/group/type field, that is not valid UTF-8 (e.g. an undecodable file name, or a pwd/grp entry containing one) now raises DbError naming it, instead of an interpreter-level UnicodeEncodeError traceback partway through a scan.
  • register_provider's suffixes now add a missing leading dot ("toml" registers the same as ".toml"), since Path.suffix always includes one and a dotless entry could never match a --db path before. A suffix with more than one dot (e.g. ".tar.gz") is now rejected with ValueError at registration time instead of silently never matching.
  • dbdump now logs a WARNING when --db is unset, -, or names a file that doesn't exist, and when the DB has entries but --exclude leaves none; initdb warns when --db is unset or -; compact warns for either of those cases, and also for a --db naming a file that doesn't exist yet (previously it silently created an empty jsonl/yaml file in that case; now nothing is created, for every backend). Exit codes and output are unchanged -- a missing/unset/- DB has always read as empty; this just makes that visible instead of silent.

Security

  • rpmspecfiles rejects a path containing %: rpmbuild expanded it, so a staged %(...) name ran a shell command, and no quoting is literal on rpm both before and after 4.19; write that %files line by hand.
  • Archives extracted with bsdtar (stdin, .zip, .iso, .cpio) no longer restore owners, setuid/setgid, group/other write, xattrs, ACLs or file flags as root; an archive holding a device node, FIFO or socket is refused (exit 1).
  • Without tarfile's extraction filter (Python before 3.9.17, 3.10.12 or 3.11.4), a tar archive now routes to bsdtar, or is refused (exit 1), instead of extracting with no path/symlink/special-file checks at all.
  • The quick start and unattended guide build in a private mktemp -d root, not /tmp/stage; scripts copied from the old docs should do the same.

0.1.2 - 2026-08-16

Changed

  • The duho dependency is now >=0.5.0,<0.6. 0.1.1 capped it at <0.4 to stay installable; the commands are now declared against duho's current argument model instead, so the cap is gone and pkgforge tracks the supported duho line again.
  • install's source is declared as a list[Path] rather than a Union[list[Path], Path]. duho resolves a union by composing its members' scalar converters, so a collection member has no way to keep its own argparse action and the parser refused to build. The command still accepts a bare Path from the Python API; only the annotation narrowed.
  • --exclude on install, scan and dbdump is declared with duho.Append, making it a repeatable single-value option.

Fixed

  • --exclude works through the command line again. It previously combined a greedy nargs with an append action, so install --exclude=P produced a list of lists (AttributeError: 'list' object has no attribute 'pattern') and the bare -X P SRC DST form swallowed the positionals. Both forms now parse to a flat statement list; the multi-source regression test drives the option through argv rather than assigning it directly.

Notes

  • -x/--decompress still takes an optional argument and therefore still consumes the following token, so install -x SRC DST reads SRC as the compression kind. That is argparse's own behavior for this shape and the 0.1.1 guard rejecting a path-shaped kind remains in place and necessary.
  • The suite also passes against duho 0.4.x, but 0.5 is the series exercised and therefore the one declared. CI now runs one job pinned to the declared floor so it is verified rather than assumed.

0.1.1 - 2026-08-16

Added

  • py.typed marker, so the Typing :: Typed classifier is honored by type checkers (PEP 561).

Changed

  • The duho dependency is now >=0.3.2,<0.4. It was unbounded, so a fresh install of 0.1.0 resolved a duho the parser cannot build against and every pkgforge invocation failed.
  • sniff_format() reads only the 16 bytes it inspects instead of the whole file.

Fixed

  • --exclude with an absolute pattern now works for every source of a multi-source install. The parsed statements were rewritten in place when bound to a source root, so the second source re-prefixed an already-rebased pattern (/a/** -> /src2/src1/a/**) and silently excluded nothing.
  • An exclude statement that does not apply no longer vetoes the statements after it. A directory that failed a recursive (**) pattern returned a definite "keep" instead of falling through, making the outcome depend on statement order (-X '**/*.pyc' -X '(?type:directory)**/tmp' never reached the second statement).
  • install -x with a stdin source (-) raises a clear cannot infer compression from stdin; pass -x TYPE instead of an AttributeError.
  • install -x given a path as its kind is rejected with an explanation. -x takes an optional argument, so install -x SRC DST parses SRC as the compression kind; with further sources the positionals silently shifted along by one and the source path was then run as a decompressor command.

Notes

  • duho 0.4 and newer are not supported. From 0.4.0 the argument model rejects Install.source's Union[List[Path], Path] and the parser fails to build, so the cap above is what makes this release installable; supporting 0.4+ requires re-declaring source and exclude against that model.
  • On the supported duho range, --exclude passed through the command line parses to a nested list and install then fails; the exclude fixes above are reachable through the Python API.

0.1.0 - 2026-07-18

First release. pkgforge stages files into a build root, records their intended install metadata in a file DB, and renders that DB into RPM/Debian packaging manifests. Built on the duho declarative CLI framework; Python 3.9+, Linux runtime.

Added

  • Commands: install (stage a source and record its entry — files, directories, symlinks, decompression, hardlinks, --exclude, --chown, --remove-source), scan (walk a tree and record entries; --missing fills gaps), dbdump (render the DB to a packaging manifest), initdb, and compact (collapse an append-log DB to one record per live path).
  • Dump formats: rpmspecfiles (RPM %files lines with %attr/%dir and a meta.rpmprefix) and debian (install + permissions artifacts). The format registry supports multi-artifact formats.
  • Pluggable file-DB backends behind one DbProvider interface: jsonl (append-only JSON Lines, the default), yaml (append-only YAML), and sqlite (a real SQLite store, upserted in place). Every command behaves identically across all three. The backend is inferred from the --db suffix (.jsonl/.ndjson, .yaml/.yml, .db/.sqlite/.sqlite3); --db-format / PKGFORGE_DB_FORMAT overrides it, and reading auto-detects a file's actual format. register_provider() lets a third-party package add its own backend; DbProvider, open_db, and register_provider are exported from the package.
  • Environment-driven configuration for unattended builds (PKGFORGE_ROOT, PKGFORGE_DB, PKGFORGE_DB_FORMAT), --version, and shell completion.
  • Tar-family archives extract via stdlib tarfile (safe data filter where supported); bsdtar is only a fallback for other formats (e.g. .iso).
  • Documentation site (mkdocs-material) with a guide + API reference, a benchmarks/ runner, an end-to-end example, and CI (test.yml/release.yml).

Notes

  • File-DB entries store mode as an octal permission string; two sentinels (- = OS default, -- = resolve from disk) defer a field to the staged file.
  • Hardlink install uses os.link for portability across Python 3.9–3.13 (Path.link_to was removed in 3.12; Path.hardlink_to only exists from 3.10).