Repository: QubesOS/qubes-builderv2
Pull request #191: Adjust job ordering, and fix build_windows artifact naming
Author: login: marmarek
Created: 2025-03-20T22:01:22Z | Merged: 2025-03-27T00:21:27Z

Prerequisite for cross-distro dependencies to work also with a single qb call.


Diff:
diff --git a/qubesbuilder/cli/cli_package.py b/qubesbuilder/cli/cli_package.py
index 217a0e5c..3fcae4e1 100644
--- a/qubesbuilder/cli/cli_package.py
+++ b/qubesbuilder/cli/cli_package.py
@@ -22,18 +22,18 @@ def _component_stage(
     config: Config,
     components: List[QubesComponent],
     distributions: List[QubesDistribution],
-    stage_name: str,
+    stages: List[str],
 ):
     """
     Generic function to trigger stage for a standard component
     """
-    QubesBuilderLogger.info(f"Running stage '{stage_name}'")
+    QubesBuilderLogger.info(f"Running stages: {', '.join(stages)}")
 
     for job in config.get_jobs(
         components=components,
         distributions=distributions,
         templates=[],
-        stage=stage_name,
+        stages=stages,
     ):
         job.run()
 
@@ -44,13 +44,22 @@ def _all_package_stage(obj: ContextObj):
     stages = obj.config.get_stages()
     if obj.config.automatic_upload_on_publish:
         stages.remove("upload")
-    for stage in stages:
+    # run "fetch" first as other stages may depend on configuration fetched
+    # by it
+    if "fetch" in stages:
         _component_stage(
             config=obj.config,
             components=obj.components,
             distributions=obj.distributions,
-            stage_name=stage,
+            stages=["fetch"],
         )
+        stages.remove("fetch")
+    _component_stage(
+        config=obj.config,
+        components=obj.components,
+        distributions=obj.distributions,
+        stages=stages,
+    )
 
 
 @package.command()
@@ -60,7 +69,7 @@ def fetch(obj: ContextObj):
         config=obj.config,
         components=obj.components,
         distributions=obj.distributions,
-        stage_name="fetch",
+        stages=["fetch"],
     )
 
 
@@ -71,7 +80,7 @@ def prep(obj: ContextObj):
         config=obj.config,
         components=obj.components,
         distributions=obj.distributions,
-        stage_name="prep",
+        stages=["prep"],
     )
 
 
@@ -82,7 +91,7 @@ def build(obj: ContextObj):
         config=obj.config,
         components=obj.components,
         distributions=obj.distributions,
-        stage_name="build",
+        stages=["build"],
     )
 
 
@@ -93,7 +102,7 @@ def post(obj: ContextObj):
         config=obj.config,
         components=obj.components,
         distributions=obj.distributions,
-        stage_name="post",
+        stages=["post"],
     )
 
 
@@ -104,7 +113,7 @@ def verify(obj: ContextObj):
         config=obj.config,
         components=obj.components,
         distributions=obj.distributions,
-        stage_name="verify",
+        stages=["verify"],
     )
 
 
@@ -115,7 +124,7 @@ def sign(obj: ContextObj):
         config=obj.config,
         components=obj.components,
         distributions=obj.distributions,
-        stage_name="sign",
+        stages=["sign"],
     )
 
 
@@ -126,14 +135,14 @@ def publish(obj: ContextObj):
         config=obj.config,
         components=obj.components,
         distributions=obj.distributions,
-        stage_name="publish",
+        stages=["publish"],
     )
     if obj.config.automatic_upload_on_publish:
         _component_stage(
             config=obj.config,
             components=obj.components,
             distributions=obj.distributions,
-            stage_name="upload",
+            stages=["upload"],
         )
 
 
@@ -144,7 +153,7 @@ def upload(obj: ContextObj):
         config=obj.config,
         components=obj.components,
         distributions=obj.distributions,
-        stage_name="upload",
+        stages=["upload"],
     )
 
 
@@ -155,7 +164,7 @@ def init_cache(obj: ContextObj):
         config=obj.config,
         components=obj.components,
         distributions=obj.distributions,
-        stage_name="init-cache",
+        stages=["init-cache"],
     )
 
 
diff --git a/qubesbuilder/cli/cli_repository.py b/qubesbuilder/cli/cli_repository.py
index 81ccbe75..265a5ea7 100644
--- a/qubesbuilder/cli/cli_repository.py
+++ b/qubesbuilder/cli/cli_repository.py
@@ -40,14 +40,14 @@ def _publish(
             components=components,
             distributions=distributions,
             templates=[],
-            stage="publish",
+            stages=["publish"],
         )
     elif repository_publish in TEMPLATE_REPOSITORIES:
         jobs = config.get_jobs(
             templates=templates,
             components=[],
             distributions=[],
-            stage="publish",
+            stages=["publish"],
         )
     else:
         raise CliError(f"Unknown repository '{repository_publish}'")
@@ -374,14 +374,14 @@ def _upload(
             distributions=distributions,
             components=[],
             templates=[],
-            stage="upload",
+            stages=["upload"],
         )
     elif repository_publish in TEMPLATE_REPOSITORIES:
         jobs = config.get_jobs(
             templates=templates,
             distributions=[],
             components=[],
-            stage="upload",
+            stages=["upload"],
         )
     for job in jobs:
         job.run(repository_publish=repository_publish)
