Repository: systemd/mkosi

Issue:
Idea: add .packages file similar to what kiwi-ng generates
Kiwi-ng has this nice property that it generates sidecar files that provide metadata about the generated image, see https://osinside.github.io/kiwi/image_types_and_results.html#image-results.

In particular I think the following are interesting:
- `<image-name>.<arch>-<version>.packages`: a sorted list of the packages that are included into the image. In fact this is normalized dump of the package manager database. It follows the following cvs format where each line is represented by: `<name>|<epoch>|<version>|<release>|<arch>|<disturl>|<license>`.
  The format is somewhat clunky, but it works. It could be useful to generate the same for compatibility. Or we could generate a json file.
- `*.changelog`: a concatenated changelog of rpms in the image.

Such metadata makes it easier to figure out "what changed" between image builds. In particular, when packages are pulled through transitive deps it can be very helpful.




Discussion:
That a good idea. If I remember correctly @poettering mentioned this idea already in some comment thinking about more reproducible images. I guess this format is as good as any.
i think we should not just have this "manifest" as output, but as input as well, to make things more reproducible, as @behrmann suggested.

See #700.

Resolving patch:
diff --git a/mkosi/__init__.py b/mkosi/__init__.py
--- a/mkosi/__init__.py
+++ b/mkosi/__init__.py
@@ -66,6 +66,7 @@
     ARG_DEBUG,
     CommandLineArguments,
     Distribution,
+    ManifestFormat,
     MkosiException,
     MkosiPrinter,
     OutputFormat,
@@ -76,6 +77,7 @@
     nspawn_params_for_blockdev_access,
     partition,
     patch_file,
+    path_relative_to_cwd,
     run,
     run_workspace_command,
     should_compress_fs,
@@ -87,6 +89,7 @@
     workspace,
     write_grub_config,
 )
+from .manifest import Manifest
 
 complete_step = MkosiPrinter.complete_step
 
