Dump formats
pkgforge dbdump -f FORMAT [OUTPUT] renders the file DB into a packaging
manifest. null (removed) entries and --exclude matches are skipped.
| Format | Aliases | Shape | Output |
|---|---|---|---|
rpmspecfiles |
rpm |
per-entry lines | a file or - (stdout), or an existing directory (one list per package) |
rpmspecfiles-pre419 |
rpm-pre419 |
per-entry lines | a file or - (stdout), or an existing directory (one list per package) |
rpmspec |
the whole spec (rpm 4.19+) | a file or - (stdout); needs [package] name, version, summary and license; kept if it exists, unless --force |
|
rpmspec-pre419 |
the whole spec (older rpm) | as rpmspec |
|
debian |
deb |
multiple artifacts | a directory, or - (stdout, sectioned); needs [package] name; with --skeleton also control, changelog, rules and source/format |
rpmspec, rpmspec-pre419 and debian --skeleton write the spec and the
debian/ files for a whole package from pkgforge.cfg; the walk-through from
the project file to the built packages is
From pkgforge.cfg to packages. To build the packages without
rpmbuild or dpkg-buildpackage, use pkgforge package.
Before any format renders, every surviving entry is checked: the key must
be an absolute path with no empty, . or .. segment; type must be
file, directory or symlink; mode is 1-4 octal digits, - or --;
owner and group are an account name ([A-Za-z_][A-Za-z0-9_.-]*,
optionally ending in $), - or --; kind is one of file, config,
config-noreplace, doc, license and ghost, and all but file and
ghost need type file; caps is - or one lowercase setcap clause
(cap_net_raw=ep) on a regular file that is not a ghost. A bad entry stops
dbdump with exit 2 and one line naming the entry and the field, and writes
nothing. %, whitespace and control characters cannot occur in mode, owner,
group or caps.
A file OUTPUT is written whole or not at all: dbdump writes each file to
a sibling .<name>.pkgforge-tmp and renames them into place only when every
write has succeeded.
Kinds and capabilities
An entry's kind says how the package manager treats the file, in the same
words for both formats (install -k, a rule's kind, a step's kind):
| Kind | rpm | Debian |
|---|---|---|
file |
no directive | an install line |
config |
%config |
a conffile: debhelper marks every file under /etc/ itself, and a path elsewhere is listed in conffiles |
config-noreplace |
%config(noreplace) |
as config |
doc |
%doc |
an install line |
license |
%license |
an install line |
ghost |
%ghost |
not shipped: left out of install, dirs and fixperms, and a file is listed in not-installed |
caps is one setcap clause such as cap_net_bind_service=ep. rpm stores it
with %caps(CAPS). A .deb keeps no file capabilities in its payload, so the
generated postinst runs setcap on configure, and the package needs
Depends: libcap2-bin (${pkgforge:Depends} names it, see debian).
rpmspecfiles
Emits one RPM %files line per entry:
%attr(755,root,root) "/usr/bin/tool"
%dir %attr(-,-,-) "/etc/tool"
%config(noreplace) %attr(640,root,adm) "/etc/tool/config"
Directories get a %dir prefix, a symlink's mode is written as -
(rpm ignores it and warns on an explicit one), and an entry's kind (set with
-k/--kind) starts the line: %config,
%config(noreplace), %doc, %license or %ghost, and nothing for file.
An entry with file capabilities (--caps) gets %caps(CAPS) before its
%attr:
%config(noreplace) %attr(640,root,adm) "/etc/tool/config"
%caps(cap_net_bind_service=ep) %attr(755,root,root) "/usr/bin/tool"
%ghost %dir %attr(750,tool,tool) "/run/tool"
Each path is quoted for rpm's %files -f parser (targets rpm 4.19+, where
no spelling of % is literal inside or outside quotes): a backslash and a
double quote are escaped, and the whole path is written as UTF-8 (a
non-UTF-8 name round-trips its original bytes). Glob characters
(* ? [ ]) are never escaped -- rpm's own quoted-string globbing already
matches only the literal name. A % anywhere in the path is refused
outright (DumpError, dbdump exits 1): rpm macro-expands every %files
line before it looks at quoting, so an unescaped % let a staged filename
run as a macro -- including %(...), which runs a shell command -- and
there is no quoting that makes it literal on every rpm version. Rename or
--exclude such a file, or write that one line by hand. A path containing
a control character (including a tab) is refused the same way: rpm cannot
represent it either.
Warning
Every directory entry becomes a %dir ownership claim -- the built
RPM installs that directory with the recorded mode/owner/group (or the
build root's own, if unset). Never scan a directory the distro itself
ships (/usr, /usr/bin, /usr/share, /etc, ...): if its mode/owner
differs from the distro package that already owns it (e.g. Fedora's
filesystem ships /usr/bin as 0555), rpm -U refuses to install.
Scan only a directory your package owns.
pkgforge dbdump -f rpmspecfiles files.txt
pkgforge dbdump -f rpmspecfiles - # to stdout
One list per package
A file (or -) holds the entries of one package; a DB with entries of two
packages is refused (exit 2) unless OUTPUT is an existing directory. Into a
directory, rpmspecfiles writes files-PACKAGE.txt for every package that
pkgforge.cfg declares ([package] and each [[packages]]; an entry recorded
as - belongs to the main package), so a spec reads each package's list with
%files -n PACKAGE -f .../files-PACKAGE.txt. A package with no entry gets a
list of one empty line, which rpm builds as an empty package (it refuses a
list of no bytes, or of comments only). Writing into a directory needs
[package] name.
mkdir lists && pkgforge dbdump -f rpmspecfiles lists # lists/files-hello.txt, lists/files-hello-doc.txt
rpm older than 4.19
rpmspecfiles targets rpm 4.19+. Below that, a quoted %files -f name is
macro-expanded twice and an unquoted name's glob characters are matched by
rpm's own globbing before pkgforge's escaping is ever consulted, so the same
quoting is not safe there. Measured 2026-09-29 against real rpmbuild runs
on rpm 4.14.3 (Rocky Linux 8), 4.16.1 (Rocky Linux 9) and 4.18.2 (Ubuntu's
rpm package), against the rpm 4.19+ baseline of 6.0.2 (Fedora):
| class | fedora (6.0) | rocky 8 (4.14) | rocky 9 (4.16) | ubuntu (4.18) |
|---|---|---|---|---|
| plain | exact | exact | exact | exact |
| space | exact | exact | exact | exact |
| utf8 | exact | exact | exact | exact |
| non-UTF-8 byte | loud | exact | loud | loud |
" (dquote) |
exact | loud | loud | loud |
\ (backslash) |
exact | loud | loud | loud |
* (star) |
exact | overmatch | overmatch | overmatch |
? (qmark) |
exact | overmatch | overmatch | overmatch |
[ ] (bracket) |
exact | wrong | wrong | wrong |
{ } (brace) |
exact | wrong | wrong | wrong |
% (any form) |
refused | refused | refused | refused |
overmatch means the build also packages an unrelated sibling file;
wrong means it packages a different file than the one named. rpm
--version on the build host decides which -f to use:
- rpm 4.19 or newer:
-f rpmspecfiles, as documented above. -
older than 4.19:
-f rpmspecfiles-pre419(aliasrpm-pre419). It writes each path completely unquoted (rpm's bare-token reader passes a literal"/\straight through, unescaped and literal), and refuses a path containing a space, a glob character (* ? [ ] { }),%(any form), or a non-UTF-8 byte, instead of risking the wrong file:class fedora (6.0) rocky 8 (4.14) rocky 9 (4.16) ubuntu (4.18) plain exact exact exact exact utf8 exact exact exact exact "(dquote)exact exact exact exact \(backslash)exact exact exact exact space, glob chars, %, non-UTF-8refused refused refused refused
Using the wrong format for the rpm that actually builds the package: with
rpm below 4.19, rpmspecfiles fails the build loudly for a dquote/
backslash/non-UTF-8 name, silently packages an extra sibling for a star/
qmark name, and silently packages the wrong file for a bracket/brace
name. rpmspecfiles-pre419 used with rpm 4.19 or newer either fails the
build for a name rpm 4.19+ could package, or packages it correctly -- never
the wrong file.
pkgforge dbdump -f rpmspecfiles-pre419 files.txt
rpmspec
Writes a whole spec to a file or -, from [package] and [[packages]];
rpmspec-pre419 is the same for rpm older than 4.19. It needs name,
version, summary and license, and a summary for each subpackage (exit 2
naming what is missing). In order it holds: a guard that stops rpmbuild unless
pkgforge_config is defined; __os_install_post and debug_package set to
nothing; the preamble (Name, Version, Release, Summary, License, URL,
BuildArch, Requires) and %description; a %package -n block per
subpackage; an %install that runs pkgforge stage --init and writes the lists
with rpmspecfiles (or rpmspecfiles-pre419); a %post that runs
systemd-tmpfiles for a package that records a tmpfiles.d file (and, for
rpmspec-pre419, a %pre that creates the accounts); and one %files -n NAME -f
per package. Text taken from the file has % doubled, so it is never a macro.
A description line whose first non-blank character is % or # exits 2: rpm
would read it as a section such as %post, a conditional or a comment, even
with the % doubled. An existing spec is kept with a WARNING unless --force. Build it with
rpmbuild -bb --define "pkgforge_config PATH" NAME.spec.
Unit scriptlets
A package that records a systemd unit gets the scriptlets that enable, stop and
restart it. A unit is a regular file, not a ghost, directly in
/usr/lib/systemd/system and named *.service, *.socket, *.timer or
*.path; a template (NAME@.service) is not one. For the package that records
hello.service the spec has:
%post
%{?systemd_post:%systemd_post hello.service}
%preun
%{?systemd_preun:%systemd_preun hello.service}
%postun
%{?systemd_postun_with_restart:%systemd_postun_with_restart hello.service}
The line goes first in the package's one %post, before the tmpfiles.d line,
because rpm refuses two %post sections for one package. The %{?...} guard
expands to nothing where the macro is not defined, so the spec adds no
BuildRequires; the macros come from the host that runs rpmbuild:
systemd-rpm-macros on Fedora and RHEL 9 or later, systemd itself on RHEL 8.
An rpm without them (Ubuntu's, say) builds a package whose scriptlets do not
touch the unit, and pkgforge package -f rpm logs a WARNING saying so. rpmspec,
rpmspec-pre419 and package -f rpm write the same scriptlets.
debian
Needs [package] name in pkgforge.cfg: debhelper reads debian/PKG.NAME
before debian/NAME, and refuses the nameless form for a source with more than
one binary package from compat 15, so every per-package file is named for its
package. For each package P ([package] first, then each [[packages]]
name; an entry recorded as - belongs to the main package) it writes into an
output directory (created if needed):
-
P.install—dh_install-style<src> <dest-dir>lines (one per non-directory entry), where the source is the build-root-relative path and the destination is the entry's parent directory:# Generated by pkgforge dbdump -f debian usr/bin/tool usr/bin etc/tool/config etc/tool -
P.dirs—dh_installdirs-style lines, one per directory entry (dest-escaped; always written, even when it holds no line), so a directory recorded with no files under it (e.g.install -dfor an empty state directory) still reaches the package --rpmspecfiles'%dirprefix covers the same case for RPM:# Generated by pkgforge dbdump -f debian var/lib/tool -
P.conffiles— theconfigandconfig-noreplaceentries outside/etc/(debhelper lists everything under/etc/itself), one absolute path per line; always written, empty when there is none. A path with whitespace in it stopsdbdumpwith exit 1. It carries no marker line: debhelper copies a comment into the package, anddpkg-debrefuses it. -
P.postinst— a maintainer script that holds#DEBHELPER#and, underif [ "$1" = configure ], first one guardeddpkg-statoverride --update --add OWNER GROUP MODE PATHfor each entry owned by an account the package creates (see Build time vs install time ownership), thensetcap CAPS "$DPKG_ROOT"PATHfor each entry withcaps. Capabilities are not kept in a.deb's payload, so a package with acapsentry needsDepends: libcap2-bin. -
P.postrm— the same header and, underif [ "$1" = purge ], a guardeddpkg-statoverride --remove PATHfor each of those entries. -
P.substvars—pkgforge:Depends=libcap2-binwhenPhas an entry withcaps, elsepkgforge:Depends=with an empty value. List${pkgforge:Depends}in the package'sDependsand the dependency follows the entries.dh_prepdeletesdebian/P.substvarson every build, so this works only whendbdumpruns afterdh_prep(the stage intodebian/tmprecipe below); with the other recipe, listlibcap2-bininDependsyourself.
And once for the source package:
-
not-installed— theghostentries of every package that are not directories, sorted and escaped likeinstall, sodh_missing --fail-missingaccepts a ghost file that is staged but never shipped. debhelper reads only the source-widedebian/not-installed. A ghost entry is left out ofinstall,dirsandfixperms. -
fixperms— a POSIXshscript, always written, that applies every pinned mode/owner/group directly to the package directories under the debian directory (see the recipes below): it starts with#!/bin/sh # Generated by pkgforge dbdump -f debian. Usage: sh fixperms DEBIAN-DIR set -e d=${1:?usage: sh fixperms DEBIAN-DIR} p() { ...resolves a path to itself or to its .gz... }followed by, per pinned entry, an
if t=$(p "$d"/P/path); then ...; fithat runs onechown owner:group(orchown/chgrpwhen only one is pinned) and thenchmod(owner and group before mode, sincechownclears a regular file's setuid/setgid bit even to the same owner).dh_compressrenames a man page or a large doc to<path>.gzanddh_usrlocalremoves directories under/usr/local; the pin follows the rename, and a path that is gone is named on stderr and skipped, so the script still exits 0. A symlink getschown -h/chgrp -hand never achmod(POSIXchmodhas no-h, and would otherwise follow the link to a target outside the package tree). With no pinned entry, the script is just its own header.
install, dirs, not-installed, substvars, postinst, postrm and
fixperms carry a marker line, # Generated by pkgforge dbdump -f debian (the
first line of a list, the line after the shebang of a script). dbdump always
rewrites every artifact for every declared package, so no stale file survives a
package's removal from the configuration, and it never replaces a hand-written
debhelper file silently. Before it writes anything it refuses, with exit 2 and
nothing written:
- an existing
install,dirs,not-installed,postinst,postrmorfixpermsof those names that lacks the marker: a hand-written file is never overwritten. Merge the lines by hand into your own copy, or dump into another directory; - a nameless
install,dirs,conffiles,postinstorpostrmin the output directory: debhelper ignores it, without a word, onceP.installexists. Move its lines intopkgforge.cfg(or into the package-named file) and delete it; - an existing
P.conffilesthat lists a path the new one lacks: mark that fileconfigorconfig-noreplace, or delete the line.
install sources are escaped for dh_install/dh_installdirs (needs
debhelper compat 13): a backslash before each glob character (\ * ? [ ] { })
so the name matches only itself, a leading # backslash-escaped (dh_install
treats a line starting with # as a comment), and a space written as
${Space}. Destinations are only ever ${Dollar}{/${Space}-escaped, never
glob-escaped (dh_install takes the destination literally). A path
containing a control character stops dbdump with an error naming the
problem; a non-UTF-8 name is written
as its original bytes.
pkgforge dbdump -f debian debian/ # writes debian/P.{install,dirs,conffiles,postinst,postrm,substvars}, debian/not-installed and debian/fixperms
pkgforge dbdump -f debian - # all of them to stdout under "# === <name> ===" headers
Note
install's sources are relative to the build root, and dh_install
looks for them only in the package directory and debian/tmp. Either
stage with PKGFORGE_ROOT=debian/tmp, or add an
override_dh_install: dh_install --sourcedir=$(PKGFORGE_ROOT) target to
debian/rules.
Run the generated fixperms script from an override_dh_fixperms
target in debian/rules, after dh_fixperms (which would otherwise
strip the very bits being pinned, including a setuid/setgid mode). It
takes the debian directory, so a package added later needs no change to
debian/rules. It pins the base-passwd owners and groups; a file owned
by an account the package creates is left to postinst:
override_dh_fixperms:
dh_fixperms
sh debian/fixperms debian
Pinning an owner or group needs root at binary time: add
Rules-Requires-Root: binary-targets to debian/control's source
stanza so dpkg-buildpackage runs binary under fakeroot. A mode-only
pin needs no root. Building with dh_builddeb directly, outside
dpkg-buildpackage, needs DEB_RULES_REQUIRES_ROOT=binary-targets
exported by hand -- dpkg-buildpackage is what normally reads
Rules-Requires-Root and exports it for you.
The skeleton
--skeleton (a directory OUTPUT, and the project file) also writes the files a
package needs around those: control (a stanza per package, Depends:
${misc:Depends}, ${pkgforge:Depends} plus the declared depends),
changelog (one entry, dated from SOURCE_DATE_EPOCH when set, else now),
rules (the stage into debian/tmp recipe below, calling pkgforge --config
CFG) and source/format. It needs name, version, summary and
maintainer, and a summary for each subpackage. These are meant to be edited,
so an existing one is kept with a WARNING unless --force.
dpkg-gencontrol replaces ${NAME} in every field of control, and has no way to
write a literal one: an unknown name becomes empty. --skeleton therefore exits
2 when summary, description, homepage or maintainer of [package] or of a
[[packages]] element holds a ${NAME} (names are letters, digits, - and :),
naming the key and the text; reword it (the project file spells a literal ${
as $${). depends items keep theirs, since
${misc:Depends} is meant to be substituted there. The check runs when the
skeleton is written, not when the project file loads, because rpmspec and
package -f deb take the text as written. The walk-through is
From pkgforge.cfg to packages.
Debian recipes
Both recipes assume debian/control has Rules-Requires-Root:
binary-targets (owner and group pins need root at binary time), a
debhelper-compat (= 13) build dependency, a pkgforge.cfg with [package]
name in the source root (the pkgforge commands read ./pkgforge.cfg), and a
stage.sh that runs the pkgforge install calls. Either way,
override_dh_fixperms runs dh_fixperms first and the generated fixperms
after it, because dh_fixperms would otherwise strip the very bits being
pinned. dh $@ --with installsysusers needs debhelper 13.10 or later (Debian
12, Ubuntu 24.04); it installs the accounts' sysusers.d file into postinst.
Separate stage root. Stage into a directory of your own and hand it to
dh_install with --sourcedir. dh_prep wipes debian/tmp, so a root of
your own survives to dh_install; the build target runs before dh_prep and
dh_installdirs, so P.dirs already exists when dh_installdirs reads it.
dh_prep also deletes debian/P.substvars, so a package with a caps entry
lists libcap2-bin in Depends by hand.
#!/usr/bin/make -f
export PKGFORGE_ROOT := $(CURDIR)/stage
export PKGFORGE_DB := $(CURDIR)/debian/pkgforge.jsonl
%:
dh $@ --with installsysusers
override_dh_auto_build:
sh stage.sh
pkgforge dbdump -f debian debian/
override_dh_install:
dh_install --sourcedir=$(PKGFORGE_ROOT)
override_dh_fixperms:
dh_fixperms
sh debian/fixperms debian
Stage into debian/tmp. dh_install searches debian/tmp by itself, so
no --sourcedir is needed. Stage in override_dh_auto_install, which runs
after dh_prep has wiped debian/tmp and deleted the substvars files, so the
dbdump there writes the P.substvars that ${pkgforge:Depends} reads (list
it in Depends, with ${misc:Depends}). dh_installdirs has already run by
then, before P.dirs existed, so call it again after dbdump; without that
call a directory recorded empty (and any directory pinned only for its mode)
never reaches the package.
#!/usr/bin/make -f
export PKGFORGE_ROOT := $(CURDIR)/debian/tmp
export PKGFORGE_DB := $(CURDIR)/debian/pkgforge.jsonl
%:
dh $@ --with installsysusers
override_dh_auto_install:
sh stage.sh
pkgforge dbdump -f debian debian/
dh_installdirs
override_dh_fixperms:
dh_fixperms
sh debian/fixperms debian
Build time vs install time ownership
An owner or group in Debian's base-passwd set (root, daemon, adm,
www-data, ...) exists on every target, so it is pinned at build time: rpm's
%attr, and the fixperms script for a .deb. Any other name must be declared
in [[users]] or [[groups]] and is owned at
install time, because the account does not exist while the files are unpacked.
pkgforge sysfiles (and stage) writes the payload files that create it:
# /usr/lib/sysusers.d/hello.conf
u hello - "hello example daemon" /var/lib/hello -
# /usr/lib/tmpfiles.d/hello.conf
d /run/hello 0755 hello hello -
The first has a line for each account the package creates; the second a d
line for each ghost directory, so kind = "ghost" on a directory is how a
package gets a runtime directory such as /run/hello.
- rpm 4.19 and later reads the
sysusers.dfile from the payload and creates the accounts before it unpacks, so the plain%attrline owns the file. Every non-root name in an%attralso becomes auser()orgroup()requirement, so a base-passwd name the target's distribution does not provide (www-dataon Fedora) makes the install fail its dependency check. - Debian skips such an entry in
fixperms.#DEBHELPER#inpostinstrunssystemd-sysusers, thensystemd-tmpfiles(build withdh $@ --with installsysusers, debhelper 13.10+); after it the generated lines rundpkg-statoverride --update --addfor the entry, unless the path already has an override, so an administrator's own override is not replaced.postrmremoves the override on purge. An entry of this kind needs a recorded mode and cannot be a symlink (dbdumpexits 1), sincedpkg-statoverrideneeds a mode and follows a link.
Accounts on rpm older than 4.19
Older rpm does not read the sysusers.d file, so the spec creates the accounts
itself in %pre, and the tmpfiles directories in %post. %pre is expanded
when the spec is parsed, before %install has run, so it asks pkgforge
sysfiles for the text instead of reading a staged file. From
examples/hello.spec:
# rpm below 4.19 reads a pre-install script file when it parses the spec,
# before the install step has written anything, so the sysusers text is
# expanded into the script here instead.
%pre
systemd-sysusers - <<'PKGFORGE_SYSUSERS' || :
%(d=$(mktemp -d) && PKGFORGE_ROOT=$d PKGFORGE_DB=- pkgforge -q --config %{proj}/pkgforge.cfg sysfiles >/dev/null && cat $d/usr/lib/sysusers.d/hello.conf; rm -rf $d)
PKGFORGE_SYSUSERS
%post
systemd-tmpfiles --create /usr/lib/tmpfiles.d/hello.conf >/dev/null 2>&1 || :
On rpm 4.19 and later the accounts exist already and the script changes nothing.
Adding a format
A package adds a format by subclassing PerEntryFormat (one file of lines) or
MultiArtifactFormat (several files in a directory) with its own NAME, and
advertising it in the pkgforge.dump_formats entry-point group:
[project.entry-points."pkgforge.dump_formats"]
lines = "myplug:LinesFormat" # name = module:Class, the name equal to NAME
A format renders with render(entries, context), where context is a
DumpContext: the project configuration whose [package] and [[packages]]
name the packages, the config file's path when known, and the OUTPUT being
written. A PerEntryFormat gets the per-package grouping and the
files-PACKAGE.txt directory output for free.
Once installed, dbdump -f lines finds it, and DumpFormat.names() lists it
before anything is imported. A NAME or alias another format holds is a
ValueError when the class is created. A format that fails to load stops the
command with an error naming the entry point.