@@ -396,7 +396,7 @@ def _upload(
 def create(obj: ContextObj, repository_publish: str):
     _publish(
         config=obj.config,
-        components=obj.components,
+        components=[],
         distributions=obj.distributions,
         templates=obj.templates,
         repository_publish=repository_publish,
diff --git a/qubesbuilder/cli/cli_template.py b/qubesbuilder/cli/cli_template.py
index 7da60f42..d85e5101 100644
--- a/qubesbuilder/cli/cli_template.py
+++ b/qubesbuilder/cli/cli_template.py
@@ -18,17 +18,17 @@ def template():
 def _template_stage(
     config: Config,
     templates: List[QubesTemplate],
-    stage_name: str,
+    stages: List[str],
     template_timestamp: Optional[str] = None,
 ):
     """
     Generic function to trigger stage for a template component
     """
-    click.echo(f"Running template stage: {stage_name}")
+    click.echo(f"Running template stages: {', '.join(stages)}")
 
     # Qubes templates
     jobs = config.get_jobs(
-        templates=templates, components=[], distributions=[], stage=stage_name
+        templates=templates, components=[], distributions=[], stages=stages
     )
     for job in jobs:
         job.run(template_timestamp=template_timestamp)
@@ -40,12 +40,11 @@ def _all_template_stage(obj: ContextObj):
     stages = STAGES
     if obj.config.automatic_upload_on_publish:
         stages.remove("upload")
-    for s in stages:
-        _template_stage(
-            config=obj.config,
-            templates=obj.templates,
-            stage_name=s,
-        )
+    _template_stage(
+        config=obj.config,
+        templates=obj.templates,
+        stages=stages,
+    )
 
 
 @template.command()
@@ -54,7 +53,7 @@ def fetch(obj: ContextObj):
     _template_stage(
         config=obj.config,
         templates=obj.templates,
-        stage_name="fetch",
+        stages=["fetch"],
     )
 
 
@@ -69,7 +68,7 @@ def prep(obj: ContextObj, template_timestamp: str):
     _template_stage(
         config=obj.config,
         templates=obj.templates,
-        stage_name="prep",
+        stages=["prep"],
         template_timestamp=template_timestamp,
     )
 
@@ -80,7 +79,7 @@ def build(obj: ContextObj):
     _template_stage(
         config=obj.config,
         templates=obj.templates,
-        stage_name="build",
+        stages=["build"],
     )
 
 
@@ -90,7 +89,7 @@ def post(obj: ContextObj):
     _template_stage(
         config=obj.config,
         templates=obj.templates,
-        stage_name="post",
+        stages=["post"],
     )
 
 
@@ -100,7 +99,7 @@ def verify(obj: ContextObj):
     _template_stage(
         config=obj.config,
         templates=obj.templates,
-        stage_name="verify",
+        stages=["verify"],
     )
 
 
@@ -110,7 +109,7 @@ def sign(obj: ContextObj):
     _template_stage(
         config=obj.config,
         templates=obj.templates,
-        stage_name="sign",
+        stages=["sign"],
     )
 
 
@@ -120,13 +119,13 @@ def publish(obj: ContextObj):
     _template_stage(
         config=obj.config,
         templates=obj.templates,
-        stage_name="publish",
+        stages=["publish"],
     )
     if obj.config.automatic_upload_on_publish:
         _template_stage(
             config=obj.config,
             templates=obj.templates,
-            stage_name="upload",
+            stages=["upload"],
         )
 
 
@@ -136,7 +135,7 @@ def upload(obj: ContextObj):
     _template_stage(
         config=obj.config,
         templates=obj.templates,
-        stage_name="upload",
+        stages=["upload"],
     )
 
 
diff --git a/qubesbuilder/component.py b/qubesbuilder/component.py
index 3f6b264c..d1df8e74 100644
--- a/qubesbuilder/component.py
+++ b/qubesbuilder/component.py
@@ -315,5 +315,8 @@ def __repr__(self):
     def __eq__(self, other):
         return repr(self) == repr(other)
 
+    def __hash__(self):
+        return hash(self.name)
+
     def __str__(self):
         return self.to_str()
diff --git a/qubesbuilder/config.py b/qubesbuilder/config.py
index deccc480..5d645bd4 100644
--- a/qubesbuilder/config.py
+++ b/qubesbuilder/config.py
@@ -18,6 +18,7 @@
 # SPDX-License-Identifier: GPL-3.0-or-later
 import re
 from copy import deepcopy
+from graphlib import TopologicalSorter
 from pathlib import Path
 from typing import Union, List, Dict, Any
 
@@ -43,6 +44,7 @@
     TemplatePlugin,
     JobReference,
     JobDependency,
+    Plugin,
 )
 from qubesbuilder.template import QubesTemplate
 from qubesbuilder.log import QubesBuilderLogger
@@ -280,13 +282,15 @@ def get_distributions(self, filtered_distributions=None):
                 self._dists.append(QubesDistribution(dist_name, **dist_options))
         if filtered_distributions:
             result = []
-            for fd in filtered_distributions:
-                for d in self._dists:
-                    if d.distribution == fd:
-                        result.append(d)
-                        break
-                else:
-                    raise ConfigError(f"No such distribution: {fd}")
+            filtered_distributions = set(filtered_distributions)
+            for d in self._dists:
+                if d.distribution in filtered_distributions:
+                    result.append(d)
+                    filtered_distributions.remove(d.distribution)
+            if filtered_distributions:
+                raise ConfigError(
+                    f"No such distribution: {', '.join(filtered_distributions)}"
+                )
             return result
         return self._dists
 
@@ -627,20 +631,20 @@ def get_needs(
         self,
         component: QubesComponent,
         dist: QubesDistribution,
-        stage_name: str,
+        stage: str,
     ):
         needs = []
         stages = component.kwargs.get(dist.distribution, {}).get("stages", [])
-        for stage in stages:
-            if isinstance(stage, dict):
-                if next(iter(stage)) != stage_name:
+        for stage_config in stages:
+            if isinstance(stage_config, dict):
+                if next(iter(stage_config)) != stage:
                     continue
-                if not isinstance(stage[stage_name], dict):
+                if not isinstance(stage_config[stage], dict):
                     QubesBuilderLogger.warning(
-                        f"{component}:{dist}: Cannot parse provided stage '{str(stage)}'. Check stage format."
+                        f"{component}:{dist}: Cannot parse provided stage '{str(stage_config)}'. Check stage format."
                     )
                     continue
-                for need in stage[stage_name].get("needs", []):
+                for need in stage_config[stage].get("needs", []):
                     if all(
                         [
                             need.get("component", None),
@@ -680,7 +684,7 @@ def get_needs(
                         )
             else:
                 QubesBuilderLogger.warning(
-                    f"{component}:{dist}: Cannot parse provided stage '{str(stage)}'. Check stage format."
+                    f"{component}:{dist}: Cannot parse provided stage '{str(stage_config)}'. Check stage format."
                 )
         return needs
 
@@ -689,82 +693,164 @@ def get_jobs(
         components: List[QubesComponent],
         distributions: List[QubesDistribution],
         templates: List[QubesTemplate],
-        stage: str,
+        stages: List[str],
     ):
+        """
+        Collects jobs related to given constraints.
+        First collec jobs according to stage orders. But then,
+        apply topological sorting based on defined dependencies that will
+        possibly reorder jobs to satisfy dependencies.
+        """
 
         manager = self.get_plugin_manager()
         plugins = manager.get_plugins()
-        jobs = []
+        jobs: List[Plugin] = []
+        # while collecting jobs, collect also dependency objects for later use
+        depencies_dict: dict[JobReference, Plugin] = {}
 
-        # DistributionComponentPlugin
-        for distribution in distributions:
+        for stage in stages:
+            # DistributionComponentPlugin
+            for distribution in distributions:
+                for component in components:
+                    for plugin in plugins:
+                        if "DistributionComponentPlugin" in [
+                            c.__name__ for c in plugin.__mro__
+                        ]:
+                            job = plugin.from_args(
+                                dist=distribution,
+                                component=component,
+                                config=self,
+                                stage=stage,
+                            )
+                            if not job:
+                                continue
+                            job.dependencies += self.get_needs(
+                                component=component,
+                                dist=distribution,
+                                stage=stage,
+                            )
+                            depencies_dict[
+                                JobReference(
+                                    component=component,
+                                    dist=distribution,
+                                    template=None,
+                                    stage=stage,
+                                    build=None,
+                                )
+                            ] = job
+                            jobs.append(job)
+
+            # ComponentPlugin
             for component in components:
                 for plugin in plugins:
-                    if "DistributionComponentPlugin" in [
-                        c.__name__ for c in plugin.__mro__
-                    ]:
+                    classes = [c.__name__ for c in plugin.__mro__]
+                    if (
+                        "ComponentPlugin" in classes
+                        and "DistributionComponentPlugin" not in classes
+                    ):
                         job = plugin.from_args(
-                            dist=distribution,
                             component=component,
                             config=self,
                             stage=stage,
                         )
                         if not job:
                             continue
-                        job.dependencies += self.get_needs(
-                            component=component,
+                        depencies_dict[
+                            JobReference(
+                                component=component,
+                                dist=None,
+                                template=None,
+                                stage=stage,
+                                build=None,
+                            )
+                        ] = job
+                        jobs.append(job)
+
+            # DistributionPlugin
+            for distribution in distributions:
+                for plugin in plugins:
+                    classes = [c.__name__ for c in plugin.__mro__]
+                    if (
+                        "DistributionPlugin" in classes
+                        and "DistributionComponentPlugin" not in classes
+                        and "TemplatePlugin" not in classes
+                    ):
+                        job = plugin.from_args(
                             dist=distribution,
-                            stage_name=stage,
+                            config=self,
+                            stage=stage,
                         )
+                        if not job:
+                            continue
+                        depencies_dict[
+                            JobReference(
+                                component=None,
+                                dist=distribution,
+                                template=None,
+                                stage=stage,
+                                build=None,
+                            )
+                        ] = job
                         jobs.append(job)
 
-        # ComponentPlugin
-        for component in components:
-            for plugin in plugins:
-                classes = [c.__name__ for c in plugin.__mro__]
-                if (
-                    "ComponentPlugin" in classes
-                    and "DistributionComponentPlugin" not in classes
-                ):
-                    job = plugin.from_args(
-                        component=component,
-                        config=self,
-                        stage=stage,
-                    )
-                    if not job:
-                        continue
-                    jobs.append(job)
-
-        # DistributionPlugin
-        for distribution in distributions:
-            for plugin in plugins:
-                classes = [c.__name__ for c in plugin.__mro__]
-                if (
-                    "DistributionPlugin" in classes
-                    and "DistributionComponentPlugin" not in classes
-                    and "TemplatePlugin" not in classes
-                ):
-                    job = plugin.from_args(
-                        dist=distribution,
-                        config=self,
-                        stage=stage,
-                    )
-                    if not job:
+            # TemplatePlugin
+            for template in templates:
+                for plugin in plugins:
+                    classes = [c.__name__ for c in plugin.__mro__]
+                    if "TemplatePlugin" in classes:
+                        job = plugin.from_args(
+                            template=template,
+                            config=self,
+                            stage=stage,
+                        )
+                        if not job:
+                            continue
+                        depencies_dict[
+                            JobReference(
+                                component=None,
+                                dist=None,
+                                template=template,
+                                stage=stage,
+                                build=None,
+                            )
+                        ] = job
+                        jobs.append(job)
+
+        # and finally, sort topologically to resolve any dependencies
+        graph = {}
+        for job in jobs:
+            deps = []
+            for dep in job.dependencies:
+                if dep.builder_object == "job":
+                    try:
+                        # don't care about "build" part
+                        dep_job = depencies_dict[
+                            JobReference(
+                                dep.reference.component,
+                                dep.reference.dist,
+                                dep.reference.template,
+                                dep.reference.stage,
+                                build=None,
+                            )
+                        ]
+                    except KeyError:
                         continue
-                    jobs.append(job)
-
-        # TemplatePlugin
-        for template in templates:
-            for plugin in plugins:
-                classes = [c.__name__ for c in plugin.__mro__]
-                if "TemplatePlugin" in classes:
-                    job = plugin.from_args(
-                        template=template,
-                        config=self,
-                        stage=stage,
-                    )
-                    if not job:
+                    deps.append(dep_job)
+                elif dep.builder_object == "component":
+                    try:
+                        dep_job = depencies_dict[
+                            JobReference(
+                                component=dep.reference.component,
+                                dist=None,
+                                template=None,
+                                stage="fetch",
+                                build="source",
+                            )
+                        ]
+                    except KeyError:
                         continue
-                    jobs.append(job)
-
+                    deps.append(dep_job)
+            graph[job] = deps
+        ts = TopologicalSorter(graph)
+        jobs = list(ts.static_order())
         return jobs
diff --git a/qubesbuilder/distribution.py b/qubesbuilder/distribution.py
index e2ab4df9..e14ed603 100644
--- a/qubesbuilder/distribution.py
+++ b/qubesbuilder/distribution.py
@@ -130,6 +130,9 @@ def __str__(self):
     def __eq__(self, other):
         return repr(self) == repr(other)
 
+    def __hash__(self):
+        return hash(self.distribution)
+
     def is_rpm(self) -> bool:
         if FEDORA_RE.match(self.name) or CENTOS_STREAM_RE.match(self.name):
             return True
diff --git a/qubesbuilder/pluginmanager.py b/qubesbuilder/pluginmanager.py
index 4efee35b..b6105805 100644
--- a/qubesbuilder/pluginmanager.py
+++ b/qubesbuilder/pluginmanager.py
@@ -6,6 +6,7 @@
 
 from qubesbuilder.exc import EntityError, PluginManagerError
 from qubesbuilder.log import QubesBuilderLogger
+from qubesbuilder.plugins import Plugin
 
 
 class PluginEntity:
@@ -75,7 +76,7 @@ def _get_plugin_entities(self):
 
         return entities
 
-    def _get_plugins_with_attr(self, module_attr):
+    def _get_plugins_with_attr(self, module_attr) -> List[Plugin]:
         # Ensure plugin class name are uniq
         plugin_names = []
         for entity in self.entities.values():
@@ -101,5 +102,5 @@ def entities(self):
             self._entities = self._get_plugin_entities()
         return self._entities
 
-    def get_plugins(self):
+    def get_plugins(self) -> List[Plugin]:
         return self._get_plugins_with_attr("PLUGINS")
diff --git a/qubesbuilder/plugins/__init__.py b/qubesbuilder/plugins/__init__.py
index fb5ff056..8df02a49 100644
--- a/qubesbuilder/plugins/__init__.py
+++ b/qubesbuilder/plugins/__init__.py
@@ -162,7 +162,7 @@ class Plugin:
     dependencies: List[Dependency] = []
 
     @classmethod
-    def from_args(cls, **kwargs):
+    def from_args(cls, **kwargs) -> "Plugin":
         raise NotImplementedError
 
     @classmethod
@@ -234,6 +234,10 @@ def check_dependencies(self):
                     f"dependency '{dependency.reference}' (commit hash: {component[0].get_source_commit_hash()})"
                 )
             elif dependency.builder_object == "job":
+                if not dependency.reference.build:
+                    # non-build-specific dependencies are only for
+                    # ordering, ignore for check_dependencies
+                    continue
                 artifact_path = None
                 try:
                     artifact_path = get_artifacts_path(
@@ -335,6 +339,9 @@ def default_copy_in(self, plugins_dir: Path, sources_dir: Path):
                     )
                 )
             if dependency.builder_object == "job":
+                if dependency.reference.build is None:
+                    # ordering-only dependency
+                    continue
                 artifact_path = get_artifacts_path(
                     self.config, dependency.reference
                 )
@@ -446,6 +453,8 @@ def check_stage_artifacts(
 
 
 class DistributionPlugin(Plugin):
+    _signing_not_configured_warned = False
+
     def __init__(self, dist, config, stage, **kwargs):
         self.dist = dist
         super().__init__(config=config, stage=stage, **kwargs)
@@ -454,6 +463,28 @@ def __init__(self, dist, config, stage, **kwargs):
     def supported_distribution(cls, distribution):
         raise NotImplementedError
 
+    @classmethod
+    def is_signing_configured(cls, config, dist, component):
+        sign_key = config.sign_key.get(
+            dist.distribution, None
+        ) or config.sign_key.get(dist.type, None)
+
+        if not sign_key:
+            if not cls._signing_not_configured_warned:
+                QubesBuilderLogger.info(
+                    f"{cls.name}:{dist}: No signing key found."
+                )
+                cls._signing_not_configured_warned = True
+            return False
+        if not config.gpg_client:
+            if not cls._signing_not_configured_warned:
+                QubesBuilderLogger.info(
+                    f"{cls.name}:{dist}: Please specify GPG client to use!"
+                )
+                cls._signing_not_configured_warned = True
+            return False
+        return True
+
     @classmethod
     def from_args(cls, **kwargs):
         if kwargs.get("stage") in cls.stages and cls.supported_distribution(
diff --git a/qubesbuilder/plugins/build_windows/__init__.py b/qubesbuilder/plugins/build_windows/__init__.py
index ed30bfc3..e82622ff 100644
--- a/qubesbuilder/plugins/build_windows/__init__.py
+++ b/qubesbuilder/plugins/build_windows/__init__.py
@@ -270,10 +270,14 @@ def run(self):
         self.log.debug(f"{stage_options=}")
 
         # Compare previous artifacts hash with current source hash
-        hash = self.get_dist_artifacts_info(
-            self.stage, self.component.name
-        ).get("source-hash", None)
-        if self.component.get_source_hash() == hash:
+        source_hash = self.component.get_source_hash()
+        for target in parameters["build"]:
+            build_hash = self.get_dist_artifacts_info(
+                self.stage, target.mangle()
+            ).get("source-hash", None)
+            if source_hash != build_hash:
+                break
+        else:
             self.log.info(
                 f"{self.component}:{self.dist}: Source hash is the same than already built source. Skipping."
             )
@@ -506,7 +510,7 @@ def run(self):
                     }
                 )
                 self.save_dist_artifacts_info(
-                    stage=self.stage, basename=self.component.name, info=info
+                    stage=self.stage, basename=target.mangle(), info=info
                 )
         finally:
             if test_sign:
diff --git a/qubesbuilder/plugins/publish/__init__.py b/qubesbuilder/plugins/publish/__init__.py
index a30be583..d873ede1 100644
--- a/qubesbuilder/plugins/publish/__init__.py
+++ b/qubesbuilder/plugins/publish/__init__.py
@@ -23,9 +23,12 @@
 from qubesbuilder.config import Config
 from qubesbuilder.distribution import QubesDistribution
 from qubesbuilder.executors.local import LocalExecutor
+from qubesbuilder.log import QubesBuilderLogger
 from qubesbuilder.plugins import (
     DistributionComponentPlugin,
     PluginError,
+    JobDependency,
+    JobReference,
 )
 
 # Define the minimum age for which packages can be published to 'current'
@@ -52,6 +55,7 @@ class PublishPlugin(DistributionComponentPlugin):
         - build
     """
 
+    _publish_not_configured_warned = False
     name = "publish"
     stages = ["publish"]
 
@@ -69,12 +73,44 @@ def __init__(
             config=config,
             stage=stage,
         )
+        if self.has_component_packages(stage="build"):
+            for build in self.get_parameters(stage="build").get("build", []):
+                self.dependencies.append(
+                    JobDependency(
+                        JobReference(
+                            component=self.component,
+                            dist=self.dist,
+                            stage="build",
+                            build=build.mangle(),
+                            template=None,
+                        )
+                    )
+                )
+
+    @classmethod
+    def is_publish_configured(cls, config, dist, component):
+        if not cls.supported_distribution(dist):
+            return False
+        if not cls.is_signing_configured(config, dist, component):
+            return False
+        if not config.repository_publish.get("components"):
+            if not cls._publish_not_configured_warned:
+                QubesBuilderLogger.info(
+                    f"{cls.name}:{dist}: 'repository-publish:components' not set."
+                )
+                cls._publish_not_configured_warned = True
+            return False
+        return True
 
     @classmethod
     def from_args(cls, **kwargs):
         component = kwargs.get("component")
+        config = kwargs.get("config")
+        dist = kwargs.get("dist")
         if component and not component.has_packages:
             return None
+        if not cls.is_publish_configured(config, dist, component):
+            return None
         return super().from_args(**kwargs)
 
     def validate_repository_publish(self, repository_publish):
diff --git a/qubesbuilder/plugins/publish_archlinux/__init__.py b/qubesbuilder/plugins/publish_archlinux/__init__.py
index c8f6d7c7..dba9297d 100644
--- a/qubesbuilder/plugins/publish_archlinux/__init__.py
+++ b/qubesbuilder/plugins/publish_archlinux/__init__.py
@@ -28,7 +28,7 @@
 from qubesbuilder.plugins.publish import PublishPlugin, PublishError
 
 
-class ArchlinuxPublishPlugin(ArchlinuxDistributionPlugin, PublishPlugin):
+class ArchlinuxRepoPlugin(ArchlinuxDistributionPlugin):
     """
     ArchlinuxPublishPlugin manages Archlinux distribution publication.
 
@@ -42,26 +42,14 @@ class ArchlinuxPublishPlugin(ArchlinuxDistributionPlugin, PublishPlugin):
     name = "publish_archlinux"
     stages = ["publish"]
 
-    def __init__(
-        self,
-        component: QubesComponent,
-        dist: QubesDistribution,
-        config: Config,
-        stage: str,
-        **kwargs,
-    ):
-        super().__init__(
-            component=component,
-            dist=dist,
-            config=config,
-            stage=stage,
-        )
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
         self.dependencies.append(PluginDependency("publish"))
 
+        self.log_prefix = f"{self.name}:{self.dist}"
+
     def sign_metadata(self, executor, directory, sign_key, repository_db):
-        self.log.info(
-            f"{self.component}:{self.dist}:{directory}: Signing metadata."
-        )
+        self.log.info(f"{self.log_prefix}:{directory}: Signing metadata.")
         repository_db_sig = repository_db.with_suffix(".gz.sig")
         cmd = [
             f"{self.config.gpg_client} --batch --no-tty --yes --detach-sign --armor -u {sign_key} {repository_db} > {repository_db_sig}",
@@ -71,9 +59,26 @@ def sign_metadata(self, executor, directory, sign_key, repository_db):
         except (ExecutorError, OSError) as e:
             # On error, it creates an empty file.
             repository_db_sig.unlink(missing_ok=True)
-            msg = f"{self.component}:{self.dist}:{directory}:  Failed to sign metadata"
+            msg = f"{self.log_prefix}:{directory}:  Failed to sign metadata"
             raise PublishError(msg) from e
 
+    def run(
+        self,
+        repository_publish: Optional[str] = None,
+        ignore_min_age: bool = False,
+        unpublish: bool = False,
+        **kwargs,
+    ):
+        super().run()
+
+
+class ArchlinuxPublishPlugin(ArchlinuxRepoPlugin, PublishPlugin):
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+
+        self.log_prefix = f"{self.component}:{self.dist}"
+
     def publish(
         self, executor, directory, keyring_dir, sign_key, repository_publish
     ):
@@ -94,14 +99,10 @@ def publish(
         )
 
         if not build_info.get("packages", None):
-            self.log.info(
-                f"{self.component}:{self.dist}:{directory}: Nothing to publish."
-            )
+            self.log.info(f"{self.log_prefix}:{directory}: Nothing to publish.")
             return
 
-        self.log.info(
-            f"{self.component}:{self.dist}:{directory}: Publishing packages."
-        )
+        self.log.info(f"{self.log_prefix}:{directory}: Publishing packages.")
 
         packages_list = [
             build_artifacts_dir / "pkgs" / pkg for pkg in build_info["packages"]
@@ -110,7 +111,7 @@ def publish(
         # Verify signatures (sanity check, refuse to publish if packages weren't signed)
         try:
             self.log.info(
-                f"{self.component}:{self.dist}:{directory}: Verifying signatures."
+                f"{self.log_prefix}:{directory}: Verifying signatures."
             )
             cmd = []
             for pkg in packages_list:
@@ -119,7 +120,7 @@ def publish(
                 ]
             executor.run(cmd)
         except ExecutorError as e:
-            msg = f"{self.component}:{self.dist}:{directory}: Failed to check signatures."
+            msg = f"{self.log_prefix}:{directory}: Failed to check signatures."
             raise PublishError(msg) from e
 
         # Publishing packages
@@ -149,7 +150,7 @@ def publish(
                 cmd += [f"repo-add {repository_db} {pkg}"]
             executor.run(cmd)
         except ExecutorError as e:
-            msg = f"{self.component}:{self.dist}:{directory}: Failed to publish packages."
+            msg = f"{self.log_prefix}:{directory}: Failed to publish packages."
             raise PublishError(msg) from e
 
         self.sign_metadata(executor, directory, sign_key, repository_db)
@@ -173,13 +174,11 @@ def unpublish(self, executor, directory, sign_key, repository_publish):
 
         if not build_info.get("packages", None):
             self.log.info(
-                f"{self.component}:{self.dist}:{directory}: Nothing to unpublish."
+                f"{self.log_prefix}:{directory}: Nothing to unpublish."
             )
             return
 
-        self.log.info(
-            f"{self.component}:{self.dist}:{directory}: Unpublishing packages."
-        )
+        self.log.info(f"{self.log_prefix}:{directory}: Unpublishing packages.")
 
         packages_list = [
             build_artifacts_dir / "pkgs" / pkg for pkg in build_info["packages"]
@@ -213,7 +212,7 @@ def unpublish(self, executor, directory, sign_key, repository_publish):
                 cmd += [f"repo-remove {repository_db} {pkg_name}"]
             executor.run(cmd)
         except ExecutorError as e:
-            msg = f"{self.component}:{self.dist}:{directory}: Failed to publish packages."
+            msg = f"{self.log_prefix}:{directory}: Failed to publish packages."
             raise PublishError(msg) from e
 
         self.sign_metadata(executor, directory, sign_key, repository_db)
@@ -241,9 +240,7 @@ def run(
             self.dist.distribution, None
         ) or self.config.sign_key.get("archlinux", None)
         if not sign_key:
-            self.log.info(
-                f"{self.component}:{self.dist}: No signing key found."
-            )
+            self.log.info(f"{self.log_prefix}: No signing key found.")
             return
 
         # Check if we have a gpg client provided
@@ -282,7 +279,7 @@ def run(
                 for directory in parameters["build"]
             ):
                 self.log.info(
-                    f"{self.component}:{self.dist}: Already published to '{repository_publish}'."
+                    f"{self.log_prefix}: Already published to '{repository_publish}'."
                 )
                 return
 
@@ -295,7 +292,7 @@ def run(
                 for directory in parameters["build"]
             ):
                 failure_msg = (
-                    f"{self.component}:{self.dist}: "
+                    f"{self.log_prefix}: "
                     f"Refusing to publish to 'current' as packages are not "
                     f"uploaded to 'current-testing' or 'security-testing' "
                     f"for at least {self.config.min_age_days} days."
@@ -315,7 +312,7 @@ def run(
 
                 if not build_info:
                     raise PublishError(
-                        f"{self.component}:{self.dist}:{directory}: Cannot find build info."
+                        f"{self.log_prefix}:{directory}: Cannot find build info."
                     )
 
                 # If previous publication to a repo has been done and does not correspond to current
@@ -325,7 +322,7 @@ def run(
                 if publish_info:
                     if build_info["source-hash"] != publish_info["source-hash"]:
                         self.log.info(
-                            f"{self.component}:{self.dist}:{directory}: Current build hash does not match previous one."
+                            f"{self.log_prefix}:{directory}: Current build hash does not match previous one."
                         )
                         for repository in publish_info.get(
                             "repository-publish", []
@@ -369,7 +366,7 @@ def run(
                 for directory in parameters["build"]
             ):
                 self.log.info(
-                    f"{self.component}:{self.dist}: Not published to '{repository_publish}'."
+                    f"{self.log_prefix}: Not published to '{repository_publish}'."
                 )
                 return
 
@@ -403,7 +400,7 @@ def run(
                     )
                 else:
                     self.log.info(
-                        f"{self.component}:{self.dist}:{directory}: Not published anywhere else, deleting publish info."
+                        f"{self.log_prefix}:{directory}: Not published anywhere else, deleting publish info."
                     )
                     self.delete_dist_artifacts_info(
                         stage="publish", basename=directory_bn
diff --git a/qubesbuilder/plugins/publish_deb/__init__.py b/qubesbuilder/plugins/publish_deb/__init__.py
index b6a9f269..31174a06 100644
--- a/qubesbuilder/plugins/publish_deb/__init__.py
+++ b/qubesbuilder/plugins/publish_deb/__init__.py
@@ -27,7 +27,7 @@
 from qubesbuilder.plugins.publish import PublishPlugin, PublishError
 
 
-class DEBPublishPlugin(DEBDistributionPlugin, PublishPlugin):
+class DEBRepoPlugin(DEBDistributionPlugin):
     """
     DEBPublishPlugin manages Debian distribution publication.
 
@@ -41,20 +41,10 @@ class DEBPublishPlugin(DEBDistributionPlugin, PublishPlugin):
     name = "publish_deb"
     stages = ["publish"]
 
-    def __init__(
-        self,
-        component: QubesComponent,
-        dist: QubesDistribution,
-        config: Config,
-        stage: str,
-        **kwargs,
-    ):
-        super().__init__(
-            component=component,
-            dist=dist,
-            config=config,
-            stage=stage,
-        )
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+
+        self.log_prefix = f"{self.name}:{self.dist}"
 
         self.dependencies.append(PluginDependency("publish"))
 
@@ -92,7 +82,7 @@ def create_repository_skeleton(self):
             executor = self.config.get_executor_from_config("publish", self)
             executor.run(cmd)
         except ExecutorError as e:
-            msg = f"{self.component}:{self.dist}: Failed to create repository skeleton."
+            msg = f"{self.log_prefix}: Failed to create repository skeleton."
             raise PublishError(msg) from e
 
     def create_metadata(self, repository_publish):
@@ -108,7 +98,7 @@ def create_metadata(self, repository_publish):
             executor = self.config.get_executor_from_config("publish", self)
             executor.run(cmd)
         except ExecutorError as e:
-            msg = f"{self.component}:{self.dist}: Failed to create metadata."
+            msg = f"{self.log_prefix}: Failed to create metadata."
             raise PublishError(msg) from e
 
     def sign_metadata(self, repository_publish):
@@ -122,15 +112,13 @@ def sign_metadata(self, repository_publish):
         ) or self.config.sign_key.get("deb", None)
 
         if not sign_key:
-            self.log.info(
-                f"{self.component}:{self.dist}: No signing key found."
-            )
+            self.log.info(f"{self.log_prefix}: No signing key found.")
             return
 
         # Check if we have a gpg client provided
         if not self.config.gpg_client:
             self.log.info(
-                f"{self.component}: Please specify GPG client to use!"
+                f"{self.log_prefix}: Please specify GPG client to use!"
             )
             return
 
@@ -149,14 +137,42 @@ def sign_metadata(self, repository_publish):
                     f"--batch --no-tty --output {release_dir / out_name} {release_dir / 'Release'}"
                 ]
                 self.log.info(
-                    f"{self.component}:{self.dist}: Signing metadata ({out_name})."
+                    f"{self.log_prefix}: Signing metadata ({out_name})."
                 )
                 executor = self.config.get_executor_from_config("publish", self)
                 executor.run(cmd)
             except ExecutorError as e:
-                msg = f"{self.component}:{self.dist}: Failed to sign metadata ({out_name})."
+                msg = (
+                    f"{self.log_prefix}: Failed to sign metadata ({out_name})."
+                )
                 raise PublishError(msg) from e
 
+    def create(self, repository_publish: str):
+        # Create skeleton
+        self.create_repository_skeleton()
+
+        # Create metadata
+        self.create_metadata(repository_publish=repository_publish)
+
+        # Sign metadata
+        self.sign_metadata(repository_publish=repository_publish)
+
+    def run(
+        self,
+        repository_publish: Optional[str] = None,
+        ignore_min_age: bool = False,
+        unpublish: bool = False,
+        **kwargs,
+    ):
+        super().run()
+
+
+class DEBPublishPlugin(DEBRepoPlugin, PublishPlugin):
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+
+        self.log_prefix = f"{self.component}:{self.dist}"
+
     def publish(self, executor, directory, keyring_dir, repository_publish):
         # directory basename will be used as prefix for some artifacts
         directory_bn = directory.mangle()
@@ -172,9 +188,7 @@ def publish(self, executor, directory, keyring_dir, repository_publish):
         )
 
         if not build_info.get("changes", None):
-            self.log.info(
-                f"{self.component}:{self.dist}:{directory}: Nothing to publish."
-            )
+            self.log.info(f"{self.log_prefix}:{directory}: Nothing to publish.")
             return
 
         self.log.info(
@@ -184,7 +198,7 @@ def publish(self, executor, directory, keyring_dir, repository_publish):
         # Verify signatures (sanity check, refuse to publish if packages weren't signed)
         try:
             self.log.info(
-                f"{self.component}:{self.dist}:{directory}: Verifying signatures."
+                f"{self.log_prefix}:{directory}: Verifying signatures."
             )
             cmd = []
             for file in ("dsc", "changes", "buildinfo"):
@@ -192,7 +206,7 @@ def publish(self, executor, directory, keyring_dir, repository_publish):
                 cmd += [f"gpg2 -q --homedir {keyring_dir} --verify {fname}"]
             executor.run(cmd)
         except ExecutorError as e:
-            msg = f"{self.component}:{self.dist}:{directory}: Failed to check signatures."
+            msg = f"{self.log_prefix}:{directory}: Failed to check signatures."
             raise PublishError(msg) from e
 
         # Publishing packages
@@ -228,14 +242,10 @@ def unpublish(self, executor, directory, repository_publish):
         )
 
         if not build_info.get("changes", None):
-            self.log.info(
-                f"{self.component}:{self.dist}:{directory}: Nothing to publish."
-            )
+            self.log.info(f"{self.log_prefix}:{directory}: Nothing to publish.")
             return
 
-        self.log.info(
-            f"{self.component}:{self.dist}:{directory}: Unpublishing packages."
-        )
+        self.log.info(f"{self.log_prefix}:{directory}: Unpublishing packages.")
 
         # Unpublishing packages
         try:
@@ -263,16 +273,6 @@ def unpublish(self, executor, directory, repository_publish):
 
         self.sign_metadata(repository_publish=repository_publish)
 
-    def create(self, repository_publish: str):
-        # Create skeleton
-        self.create_repository_skeleton()
-
-        # Create metadata
-        self.create_metadata(repository_publish=repository_publish)
-
-        # Sign metadata
-        self.sign_metadata(repository_publish=repository_publish)
-
     def run(
         self,
         repository_publish: Optional[str] = None,
@@ -297,15 +297,13 @@ def run(
         ) or self.config.sign_key.get("deb", None)
 
         if not sign_key:
-            self.log.info(
-                f"{self.component}:{self.dist}: No signing key found."
-            )
+            self.log.info(f"{self.log_prefix}: No signing key found.")
             return
 
         # Check if we have a gpg client provided
         if not self.config.gpg_client:
             self.log.info(
-                f"{self.component}: Please specify GPG client to use!"
+                f"{self.log_prefix}: Please specify GPG client to use!"
             )
             return
 
@@ -426,7 +424,7 @@ def run(
                 for directory in parameters["build"]
             ):
                 self.log.info(
-                    f"{self.component}:{self.dist}: Not published to '{repository_publish}'."
+                    f"{self.log_prefix}: Not published to '{repository_publish}'."
                 )
                 return
 
@@ -492,4 +490,4 @@ def run(
                         break
 
 
-PLUGINS = [DEBPublishPlugin]
+PLUGINS = [DEBRepoPlugin, DEBPublishPlugin]
diff --git a/qubesbuilder/plugins/publish_rpm/__init__.py b/qubesbuilder/plugins/publish_rpm/__init__.py
index f8d43d06..539e21db 100644
--- a/qubesbuilder/plugins/publish_rpm/__init__.py
+++ b/qubesbuilder/plugins/publish_rpm/__init__.py
@@ -29,7 +29,7 @@
 from qubesbuilder.plugins.publish import PublishPlugin, PublishError
 
 
-class RPMPublishPlugin(RPMDistributionPlugin, PublishPlugin):
+class RPMRepoPlugin(RPMDistributionPlugin):
     """
     RPMPublishPlugin manages RPM distribution publication.
 
@@ -43,25 +43,15 @@ class RPMPublishPlugin(RPMDistributionPlugin, PublishPlugin):
     name = "publish_rpm"
     stages = ["publish"]
 
-    def __init__(
-        self,
-        component: QubesComponent,
-        dist: QubesDistribution,
-        config: Config,
-        stage: str,
-        **kwargs,
-    ):
-        super().__init__(
-            component=component,
-            dist=dist,
-            config=config,
-            stage=stage,
-        )
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
         self.dependencies += [
             PluginDependency("publish"),
             PluginDependency("sign_rpm"),
         ]
 
+        self.log_prefix = f"{self.name}:{self.dist}"
+
     def get_target_dir(self, repository_publish):
         artifacts_dir = self.config.repository_publish_dir / self.dist.type
         return (
@@ -98,11 +88,11 @@ def create_repository_skeleton(self):
             executor = self.config.get_executor_from_config("publish", self)
             executor.run(cmd)
         except ExecutorError as e:
-            msg = f"{self.component}:{self.dist}: Failed to create repository skeleton."
+            msg = f"{self.log_prefix}: Failed to create repository skeleton."
             raise PublishError(msg) from e
 
     def createrepo(self, executor, target_dir):
-        self.log.info(f"{self.component}:{self.dist}: Updating metadata.")
+        self.log.info(f"{self.log_prefix}: Updating metadata.")
         cmd = [f"cd {target_dir}"]
         if (
             self.config.sources_dir
@@ -115,11 +105,11 @@ def createrepo(self, executor, target_dir):
             shutil.rmtree(target_dir / "repodata")
             executor.run(cmd)
         except (ExecutorError, OSError) as e:
-            msg = f"{self.component}:{self.dist}: Failed to 'createrepo_c'"
+            msg = f"{self.log_prefix}: Failed to 'createrepo_c'"
             raise PublishError(msg) from e
 
     def sign_metadata(self, executor, sign_key, target_dir):
-        self.log.info(f"{self.component}:{self.dist}: Signing metadata.")
+        self.log.info(f"{self.log_prefix}: Signing metadata.")
         repomd = target_dir / "repodata/repomd.xml"
         cmd = [
             f"{self.config.gpg_client} --batch --no-tty --yes --detach-sign --armor -u {sign_key} {repomd} > {repomd}.asc",
@@ -127,7 +117,7 @@ def sign_metadata(self, executor, sign_key, target_dir):
         try:
             executor.run(cmd)
         except (ExecutorError, OSError) as e:
-            msg = f"{self.component}:{self.dist}: Failed to sign metadata."
+            msg = f"{self.log_prefix}: Failed to sign metadata."
             raise PublishError(msg) from e
 
     def create_metalink(self, executor, repository_publish):
@@ -141,9 +131,7 @@ def create_metalink(self, executor, repository_publish):
         )
         repomd = repository_dir / "repodata/repomd.xml"
         if not repomd.exists():
-            msg = (
-                f"{self.component}:{self.dist}: Cannot find repomd '{repomd}'."
-            )
+            msg = f"{self.log_prefix}: Cannot find repomd '{repomd}'."
             raise PublishError(msg)
 
         self.log.info(f"Creating metalink for {repomd}.")
@@ -153,7 +141,7 @@ def create_metalink(self, executor, repository_publish):
             ]
             executor.run(cmd)
         except ExecutorError as e:
-            msg = f"{self.component}:{self.dist}: Failed to create metalink for '{repomd}': {str(e)}"
+            msg = f"{self.log_prefix}: Failed to create metalink for '{repomd}': {str(e)}"
             self.log.error(msg)
 
     def create_and_sign_repository_metadata(self, repository_publish):
@@ -167,15 +155,13 @@ def create_and_sign_repository_metadata(self, repository_publish):
         ) or self.config.sign_key.get("rpm", None)
 
         if not sign_key:
-            self.log.info(
-                f"{self.component}:{self.dist}: No signing key found."
-            )
+            self.log.info(f"{self.log_prefix}: No signing key found.")
             return
 
         # Check if we have a gpg client provided
         if not self.config.gpg_client:
             self.log.info(
-                f"{self.component}: Please specify GPG client to use!"
+                f"{self.log_prefix}: Please specify GPG client to use!"
             )
             return
 
@@ -192,6 +178,32 @@ def create_and_sign_repository_metadata(self, repository_publish):
             executor=executor, repository_publish=repository_publish
         )
 
+    def create(self, repository_publish: str):
+        # Create skeleton
+        self.create_repository_skeleton()
+
+        # Create and sign metadata
+        self.create_and_sign_repository_metadata(
+            repository_publish=repository_publish
+        )
+
+    def run(
+        self,
+        repository_publish: Optional[str] = None,
+        ignore_min_age: bool = False,
+        unpublish: bool = False,
+        **kwargs,
+    ):
+        super().run()
+
+
+class RPMPublishPlugin(RPMRepoPlugin, PublishPlugin):
+
+    def __init__(self, **kwargs):
+        super().__init__(**kwargs)
+
+        self.log_prefix = f"{self.component}:{self.dist}"
+
     def publish(self, executor, build, sign_key, db_path, repository_publish):
         # spec file basename will be used as prefix for some artifacts
         build_bn = build.mangle()
@@ -202,14 +214,12 @@ def publish(self, executor, build, sign_key, db_path, repository_publish):
         )
 
         if not build_info.get("rpms", []) and not build_info.get("srpm", None):
-            self.log.info(
-                f"{self.component}:{self.dist}:{build}: Nothing to publish."
-            )
+            self.log.info(f"{self.log_prefix}:{build}: Nothing to publish.")
             return
 
         # Publish packages with hardlinks to built RPMs
         self.log.info(
-            f"{self.component}:{self.dist}:{build}: Publishing RPMs to '{repository_publish}'."
+            f"{self.log_prefix}:{build}: Publishing RPMs to '{repository_publish}'."
         )
 
         # Source artifacts
@@ -225,9 +235,7 @@ def publish(self, executor, build, sign_key, db_path, repository_publish):
         packages_list += [prep_artifacts_dir / build_info["srpm"]]
 
         # We check that signature exists (--check-only option)
-        self.log.info(
-            f"{self.component}:{self.dist}:{build}: Verifying signatures."
-        )
+        self.log.info(f"{self.log_prefix}:{build}: Verifying signatures.")
         try:
             for rpm in packages_list:
                 cmd = [
@@ -236,7 +244,7 @@ def publish(self, executor, build, sign_key, db_path, repository_publish):
                 ]
                 executor.run(cmd)
         except ExecutorError as e:
-            msg = f"{self.component}:{self.dist}:{build}: Failed to check signatures."
+            msg = f"{self.log_prefix}:{build}: Failed to check signatures."
             raise PublishError(msg) from e
 
         target_dir = self.get_target_dir(repository_publish=repository_publish)
@@ -256,7 +264,7 @@ def publish(self, executor, build, sign_key, db_path, repository_publish):
                 target_path,
             )
         except (ValueError, PermissionError, NotImplementedError) as e:
-            msg = f"{self.component}:{self.dist}:{build}: Failed to publish packages."
+            msg = f"{self.log_prefix}:{build}: Failed to publish packages."
             raise PublishError(msg) from e
 
         # Create and sign metadata
@@ -273,13 +281,11 @@ def unpublish(self, build, repository_publish):
         )
 
         if not build_info.get("rpms", []) and not build_info.get("srpm", None):
-            self.log.info(
-                f"{self.component}:{self.dist}:{build}: Nothing to unpublish."
-            )
+            self.log.info(f"{self.log_prefix}:{build}: Nothing to unpublish.")
             return
 
         self.log.info(
-            f"{self.component}:{self.dist}:{build}: Unpublishing RPMs from '{repository_publish}'."
+            f"{self.log_prefix}:{build}: Unpublishing RPMs from '{repository_publish}'."
         )
 
         # Source artifacts
@@ -301,7 +307,7 @@ def unpublish(self, build, repository_publish):
                 target_path = target_dir / "rpm" / rpm.name
                 target_path.unlink(missing_ok=True)
         except (ValueError, PermissionError, NotImplementedError) as e:
-            msg = f"{self.component}:{self.dist}:{build}: Failed to unpublish packages."
+            msg = f"{self.log_prefix}:{build}: Failed to unpublish packages."
             raise PublishError(msg) from e
 
         # Create and sign metadata
@@ -309,15 +315,6 @@ def unpublish(self, build, repository_publish):
             repository_publish=repository_publish
         )
 
-    def create(self, repository_publish: str):
-        # Create skeleton
-        self.create_repository_skeleton()
-
-        # Create and sign metadata
-        self.create_and_sign_repository_metadata(
-            repository_publish=repository_publish
-        )
-
     def run(
         self,
         repository_publish: Optional[str] = None,
@@ -342,9 +339,7 @@ def run(
         ) or self.config.sign_key.get("rpm", None)
 
         if not sign_key:
-            self.log.info(
-                f"{self.component}:{self.dist}: No signing key found."
-            )
+            self.log.info(f"{self.log_prefix}: No signing key found.")
             return
 
         # Check if we have a gpg client provided
@@ -389,7 +384,7 @@ def run(
                 for build in parameters["build"]
             ):
                 self.log.info(
-                    f"{self.component}:{self.dist}: Already published to '{repository_publish}'."
+                    f"{self.log_prefix}: Already published to '{repository_publish}'."
                 )
                 # Update metalink in case of previous failure on creating it
                 self.create_metalink(
@@ -406,7 +401,7 @@ def run(
                 for build in parameters["build"]
             ):
                 failure_msg = (
-                    f"{self.component}:{self.dist}: "
+                    f"{self.log_prefix}: "
                     f"Refusing to publish to 'current' as packages are not "
                     f"uploaded to 'current-testing' or 'security-testing' "
                     f"for at least {self.config.min_age_days} days."
@@ -424,7 +419,7 @@ def run(
 
                 if not build_info:
                     raise PublishError(
-                        f"{self.component}:{self.dist}:{build}: Cannot find build info."
+                        f"{self.log_prefix}:{build}: Cannot find build info."
                     )
 
                 # If previous publication to a repo has been done and does not correspond to current
@@ -472,7 +467,7 @@ def run(
                 for build in parameters["build"]
             ):
                 self.log.info(
-                    f"{self.component}:{self.dist}: Not published to '{repository_publish}'."
+                    f"{self.log_prefix}: Not published to '{repository_publish}'."
                 )
                 return
 
@@ -500,11 +495,11 @@ def run(
                     )
                 else:
                     self.log.info(
-                        f"{self.component}:{self.dist}:{build_bn}: Not published anywhere else, deleting publish info."
+                        f"{self.log_prefix}:{build_bn}: Not published anywhere else, deleting publish info."
                     )
                     self.delete_dist_artifacts_info(
                         stage="publish", basename=build_bn
                     )
 
 
-PLUGINS = [RPMPublishPlugin]
+PLUGINS = [RPMRepoPlugin, RPMPublishPlugin]
diff --git a/qubesbuilder/plugins/sign/__init__.py b/qubesbuilder/plugins/sign/__init__.py
index c5a9cdad..ec873ad9 100644
--- a/qubesbuilder/plugins/sign/__init__.py
+++ b/qubesbuilder/plugins/sign/__init__.py
@@ -79,8 +79,14 @@ def __init__(
     @classmethod
     def from_args(cls, **kwargs):
         component = kwargs.get("component")
+        config = kwargs.get("config")
+        dist = kwargs.get("dist")
         if component and not component.has_packages:
             return None
+        if not cls.supported_distribution(dist):
+            return None
+        if not cls.is_signing_configured(config, dist, component):
+            return None
         return super().from_args(**kwargs)
 
     def run(self):
diff --git a/qubesbuilder/plugins/template/__init__.py b/qubesbuilder/plugins/template/__init__.py
index 0e1090c4..69546195 100644
--- a/qubesbuilder/plugins/template/__init__.py
+++ b/qubesbuilder/plugins/template/__init__.py
@@ -34,6 +34,7 @@
 )
 from qubesbuilder.executors import ExecutorError
 from qubesbuilder.executors.local import LocalExecutor
+from qubesbuilder.log import QubesBuilderLogger
 from qubesbuilder.plugins import (
     PluginError,
     TemplatePlugin,
@@ -66,6 +67,8 @@ class TemplateBuilderPlugin(TemplatePlugin):
         - upload - Upload published repository for given distribution to remote mirror.
     """
 
+    _publish_not_configured_warned = False
+
     @classmethod
     def supported_template(cls, template: QubesTemplate):
         return any(
@@ -97,6 +100,24 @@ def __init__(
 
         self.dependencies.append(PluginDependency("publish"))
 
+    @classmethod
+    def from_args(cls, **kwargs):
+        config = kwargs.get("config")
+        stage = kwargs.get("stage")
+        template = kwargs.get("template")
+        if config and template and stage in ("publish", "upload"):
+            repository_publish = config.repository_publish.get("templates")
+            if not repository_publish:
+                if not cls._publish_not_configured_warned:
+                    QubesBuilderLogger.info(
+                        f"{cls.name}:{template}: "
+                        f"'repository-publish:templates' not set."
+                    )
+                    cls._publish_not_configured_warned = True
+                return None
+
+        return super().from_args(**kwargs)
+
     def get_template_version(self):
         if not self.template_version:
             try:
diff --git a/qubesbuilder/plugins/upload/__init__.py b/qubesbuilder/plugins/upload/__init__.py
index 1a3c9bca..12a4d6c9 100644
--- a/qubesbuilder/plugins/upload/__init__.py
+++ b/qubesbuilder/plugins/upload/__init__.py
@@ -21,8 +21,13 @@
 from qubesbuilder.config import Config
 from qubesbuilder.distribution import QubesDistribution
 from qubesbuilder.executors.local import LocalExecutor, ExecutorError
-from qubesbuilder.plugins import DistributionPlugin, PluginError
-from qubesbuilder.plugins.publish_deb import DEBPublishPlugin
+from qubesbuilder.plugins import (
+    DistributionPlugin,
+    PluginError,
+    JobDependency,
+    JobReference,
+)
+from qubesbuilder.plugins.publish_deb import DEBRepoPlugin
 
 
 class UploadError(PluginError):
@@ -49,6 +54,25 @@ def __init__(
     ):
         super().__init__(config=config, dist=dist, stage=stage, **kwargs)
 
+        # order upload after all publish jobs
+        for component in config.get_components():
+            if not component.has_packages:
+                continue
+            # specify build as "None" to skip implicit copy-in and
+            # check_dependencies - some configured packages may not be
+            # published and that's okay; but still order after all publish jobs
+            self.dependencies.append(
+                JobDependency(
+                    JobReference(
+                        component=component,
+                        dist=self.dist,
+                        stage="publish",
+                        build=None,
+                        template=None,
+                    )
+                )
+            )
+
     @classmethod
     def supported_distribution(cls, distribution: QubesDistribution):
         return (
@@ -90,7 +114,7 @@ def run(self, repository_publish: Optional[str] = None):
                 )
             elif self.dist.is_deb() or self.dist.is_ubuntu():
                 debian_suite = (
-                    DEBPublishPlugin.get_debian_suite_from_repository_publish(
+                    DEBRepoPlugin.get_debian_suite_from_repository_publish(
                         self.dist, repository_publish
                     )
                 )
diff --git a/tests/test_log.py b/tests/test_log.py
index 76647309..ec7cb7fe 100644
--- a/tests/test_log.py
+++ b/tests/test_log.py
@@ -48,7 +48,7 @@ def config(tmp_path):
 @pytest.fixture
 def plugins(config):
     return config.get_jobs(
-        stage="prep",
+        stages=["prep"],
         components=config.get_components(),
         distributions=config.get_distributions(),
         templates=[],
diff --git a/tests/test_objects.py b/tests/test_objects.py
index 4b794232..5ba51dcc 100644
--- a/tests/test_objects.py
+++ b/tests/test_objects.py
@@ -843,7 +843,7 @@ def test_config_example_configs():
                 components=config.get_components(),
                 distributions=config.get_distributions(),
                 templates=[],
-                stage="fetch",
+                stages=["fetch"],
             )
             executor = config.get_executor_from_config("build", jobs[0])
             assert isinstance(executor, ContainerExecutor)


Reviews:
user: login: fepitre
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T19:53:01Z
user: login: marmarek
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T21:56:06Z
user: login: marmarek
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-24T21:56:20Z

Review comments:
diff_hunk: @@ -149,14 +137,42 @@ def sign_metadata(self, repository_publish):
                     f"--batch --no-tty --output {release_dir / out_name} {release_dir / 'Release'}"
                 ]
                 self.log.info(
-                    f"{self.component}:{self.dist}: Signing metadata ({out_name})."
+                    f"{self.log_prefix}: Signing metadata ({out_name})."
                 )
                 executor = self.config.get_executor_from_config("publish", self)
                 executor.run(cmd)
             except ExecutorError as e:
-                msg = f"{self.component}:{self.dist}: Failed to sign metadata ({out_name})."
+                msg = (
+                    f"{self.log_prefix}: Failed to sign metadata ({out_name})."
+                )
                 raise PublishError(msg) from e
 
+    def create(self, repository_publish: str):
+        # Create skeleton
+        self.create_repository_skeleton()
+
+        # Create metadata
+        self.create_metadata(repository_publish=repository_publish)
+
+        # Sign metadata
+        self.sign_metadata(repository_publish=repository_publish)
+
+    def run(
+        self,
+        repository_publish: Optional[str] = None,
+        ignore_min_age: bool = False,
+        unpublish: bool = False,
+        **kwargs,
+    ):
+        super().run()
path: qubesbuilder/plugins/publish_deb/__init__.py
user: login: fepitre
body: Is this needed?
created_at: 2025-03-24T19:46:54Z
author_association: MEMBER
diff_hunk: @@ -192,6 +178,32 @@ def create_and_sign_repository_metadata(self, repository_publish):
             executor=executor, repository_publish=repository_publish
         )
 
+    def create(self, repository_publish: str):
+        # Create skeleton
+        self.create_repository_skeleton()
+
+        # Create and sign metadata
+        self.create_and_sign_repository_metadata(
+            repository_publish=repository_publish
+        )
+
+    def run(
+        self,
+        repository_publish: Optional[str] = None,
+        ignore_min_age: bool = False,
+        unpublish: bool = False,
+        **kwargs,
+    ):
+        super().run()
path: qubesbuilder/plugins/publish_rpm/__init__.py
user: login: fepitre
body: Here too
created_at: 2025-03-24T19:47:40Z
author_association: MEMBER
diff_hunk: @@ -689,82 +693,164 @@ def get_jobs(
         components: List[QubesComponent],
         distributions: List[QubesDistribution],
         templates: List[QubesTemplate],
-        stage: str,
+        stages: List[str],
     ):
+        """
+        Collects jobs related to given constraints.
+        First collec jobs according to stage orders. But then,
+        apply topological sorting based on defined dependencies that will
+        possibly reorder jobs to satisfy dependencies.
+        """
 
         manager = self.get_plugin_manager()
         plugins = manager.get_plugins()
-        jobs = []
+        jobs: List[Plugin] = []
+        # while collecting jobs, collect also dependency objects for later use
+        depencies_dict: dict[JobReference, Plugin] = {}
+
+        for stage in stages:
+            # DistributionComponentPlugin
+            for distribution in distributions:
+                for component in components:
+                    for plugin in plugins:
+                        if "DistributionComponentPlugin" in [
+                            c.__name__ for c in plugin.__mro__
+                        ]:
+                            job = plugin.from_args(
+                                dist=distribution,
+                                component=component,
+                                config=self,
+                                stage=stage,
+                            )
+                            if not job:
+                                continue
+                            job.dependencies += self.get_needs(
+                                component=component,
+                                dist=distribution,
+                                stage_name=stage,
path: qubesbuilder/config.py
user: login: fepitre
body: Maybe we can rename keyword to stage?
created_at: 2025-03-24T19:49:27Z
author_association: MEMBER
diff_hunk: @@ -149,14 +137,42 @@ def sign_metadata(self, repository_publish):
                     f"--batch --no-tty --output {release_dir / out_name} {release_dir / 'Release'}"
                 ]
                 self.log.info(
-                    f"{self.component}:{self.dist}: Signing metadata ({out_name})."
+                    f"{self.log_prefix}: Signing metadata ({out_name})."
                 )
                 executor = self.config.get_executor_from_config("publish", self)
                 executor.run(cmd)
             except ExecutorError as e:
-                msg = f"{self.component}:{self.dist}: Failed to sign metadata ({out_name})."
+                msg = (
+                    f"{self.log_prefix}: Failed to sign metadata ({out_name})."
+                )
                 raise PublishError(msg) from e
 
+    def create(self, repository_publish: str):
+        # Create skeleton
+        self.create_repository_skeleton()
+
+        # Create metadata
+        self.create_metadata(repository_publish=repository_publish)
+
+        # Sign metadata
+        self.sign_metadata(repository_publish=repository_publish)
+
+    def run(
+        self,
+        repository_publish: Optional[str] = None,
+        ignore_min_age: bool = False,
+        unpublish: bool = False,
+        **kwargs,
+    ):
+        super().run()
path: qubesbuilder/plugins/publish_deb/__init__.py
user: login: marmarek
body: Yes, to swallow unexpected args (`Plugin.run()` doesn't accept `unpublish` for example).
created_at: 2025-03-24T21:56:06Z
author_association: MEMBER
diff_hunk: @@ -689,82 +693,164 @@ def get_jobs(
         components: List[QubesComponent],
         distributions: List[QubesDistribution],
         templates: List[QubesTemplate],
-        stage: str,
+        stages: List[str],
     ):
+        """
+        Collects jobs related to given constraints.
+        First collec jobs according to stage orders. But then,
+        apply topological sorting based on defined dependencies that will
+        possibly reorder jobs to satisfy dependencies.
+        """
 
         manager = self.get_plugin_manager()
         plugins = manager.get_plugins()
-        jobs = []
+        jobs: List[Plugin] = []
+        # while collecting jobs, collect also dependency objects for later use
+        depencies_dict: dict[JobReference, Plugin] = {}
+
+        for stage in stages:
+            # DistributionComponentPlugin
+            for distribution in distributions:
+                for component in components:
+                    for plugin in plugins:
+                        if "DistributionComponentPlugin" in [
+                            c.__name__ for c in plugin.__mro__
+                        ]:
+                            job = plugin.from_args(
+                                dist=distribution,
+                                component=component,
+                                config=self,
+                                stage=stage,
+                            )
+                            if not job:
+                                continue
+                            job.dependencies += self.get_needs(
+                                component=component,
+                                dist=distribution,
+                                stage_name=stage,
path: qubesbuilder/config.py
user: login: marmarek
body: I can do that.
created_at: 2025-03-24T21:56:20Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-03-21T00:46:42Z
body: ## [Codecov](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS) Report
Attention: Patch coverage is `76.57658%` with `52 lines` in your changes missing coverage. Please review.
> Project coverage is 74.95%. Comparing base [(`099f4f6`)](https://app.codecov.io/gh/QubesOS/qubes-builderv2/commit/099f4f6cb85a761ee477b0fed1b1bf8d097bd9af?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS) to head [(`00e46b4`)](https://app.codecov.io/gh/QubesOS/qubes-builderv2/commit/00e46b4afc436f16ec34e3a6d84eb6c610a2341e?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS).
> Report is 9 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS) | Patch % | Lines |
|---|---|---|
| [qubesbuilder/plugins/publish\_rpm/\_\_init\_\_.py](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?src=pr&el=tree&filepath=qubesbuilder%2Fplugins%2Fpublish_rpm%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS#diff-cXViZXNidWlsZGVyL3BsdWdpbnMvcHVibGlzaF9ycG0vX19pbml0X18ucHk=) | 58.62% | [12 Missing :warning: ](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS) |
| [qubesbuilder/plugins/publish\_deb/\_\_init\_\_.py](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?src=pr&el=tree&filepath=qubesbuilder%2Fplugins%2Fpublish_deb%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS#diff-cXViZXNidWlsZGVyL3BsdWdpbnMvcHVibGlzaF9kZWIvX19pbml0X18ucHk=) | 66.66% | [8 Missing :warning: ](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS) |
| [qubesbuilder/plugins/publish\_archlinux/\_\_init\_\_.py](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?src=pr&el=tree&filepath=qubesbuilder%2Fplugins%2Fpublish_archlinux%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS#diff-cXViZXNidWlsZGVyL3BsdWdpbnMvcHVibGlzaF9hcmNobGludXgvX19pbml0X18ucHk=) | 63.15% | [7 Missing :warning: ](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS) |
| [qubesbuilder/plugins/\_\_init\_\_.py](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?src=pr&el=tree&filepath=qubesbuilder%2Fplugins%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS#diff-cXViZXNidWlsZGVyL3BsdWdpbnMvX19pbml0X18ucHk=) | 70.00% | [6 Missing :warning: ](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS) |
| [qubesbuilder/config.py](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?src=pr&el=tree&filepath=qubesbuilder%2Fconfig.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS#diff-cXViZXNidWlsZGVyL2NvbmZpZy5weQ==) | 92.18% | [5 Missing :warning: ](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS) |
| [qubesbuilder/plugins/build\_windows/\_\_init\_\_.py](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?src=pr&el=tree&filepath=qubesbuilder%2Fplugins%2Fbuild_windows%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS#diff-cXViZXNidWlsZGVyL3BsdWdpbnMvYnVpbGRfd2luZG93cy9fX2luaXRfXy5weQ==) | 0.00% | [5 Missing :warning: ](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS) |
| [qubesbuilder/plugins/publish/\_\_init\_\_.py](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?src=pr&el=tree&filepath=qubesbuilder%2Fplugins%2Fpublish%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS#diff-cXViZXNidWlsZGVyL3BsdWdpbnMvcHVibGlzaC9fX2luaXRfXy5weQ==) | 80.95% | [4 Missing :warning: ](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS) |
| [qubesbuilder/plugins/template/\_\_init\_\_.py](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?src=pr&el=tree&filepath=qubesbuilder%2Fplugins%2Ftemplate%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS#diff-cXViZXNidWlsZGVyL3BsdWdpbnMvdGVtcGxhdGUvX19pbml0X18ucHk=) | 73.33% | [4 Missing :warning: ](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS) |
| [qubesbuilder/cli/cli\_template.py](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?src=pr&el=tree&filepath=qubesbuilder%2Fcli%2Fcli_template.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS#diff-cXViZXNidWlsZGVyL2NsaS9jbGlfdGVtcGxhdGUucHk=) | 50.00% | [1 Missing :warning: ](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #191      +/-   ##
==========================================
+ Coverage   74.78%   74.95%   +0.17%     
==========================================
  Files          51       51              
  Lines        5928     6050     +122     
==========================================
+ Hits         4433     4535     +102     
- Misses       1495     1515      +20     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/QubesOS/qubes-builderv2/pull/191?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=QubesOS).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: marmarek
created_at: 2025-03-21T12:06:10Z
body: Oh, it looks like the issue can be reproduced on main already: https://gitlab.com/QubesOS/qubes-builderv2/-/jobs/9482178836/viewer

Lets see if this PR fixes it.
author_association: MEMBER
user: login: marmarek
created_at: 2025-03-22T02:56:49Z
body: PipelineRetry
author_association: MEMBER
user: login: marmarek
created_at: 2025-03-22T03:00:25Z
body: PipelineRetry
author_association: MEMBER
user: login: marmarek
created_at: 2025-03-22T17:52:01Z
body: @fepitre I gave up with new heuristics for job ordering. I've added explicit dependencies where they were missing and then processed it all via topological sort. Should be much more robust.
author_association: MEMBER