@@ -4161,7 +4164,7 @@ def save_cache(args: CommandLineArguments, root: str, raw: Optional[str], cache_
         if cache_path is None:
             return
 
-    with complete_step("Installing cache copy…", f"Successfully installed cache copy {cache_path}"):
+    with complete_step("Installing cache copy…", f"Installed cache copy {path_relative_to_cwd(cache_path)}"):
 
         if disk_rw:
             assert raw is not None
@@ -4186,16 +4189,18 @@ def _link_output(args: CommandLineArguments, oldpath: str, newpath: str) -> None
     if not (sudo_uid and sudo_gid):
         return
 
+    relpath = path_relative_to_cwd(newpath)
+
     sudo_user = os.getenv("SUDO_USER", default=sudo_uid)
     with complete_step(
-        f"Changing ownership of output file {newpath} to user {sudo_user} (acquired from sudo)…",
-        f"Successfully changed ownership of {newpath}",
+        f"Changing ownership of output file {relpath} to user {sudo_user} (acquired from sudo)…",
+        f"Changed ownership of {relpath}",
     ):
         os.chown(newpath, int(sudo_uid), int(sudo_gid))
 
 
 def link_output(args: CommandLineArguments, root: str, artifact: Optional[BinaryIO]) -> None:
-    with complete_step("Linking image file…", f"Successfully linked {args.output}"):
+    with complete_step("Linking image file…", f"Linked {path_relative_to_cwd(args.output)}"):
         if args.output_format in (OutputFormat.directory, OutputFormat.subvolume):
             assert artifact is None
 
@@ -4215,14 +4220,16 @@ def link_output(args: CommandLineArguments, root: str, artifact: Optional[Binary
 def link_output_nspawn_settings(args: CommandLineArguments, path: Optional[SomeIO]) -> None:
     if path:
         assert args.output_nspawn_settings
-        with complete_step("Linking nspawn settings file…", f"Successfully linked {args.output_nspawn_settings}"):
+        with complete_step(
+            "Linking nspawn settings file…", f"Linked {path_relative_to_cwd(args.output_nspawn_settings)}"
+        ):
             _link_output(args, path.name, args.output_nspawn_settings)
 
 
 def link_output_checksum(args: CommandLineArguments, checksum: Optional[SomeIO]) -> None:
     if checksum:
         assert args.output_checksum
-        with complete_step("Linking SHA256SUMS file…", f"Successfully linked {args.output_checksum}"):
+        with complete_step("Linking SHA256SUMS file…", f"Linked {path_relative_to_cwd(args.output_checksum)}"):
             _link_output(args, checksum.name, args.output_checksum)
 
 
@@ -4230,28 +4237,28 @@ def link_output_root_hash_file(args: CommandLineArguments, root_hash_file: Optio
     if root_hash_file:
         assert args.output_root_hash_file
         suffix = roothash_suffix(args.usr_only)
-        with complete_step(f"Linking {suffix} file…", f"Successfully linked {args.output_root_hash_file}"):
+        with complete_step(f"Linking {suffix} file…", f"Linked {path_relative_to_cwd(args.output_root_hash_file)}"):
             _link_output(args, root_hash_file.name, args.output_root_hash_file)
 
 
 def link_output_signature(args: CommandLineArguments, signature: Optional[SomeIO]) -> None:
     if signature:
         assert args.output_signature is not None
-        with complete_step("Linking SHA256SUMS.gpg file…", f"Successfully linked {args.output_signature}"):
+        with complete_step("Linking SHA256SUMS.gpg file…", f"Linked {path_relative_to_cwd(args.output_signature)}"):
             _link_output(args, signature.name, args.output_signature)
 
 
 def link_output_bmap(args: CommandLineArguments, bmap: Optional[SomeIO]) -> None:
     if bmap:
         assert args.output_bmap
-        with complete_step("Linking .bmap file…", f"Successfully linked {args.output_bmap}"):
+        with complete_step("Linking .bmap file…", f"Linked {path_relative_to_cwd(args.output_bmap)}"):
             _link_output(args, bmap.name, args.output_bmap)
 
 
 def link_output_sshkey(args: CommandLineArguments, sshkey: Optional[SomeIO]) -> None:
     if sshkey:
         assert args.output_sshkey
-        with complete_step("Linking private ssh key file…", f"Successfully linked {args.output_sshkey}"):
+        with complete_step("Linking private ssh key file…", f"Linked {path_relative_to_cwd(args.output_sshkey)}"):
             _link_output(args, sshkey.name, args.output_sshkey)
             os.chmod(args.output_sshkey, 0o600)
 
@@ -4259,21 +4266,23 @@ def link_output_sshkey(args: CommandLineArguments, sshkey: Optional[SomeIO]) ->
 def link_output_split_root(args: CommandLineArguments, split_root: Optional[SomeIO]) -> None:
     if split_root:
         assert args.output_split_root
-        with complete_step("Linking split root file system…", f"Successfully linked {args.output_split_root}"):
+        with complete_step(
+            "Linking split root file system…", f"Linked {path_relative_to_cwd(args.output_split_root)}"
+        ):
             _link_output(args, split_root.name, args.output_split_root)
 
 
 def link_output_split_verity(args: CommandLineArguments, split_verity: Optional[SomeIO]) -> None:
     if split_verity:
         assert args.output_split_verity
-        with complete_step("Linking split Verity data…", f"Successfully linked {args.output_split_verity}"):
+        with complete_step("Linking split Verity data…", f"Linked {path_relative_to_cwd(args.output_split_verity)}"):
             _link_output(args, split_verity.name, args.output_split_verity)
 
 
 def link_output_split_kernel(args: CommandLineArguments, split_kernel: Optional[SomeIO]) -> None:
     if split_kernel:
         assert args.output_split_kernel
-        with complete_step("Linking split kernel image…", f"Successfully linked {args.output_split_kernel}"):
+        with complete_step("Linking split kernel image…", f"Linked {path_relative_to_cwd(args.output_split_kernel)}"):
             _link_output(args, split_kernel.name, args.output_split_kernel)
 
 
@@ -4292,6 +4301,41 @@ def dir_size(path: str) -> int:
     return dir_sum
 
 
+def save_manifest(args: CommandLineArguments, manifest: Manifest) -> None:
+    if manifest.has_data():
+        relpath = path_relative_to_cwd(args.output)
+
+        if ManifestFormat.json in args.manifest_format:
+            with complete_step(f"Saving manifest {relpath}.manifest"):
+                f: TextIO = cast(
+                    TextIO,
+                    tempfile.NamedTemporaryFile(
+                        mode="w+",
+                        encoding="utf-8",
+                        prefix=".mkosi-",
+                        dir=os.path.dirname(args.output),
+                    ),
+                )
+                with f:
+                    manifest.write_json(f)
+                    _link_output(args, f.name, f"{args.output}.manifest")
+
+        if ManifestFormat.changelog in args.manifest_format:
+            with complete_step(f"Saving report {relpath}.packages"):
+                g: TextIO = cast(
+                    TextIO,
+                    tempfile.NamedTemporaryFile(
+                        mode="w+",
+                        encoding="utf-8",
+                        prefix=".mkosi-",
+                        dir=os.path.dirname(args.output),
+                    ),
+                )
+                with g:
+                    manifest.write_package_report(g)
+                    _link_output(args, g.name, f"{relpath}.packages")
+
+
 def print_output_size(args: CommandLineArguments) -> None:
     if args.output_format in (OutputFormat.directory, OutputFormat.subvolume):
         MkosiPrinter.print_step("Resulting image size is " + format_bytes(dir_size(args.output)) + ".")
@@ -4318,6 +4362,12 @@ def setup_package_cache(args: CommandLineArguments) -> Optional[TempDir]:
     return d
 
 
+def remove_duplicates(items: List[T]) -> List[T]:
+    "Return list with any repetitions removed"
+    # We use a dictionary to simulate an ordered set
+    return list({x: None for x in items})
+
+
 class ListAction(argparse.Action):
     delimiter: str
 
@@ -4354,11 +4404,13 @@ def __call__(
             # Remove ! prefixed list entries from list. !* removes all entries. This works for strings only now.
             if x == "!*":
                 ary = []
-            elif x.startswith("!"):
+            elif isinstance(x, str) and x.startswith("!"):
                 if x[1:] in ary:
                     ary.remove(x[1:])
             else:
                 ary.append(x)
+
+        ary = remove_duplicates(ary)
         setattr(namespace, self.dest, ary)
 
 
@@ -4602,7 +4654,6 @@ def create_parser() -> ArgumentParserMkosi:
     group.add_argument(
         "--repositories",
         action=CommaDelimitedListAction,
-        dest="repositories",
         default=[],
         help="Repositories to use",
         metavar="REPOS",
@@ -4618,6 +4669,12 @@ def create_parser() -> ArgumentParserMkosi:
         type=OutputFormat.from_string,
         help="Output Format",
     )
+    group.add_argument(
+        "--manifest-format",
+        action=CommaDelimitedListAction,
+        type=cast(Callable[[str], ManifestFormat], ManifestFormat.parse_list),
+        help="Manifest Format",
+    )
     group.add_argument("-o", "--output", help="Output image path", metavar="PATH")
     group.add_argument(
         "--output-split-root",
@@ -5286,6 +5343,8 @@ def unlink_output(args: CommandLineArguments) -> None:
     if not args.skip_final_phase:
         with complete_step("Removing output files…"):
             unlink_try_hard(args.output)
+            unlink_try_hard(f"{args.output}.manifest")
+            unlink_try_hard(f"{args.output}.packages")
 
             if args.checksum:
                 unlink_try_hard(args.output_checksum)
@@ -5685,6 +5744,9 @@ def load_args(args: argparse.Namespace) -> CommandLineArguments:
         else:
             args.output = prefix
 
+    if args.manifest_format is None:
+        args.manifest_format = [ManifestFormat.json]
+
     if args.output_dir is not None:
         args.output_dir = os.path.abspath(args.output_dir)
 
@@ -5995,6 +6057,8 @@ def print_summary(args: CommandLineArguments) -> None:
     if args.image_version is not None:
         MkosiPrinter.info("             Image Version: " + args.image_version)
     MkosiPrinter.info("             Output Format: " + args.output_format.name)
+    maniformats = (" ".join(str(i) for i in args.manifest_format)) or "(none)"
+    MkosiPrinter.info("          Manifest Formats: " + maniformats)
     if args.output_format.can_minimize():
         MkosiPrinter.info("                  Minimize: " + yes_no(args.minimize))
     if args.output_dir:
@@ -6260,7 +6324,13 @@ def empty(cls) -> "BuildOutput":
 
 
 def build_image(
-    args: CommandLineArguments, root: str, *, do_run_build_script: bool, for_cache: bool = False, cleanup: bool = False
+    args: CommandLineArguments,
+    root: str,
+    *,
+    manifest: Optional[Manifest] = None,
+    do_run_build_script: bool,
+    for_cache: bool = False,
+    cleanup: bool = False,
 ) -> BuildOutput:
     # If there's no build script set, there's no point in executing
     # the build script iteration. Let's quit early.
@@ -6335,6 +6405,10 @@ def build_image(
                 setup_network_veth(args, root, do_run_build_script, cached_tree)
                 run_postinst_script(args, root, loopdev, do_run_build_script, for_cache)
 
+                if manifest:
+                    with complete_step("Recording packages in manifest…"):
+                        manifest.record_packages(root)
+
                 if cleanup:
                     clean_package_manager_metadata(root)
                     remove_files(args, root)
@@ -6509,12 +6583,13 @@ def remove_artifacts(
             unlink_try_hard(root_home(args, root))
 
 
-def build_stuff(args: CommandLineArguments) -> None:
+def build_stuff(args: CommandLineArguments) -> Manifest:
     make_output_dir(args)
     setup_package_cache(args)
     workspace = setup_workspace(args)
 
     image = BuildOutput.empty()
+    manifest = Manifest(args)
 
     # Make sure tmpfiles' aging doesn't interfere with our workspace
     # while we are working on it.
@@ -6553,7 +6628,7 @@ def build_stuff(args: CommandLineArguments) -> None:
         # Run the image builder for the second (final) stage
         if not args.skip_final_phase:
             with complete_step("Running second (final) stage…"):
-                image = build_image(args, root, do_run_build_script=False, cleanup=True)
+                image = build_image(args, root, manifest=manifest, do_run_build_script=False, cleanup=True)
         else:
             MkosiPrinter.print_step("Skipping (second) final image build phase.")
 
@@ -6585,6 +6660,8 @@ def build_stuff(args: CommandLineArguments) -> None:
         if image.root_hash is not None:
             MkosiPrinter.print_step(f"Root hash is {image.root_hash}.")
 
+        return manifest
+
 
 def check_root() -> None:
     if os.getuid() != 0:
@@ -7120,12 +7197,15 @@ def run_verb(raw: argparse.Namespace) -> None:
         check_root()
         check_native(args)
         init_namespace(args)
-        build_stuff(args)
-        print_output_size(args)
+        manifest = build_stuff(args)
 
         if args.auto_bump:
             bump_image_version(args)
 
+        save_manifest(args, manifest)
+
+        print_output_size(args)
+
     if args.verb in ("shell", "boot"):
         run_shell(args)
 
diff --git a/mkosi/backend.py b/mkosi/backend.py
--- a/mkosi/backend.py
+++ b/mkosi/backend.py
@@ -5,6 +5,7 @@
 import dataclasses
 import enum
 import os
+import pathlib
 import shlex
 import shutil
 import signal
@@ -23,6 +24,7 @@
     NoReturn,
     Optional,
     Union,
+    cast,
 )
 
 if sys.version_info >= (3, 8):
@@ -52,18 +54,57 @@ class MkosiException(Exception):
 ARG_DEBUG = ()
 
 
+class Parseable:
+    "A mix-in to provide conversions for argparse"
+
+    def __repr__(self) -> str:
+        """Return the member name without the class name"""
+        return cast(str, getattr(self, "name"))
+
+    def __str__(self) -> str:
+        return self.__repr__()
+
+    @classmethod
+    def from_string(cls: Any, name: str) -> Any:
+        """A convenience method to be used with argparse"""
+        try:
+            return cls[name]
+        except KeyError:
+            raise argparse.ArgumentTypeError(f"unknown Format: {name!r}")
+
+    @classmethod
+    def parse_list(cls: Any, string: str) -> List[Any]:
+        return [cls.from_string(p) for p in string.split(",") if p]
+
+
+class PackageType(enum.Enum):
+    rpm = 1
+    deb = 2
+    pkg = 3
+    bundle = 4
+
+
 class Distribution(enum.Enum):
-    fedora = 1
-    debian = 2
-    ubuntu = 3
-    arch = 4
-    opensuse = 5
-    mageia = 6
-    centos = 7
-    centos_epel = 8
-    clear = 9
-    photon = 10
-    openmandriva = 11
+    fedora = 0, PackageType.rpm
+    debian = 1, PackageType.deb
+    ubuntu = 2, PackageType.deb
+    arch = 3, PackageType.pkg
+    opensuse = 4, PackageType.rpm
+    mageia = 5, PackageType.rpm
+    centos = 6, PackageType.rpm
+    centos_epel = 7, PackageType.rpm
+    clear = 8, PackageType.bundle
+    photon = 9, PackageType.rpm
+    openmandriva = 10, PackageType.rpm
+
+    def __new__(cls, number: int, package_type: PackageType) -> "Distribution":
+        # This turns the list above into enum entries with .package_type attributes.
+        # See https://docs.python.org/3.9/library/enum.html#when-to-use-new-vs-init
+        # for an explanation.
+        entry = object.__new__(cls)
+        entry._value_ = number
+        entry.package_type = package_type
+        return cast("Distribution", entry)
 
     def __str__(self) -> str:
         return self.name
@@ -90,7 +131,7 @@ def doc(cls) -> Dict["SourceFileTransfer", str]:
         }
 
 
-class OutputFormat(enum.Enum):
+class OutputFormat(Parseable, enum.Enum):
     directory = enum.auto()
     subvolume = enum.auto()
     tar = enum.auto()
@@ -109,22 +150,6 @@ class OutputFormat(enum.Enum):
     raw_btrfs = gpt_btrfs
     raw_squashfs = gpt_squashfs
 
-    def __repr__(self) -> str:
-        """Return the member name without the class name"""
-        return self.name
-
-    def __str__(self) -> str:
-        """Return the member name without the class name"""
-        return self.name
-
-    @classmethod
-    def from_string(cls, name: str) -> "OutputFormat":
-        """A convenience method to be used with argparse"""
-        try:
-            return cls[name]
-        except KeyError:
-            raise argparse.ArgumentTypeError(f"unknown Format: {name!r}")
-
     def is_disk_rw(self) -> bool:
         "Output format is a disk image with a parition table and a writable filesystem"
         return self in (OutputFormat.gpt_ext4, OutputFormat.gpt_xfs, OutputFormat.gpt_btrfs)
@@ -159,6 +184,11 @@ def has_fs_compression(self) -> bool:
         return self.is_squashfs() or self.is_btrfs()
 
 
+class ManifestFormat(Parseable, enum.Enum):
+    json = "json"  # the standard manifest in json format
+    changelog = "changelog"  # human-readable text file with package changelogs
+
+
 @dataclasses.dataclass
 class CommandLineArguments:
     """Type-hinted storage for command line arguments."""
@@ -172,6 +202,7 @@ class CommandLineArguments:
     repositories: List[str]
     architecture: Optional[str]
     output_format: OutputFormat
+    manifest_format: List[ManifestFormat]
     output: str
     output_dir: Optional[str]
     force_count: int
@@ -511,6 +542,16 @@ def patch_file(filepath: str, line_rewriter: Callable[[str], str]) -> None:
     shutil.move(temp_new_filepath, filepath)
 
 
+def path_relative_to_cwd(path: str) -> pathlib.Path:
+    "Return path as relative to $PWD if underneath, absolute path otherwise"
+
+    path = pathlib.Path(path)
+    try:
+        return path.relative_to(os.getcwd())
+    except ValueError:
+        return path
+
+
 def write_grub_config(args: CommandLineArguments, root: str) -> None:
     kernel_cmd_line = " ".join(args.kernel_command_line)
     grub_cmdline = f'GRUB_CMDLINE_LINUX="{kernel_cmd_line}"\n'
diff --git a/mkosi/manifest.py b/mkosi/manifest.py
new file mode 100644
--- /dev/null
+++ b/mkosi/manifest.py
@@ -0,0 +1,127 @@
+# SPDX-License-Identifier: LGPL-2.1+
+
+import dataclasses
+import json
+from subprocess import DEVNULL, PIPE
+from textwrap import dedent
+from typing import IO, Any, Dict, List, Optional, cast
+
+from .backend import CommandLineArguments, PackageType, run
+
+
+@dataclasses.dataclass
+class PackageManifest:
+    """A description of a package
+
+    The fields used here must match
+    https://systemd.io/COREDUMP_PACKAGE_METADATA/#well-known-keys.
+    """
+
+    type: str
+    name: str
+    version: str
+    size: int
+
+    def as_dict(self) -> Dict[str, str]:
+        return {
+            "type": self.type,
+            "name": self.name,
+            "version": self.version,
+        }
+
+
+@dataclasses.dataclass
+class SourcePackageManifest:
+    name: str
+    changelog: Optional[str]
+    packages: List[PackageManifest] = dataclasses.field(default_factory=list)
+
+    def add(self, package: PackageManifest) -> None:
+        self.packages.append(package)
+
+    def report(self) -> str:
+        size = sum(p.size for p in self.packages)
+
+        t = dedent(
+            f"""\
+            SourcePackage: {self.name}
+            Packages:      {" ".join(p.name for p in self.packages)}
+            Size:          {size}
+            """
+        )
+        if self.changelog:
+            t += f"""\nChangelog:\n{self.changelog}\n"""
+        return t
+
+
+@dataclasses.dataclass
+class Manifest:
+    args: CommandLineArguments
+    packages: List[PackageManifest] = dataclasses.field(default_factory=list)
+    source_packages: Dict[str, SourcePackageManifest] = dataclasses.field(default_factory=dict)
+
+    def record_packages(self, root: str) -> None:
+        if cast(Any, self.args.distribution).package_type == PackageType.rpm:
+            self.record_rpm_packages(root)
+        # TODO: add implementations for other package managers
+
+    def record_rpm_packages(self, root: str) -> None:
+        c = run(
+            ["rpm", f"--root={root}", "-qa", "--qf", r"%{NEVRA}\t%{SOURCERPM}\t%{NAME}\t%{SIZE}\n"],
+            stdout=PIPE,
+            stderr=DEVNULL,
+            universal_newlines=True,
+        )
+
+        packages = sorted(c.stdout.splitlines())
+
+        for package in packages:
+            nevra, srpm, name, size = package.split("\t")
+
+            assert nevra.startswith(f"{name}-")
+            evra = nevra[len(name) + 1 :]
+
+            size = int(size)
+
+            source = self.source_packages.get(srpm)
+            if source is None:
+                c = run(
+                    ["rpm", f"--root={root}", "-q", "--changelog", nevra],
+                    stdout=PIPE,
+                    stderr=DEVNULL,
+                    universal_newlines=True,
+                )
+                changelog = c.stdout.strip()
+                source = SourcePackageManifest(srpm, changelog)
+                self.source_packages[srpm] = source
+
+            package = PackageManifest("rpm", name, evra, size)
+            self.packages.append(package)
+            source.add(package)
+
+    def has_data(self) -> bool:
+        # We might add more data in the future
+        return len(self.packages) > 0
+
+    def as_dict(self) -> Dict[str, Any]:
+        return {
+            "packages": [package.as_dict() for package in self.packages],
+        }
+
+    def write_json(self, out: IO[str]) -> None:
+        json.dump(self.as_dict(), out, indent=2)
+
+    def write_package_report(self, out: IO[str]) -> None:
+        """Create a human-readable report about packages
+
+        This is modelled after "Fedora compose reports" that are sent
+        to fedora-devel. The format describes added and removed
+        packages, and includes the changelogs. A diff between two such
+        reports shows what changed *in* the packages quite nicely.
+        """
+        print(f"Packages: {len(self.packages)}", file=out)
+        print(f"Size:     {sum(p.size for p in self.packages)}", file=out)
+
+        for package in self.source_packages.values():
+            print(f"\n{80*'-'}\n", file=out)
+            out.write(package.report())


Test patch:
diff --git a/tests/test_backend.py b/tests/test_backend.py
new file mode 100644
--- /dev/null
+++ b/tests/test_backend.py
@@ -0,0 +1,10 @@
+# SPDX-License-Identifier: LGPL-2.1+
+
+from mkosi.backend import PackageType, Distribution
+
+
+def test_distribution():
+    assert Distribution.fedora.package_type == PackageType.rpm
+    assert Distribution.fedora is Distribution.fedora
+    assert Distribution.fedora is not Distribution.debian
+    assert str(Distribution.photon) == "photon"
diff --git a/tests/test_config_parser.py b/tests/test_config_parser.py
--- a/tests/test_config_parser.py
+++ b/tests/test_config_parser.py
@@ -76,6 +76,7 @@ def add_reference_config(self, job_name=DEFAULT_JOB_NAME):
             "install_dir": None,
             "kernel_command_line": ["rhgb", "selinux=0", "audit=0"],
             "key": None,
+            "manifest_format": None,
             "mirror": None,
             "mksquashfs_tool": [],
             "no_chown": False,
@@ -168,7 +169,7 @@ def _append_list(self, ref_entry, new_args, job_name=DEFAULT_JOB_NAME, separator
             if isinstance(arg, str) and arg.startswith("!"):
                 if arg[1:] in self.reference_config[job_name][ref_entry]:
                     self.reference_config[job_name][ref_entry].remove(arg[1:])
-            else:
+            elif arg not in self.reference_config[job_name][ref_entry]:
                 self.reference_config[job_name][ref_entry].append(arg)
 
     @staticmethod
@@ -186,7 +187,7 @@ def write_ini(dname: str, fname: str, config: dict, prio=1000) -> None:
         for section, key_val in config_all_normalized.items():
             for key, val in key_val.items():
                 if isinstance(val, list):
-                    config_all_normalized[section][key] = os.linesep.join(val)
+                    config_all_normalized[section][key] = os.linesep.join(str(item) for item in val)
 
         config_parser.read_dict(config_all_normalized)
         with open(os.path.join(dname, fname), "w") as f_ini:
@@ -215,6 +216,8 @@ def _update_ref_from_file(self, mk_config: dict, job_name=DEFAULT_JOB_NAME) -> N
                 self.reference_config[job_name]["output_format"] = mkosi.OutputFormat.from_string(
                     mk_config_output["Format"]
                 )
+            if "ManifestFormat" in mk_config_output:
+                self.reference_config[job_name]["manifest_format"] = mk_config_output["ManifestFormat"]
             if "Output" in mk_config_output:
                 self.reference_config[job_name]["output"] = mk_config_output["Output"]
             if "Force" in mk_config_output:
@@ -512,6 +515,7 @@ def prepare_mkosi_default(self, directory: str) -> None:
             "Output": {
                 "Format": "gpt_ext4",
                 "Output": "test_image.raw",
+                "ManifestFormat": [mkosi.backend.ManifestFormat.json],
                 #                 # 'OutputDirectory': '',
                 "Bootable": False,
                 "BootProtocols": "uefi",