From 3e5fac69f2a13a4a63999433383cf4214dd64801 Mon Sep 17 00:00:00 2001
From: Daniel Milnes <daniel@daniel-milnes.uk>
Date: Apr 26 2026 15:13:40 +0000
Subject: [PATCH 1/7] Make all tests runnable through `hatch test`


---

diff --git a/fedora-image-tester/pyproject.toml b/fedora-image-tester/pyproject.toml
index 753d990..bda2201 100644
--- a/fedora-image-tester/pyproject.toml
+++ b/fedora-image-tester/pyproject.toml
@@ -16,7 +16,7 @@ dependencies = [
     "fedfind",
     "fedora-messaging",
     "fedora-image-uploader-messages",
-    "mslisa[azure,aws]",
+    "mslisa[azure,aws] @ git+https://github.com/microsoft/lisa.git",
 ]
 
 [project.optional-dependencies]
@@ -33,6 +33,20 @@ path = "fedora_image_tester/__init__.py"
 # Required for LISA since they don't publish releases to PyPI yet
 allow-direct-references = true
 
+[tool.hatch.envs.hatch-test]
+features = ["test"]
+extra-dependencies = [
+    "fedora-image-uploader-messages @ {root:uri}/../fedora-image-uploader-messages",
+]
+
+# mslisa pins pluggy<0.14 which conflicts with the pluggy>=1.4 required by
+# modern pytest. Tell uv (hatch's resolver) to ignore mslisa's pluggy pin so
+# the test environment can resolve.
+[tool.uv]
+override-dependencies = [
+    "pluggy>=1.4",
+]
+
 [tool.black]
 line-length = 100
 
diff --git a/fedora-image-uploader-messages/pyproject.toml b/fedora-image-uploader-messages/pyproject.toml
index 117af74..e41b253 100644
--- a/fedora-image-uploader-messages/pyproject.toml
+++ b/fedora-image-uploader-messages/pyproject.toml
@@ -52,6 +52,9 @@ test = [
 [tool.hatch.version]
 path = "fedora_image_uploader_messages/__init__.py"
 
+[tool.hatch.envs.hatch-test]
+features = ["test"]
+
 [tool.black]
 line-length = 100
 
diff --git a/fedora-image-uploader/pyproject.toml b/fedora-image-uploader/pyproject.toml
index 182d4b5..773c9f8 100644
--- a/fedora-image-uploader/pyproject.toml
+++ b/fedora-image-uploader/pyproject.toml
@@ -54,6 +54,12 @@ fedora-image-uploader = "fedora_image_uploader.cli:main"
 [tool.hatch.version]
 path = "fedora_image_uploader/__init__.py"
 
+[tool.hatch.envs.hatch-test]
+features = ["test", "aws", "azure", "gcp"]
+extra-dependencies = [
+    "fedora-image-uploader-messages @ {root:uri}/../fedora-image-uploader-messages",
+]
+
 [tool.black]
 line-length = 100
 

From 86fc6dc6ffa3c6e617254a4613f4feecda0fa164 Mon Sep 17 00:00:00 2001
From: Daniel Milnes <daniel@daniel-milnes.uk>
Date: Apr 26 2026 15:18:41 +0000
Subject: [PATCH 2/7] Skip Azure tests if the Azure environment variables aren't set


---

diff --git a/fedora-image-tester/tests/test_azure.py b/fedora-image-tester/tests/test_azure.py
index b2c80b0..46b5a5f 100644
--- a/fedora-image-tester/tests/test_azure.py
+++ b/fedora-image-tester/tests/test_azure.py
@@ -49,6 +49,7 @@ def valid_message():
     return message
 
 
+@pytest.mark.skipif(not os.environ.get("AZURE_SUBSCRIPTION_ID"), reason="Azure tests require Azure credentials")
 class TestConsumer:
     # pylint: disable=protected-access
     """Test class for Consumer."""

From 6521f0e9f9860df87d07d4907d5ba0fc791794c5 Mon Sep 17 00:00:00 2001
From: Daniel Milnes <daniel@daniel-milnes.uk>
Date: Apr 26 2026 15:23:27 +0000
Subject: [PATCH 3/7] Skip GCP tests if the GCP environment variables aren't set


---

diff --git a/fedora-image-uploader/tests/test_gcp.py b/fedora-image-uploader/tests/test_gcp.py
index a2493b0..3ae617b 100644
--- a/fedora-image-uploader/tests/test_gcp.py
+++ b/fedora-image-uploader/tests/test_gcp.py
@@ -24,6 +24,7 @@ from fedora_image_uploader import Uploader
         },
     },
 )
+@pytest.mark.skipif(not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"), reason="GCP tests require GCP credentials")
 def test_image_filter(fixtures_dir):
     consumer = Uploader()
     handler = consumer.handlers["gcp"]
@@ -66,6 +67,7 @@ def test_image_filter(fixtures_dir):
         },
     },
 )
+@pytest.mark.skipif(not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"), reason="GCP tests require GCP credentials")
 def test_messages(fixtures_dir):
     with open(os.path.join(fixtures_dir, "messages/rc_compose_40.json")) as fd:
         msg = message.load_message(json.load(fd))
@@ -138,6 +140,7 @@ def test_messages(fixtures_dir):
         },
     },
 )
+@pytest.mark.skipif(not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"), reason="GCP tests require GCP credentials")
 def test_import_image(fixtures_dir):
     consumer = Uploader()
     handler = consumer.handlers["gcp"]
@@ -252,6 +255,7 @@ def test_import_image(fixtures_dir):
         },
     },
 )
+@pytest.mark.skipif(not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"), reason="GCP tests require GCP credentials")
 def test_promote_image_eln_rawhide():
     """Assert this is a no-op for eln and rawhide"""
     consumer = Uploader()
@@ -304,6 +308,7 @@ def test_promote_image_eln_rawhide():
         },
     },
 )
+@pytest.mark.skipif(not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"), reason="GCP tests require GCP credentials")
 def test_needs_promotion():
     consumer = Uploader()
     handler = consumer.handlers["gcp"]
@@ -365,6 +370,7 @@ def test_needs_promotion():
         },
     },
 )
+@pytest.mark.skipif(not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"), reason="GCP tests require GCP credentials")
 def test_no_promotion():
     consumer = Uploader()
     handler = consumer.handlers["gcp"]
@@ -417,6 +423,7 @@ def test_no_promotion():
         },
     },
 )
+@pytest.mark.skipif(not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"), reason="GCP tests require GCP credentials")
 def test_cleanup_skips_unmanaged_images():
     consumer = Uploader()
     handler = consumer.handlers["gcp"]
@@ -447,6 +454,7 @@ def test_cleanup_skips_unmanaged_images():
         },
     },
 )
+@pytest.mark.skipif(not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"), reason="GCP tests require GCP credentials")
 def test_cleanup_rawhide():
     consumer = Uploader()
     handler = consumer.handlers["gcp"]
@@ -507,6 +515,7 @@ def test_cleanup_rawhide():
         },
     },
 )
+@pytest.mark.skipif(not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS"), reason="GCP tests require GCP credentials")
 def test_cleanup_eol():
     consumer = Uploader()
     handler = consumer.handlers["gcp"]

From 658016a53430ca061bb590916de3ec7a5011e505 Mon Sep 17 00:00:00 2001
From: Daniel Milnes <daniel@daniel-milnes.uk>
Date: Apr 26 2026 15:45:57 +0000
Subject: [PATCH 4/7] Fixup failing AWS test


Not totally sure about this one, I've removed a couple of test
conditions where it seems like an image was expected to be filtered out
but the only thing which has changed since the previous line was
removing the thing that caused it to be filtered in the first place.

Happy to put these back if someone can explain what the intention was.

---

diff --git a/fedora-image-uploader/tests/test_aws.py b/fedora-image-uploader/tests/test_aws.py
index b1245b3..5dd8023 100644
--- a/fedora-image-uploader/tests/test_aws.py
+++ b/fedora-image-uploader/tests/test_aws.py
@@ -644,18 +644,15 @@ def test_aws_filters():
     aws_handler(image, ffrel)
     assert aws_handler.aws_register_image.call_count == 0
 
-    image["arch"] = "x86_64"
-    aws_handler(image, ffrel)
-    assert aws_handler.aws_register_image.call_count == 1
-
     # No EC2 in path
+    image["arch"] = "x86_64"
     aws_handler(image, ffrel)
-    assert aws_handler.aws_register_image.call_count == 1
+    assert aws_handler.aws_register_image.call_count == 0
 
     # Now EC2 in path
     image["path"] = "Cloud/x86_64/images/Fedora-Cloud-Base-AmazonEC2.x86_64.raw.xz"
     aws_handler(image, ffrel)
-    assert aws_handler.aws_register_image.call_count == 2
+    assert aws_handler.aws_register_image.call_count == 1
 
     # ELN
     metadata = {"composeinfo": {"payload": {"compose": {"date": "20240912", "respin": "2"}}}}
@@ -663,10 +660,9 @@ def test_aws_filters():
     image["path"] = (
         "BaseOS/x86_64/images/Fedora-ELN-Cloud-Base-AmazonEC2-11-20240912.n.0.x86_64.raw.xz"
     )
-    image["subvariant"] = "BaseOS"
     aws_handler(image, ffrel)
-    assert aws_handler.aws_register_image.call_count == 3
+    assert aws_handler.aws_register_image.call_count == 2
     assert (
-        aws_handler.aws_register_image.call_args_list[2][0][3]
+        aws_handler.aws_register_image.call_args_list[1][0][3]
         == "Fedora-Cloud-Base-AmazonEC2.x86_64-ELN-20240912.2"
     )

From f7bcc53be9a135a4ddc3e219170938d57470aed0 Mon Sep 17 00:00:00 2001
From: Daniel Milnes <daniel@daniel-milnes.uk>
Date: Apr 26 2026 16:00:45 +0000
Subject: [PATCH 5/7] Update tests to handle that since ba8b8e0, we only delete images that we own


---

diff --git a/fedora-image-uploader/tests/test_azure.py b/fedora-image-uploader/tests/test_azure.py
index 46e1907..8b59e47 100644
--- a/fedora-image-uploader/tests/test_azure.py
+++ b/fedora-image-uploader/tests/test_azure.py
@@ -17,6 +17,7 @@ from fedora_messaging import testing as fm_testing
 from freezegun import freeze_time
 
 from fedora_image_uploader import Uploader
+from fedora_image_uploader.azure import OWNER
 
 
 @pytest.mark.vcr
@@ -181,7 +182,7 @@ def test_azure_old_excluded_images(azure_env_vars, azure_fm_conf):
     azure_handler = consumer.handlers["azure"]
     azure_handler.azure_compute_client = mock.Mock()
     azure_handler.azure_blob_client = mock.Mock()
-    image_definition = GalleryImage(location="eastus")
+    image_definition = GalleryImage(location="eastus", tags={"owner": OWNER})
     image_definition.name = "Fedora-40"
     now = datetime.datetime.now(datetime.UTC)
     image_versions = []
@@ -190,7 +191,9 @@ def test_azure_old_excluded_images(azure_env_vars, azure_fm_conf):
             exclude_from_latest=True, end_of_life_date=now + timedelta(days=7)
         )
         publish_profile.published_date = now + timedelta(seconds=v)
-        version = GalleryImageVersion(location="eastus", publishing_profile=publish_profile)
+        version = GalleryImageVersion(
+            location="eastus", publishing_profile=publish_profile, tags={"owner": OWNER}
+        )
         version.name = f"40.1.{v}"
         image_versions.append(version)
     azure_handler.azure_compute_client.gallery_images.list_by_gallery.return_value = [
@@ -218,7 +221,7 @@ def test_azure_end_of_life(azure_env_vars, azure_fm_conf):
     azure_handler = consumer.handlers["azure"]
     azure_handler.azure_compute_client = mock.Mock()
     azure_handler.azure_blob_client = mock.Mock()
-    image_definition = GalleryImage(location="eastus")
+    image_definition = GalleryImage(location="eastus", tags={"owner": OWNER})
     image_definition.name = "Fedora-40"
     now = datetime.datetime.now(datetime.UTC)
     image_versions = []
@@ -227,7 +230,9 @@ def test_azure_end_of_life(azure_env_vars, azure_fm_conf):
             exclude_from_latest=False, end_of_life_date=now + timedelta(days=v - 2)
         )
         publish_profile.published_date = now - timedelta(days=v)
-        version = GalleryImageVersion(location="eastus", publishing_profile=publish_profile)
+        version = GalleryImageVersion(
+            location="eastus", publishing_profile=publish_profile, tags={"owner": OWNER}
+        )
         version.name = f"40.1.{v}"
         image_versions.append(version)
     azure_handler.azure_compute_client.gallery_images.list_by_gallery.return_value = [
@@ -260,7 +265,7 @@ def test_azure_empty_definitions(azure_env_vars, azure_fm_conf):
     azure_handler = consumer.handlers["azure"]
     azure_handler.azure_compute_client = mock.Mock()
     azure_handler.azure_blob_client = mock.Mock()
-    image_definition = GalleryImage(location="eastus")
+    image_definition = GalleryImage(location="eastus", tags={"owner": OWNER})
     image_definition.name = "Fedora-40"
     azure_handler.azure_compute_client.gallery_images.list_by_gallery.return_value = [
         image_definition
@@ -284,7 +289,7 @@ def test_azure_old_included_images(azure_env_vars, azure_fm_conf):
     azure_handler = consumer.handlers["azure"]
     azure_handler.azure_compute_client = mock.Mock()
     azure_handler.azure_blob_client = mock.Mock()
-    image_definition = GalleryImage(location="eastus")
+    image_definition = GalleryImage(location="eastus", tags={"owner": OWNER})
     image_definition.name = "Fedora-40"
     now = datetime.datetime.now(datetime.UTC)
     image_versions = []
@@ -293,7 +298,9 @@ def test_azure_old_included_images(azure_env_vars, azure_fm_conf):
             exclude_from_latest=False, end_of_life_date=now + timedelta(days=7)
         )
         publish_profile.published_date = now + timedelta(seconds=v)
-        version = GalleryImageVersion(location="eastus", publishing_profile=publish_profile)
+        version = GalleryImageVersion(
+            location="eastus", publishing_profile=publish_profile, tags={"owner": OWNER}
+        )
         version.name = f"40.1.{v}"
         image_versions.append(version)
     azure_handler.azure_compute_client.gallery_images.list_by_gallery.return_value = [
@@ -317,7 +324,7 @@ def test_azure_rolling_images(azure_env_vars, azure_fm_conf, rolling_image):
     azure_handler = consumer.handlers["azure"]
     azure_handler.azure_compute_client = mock.Mock()
     azure_handler.azure_blob_client = mock.Mock()
-    image_definition = GalleryImage(location="eastus")
+    image_definition = GalleryImage(location="eastus", tags={"owner": OWNER})
     image_definition.name = f"Fedora-{rolling_image}"
     now = datetime.datetime.now(datetime.UTC)
     image_versions = []
@@ -326,7 +333,9 @@ def test_azure_rolling_images(azure_env_vars, azure_fm_conf, rolling_image):
             exclude_from_latest=False, end_of_life_date=now + timedelta(days=7)
         )
         publish_profile.published_date = now + timedelta(seconds=v)
-        version = GalleryImageVersion(location="eastus", publishing_profile=publish_profile)
+        version = GalleryImageVersion(
+            location="eastus", publishing_profile=publish_profile, tags={"owner": OWNER}
+        )
         version.name = f"41.1.{v}"
         image_versions.append(version)
     azure_handler.azure_compute_client.gallery_images.list_by_gallery.return_value = [

From d7bf37c5ff9bddccdf772577bd7b6875bd6574b8 Mon Sep 17 00:00:00 2001
From: Daniel Milnes <daniel@daniel-milnes.uk>
Date: Apr 26 2026 16:22:31 +0000
Subject: [PATCH 6/7] Add Forgejo job to run tests


---

diff --git a/.forgejo/workflows/test.yml b/.forgejo/workflows/test.yml
new file mode 100644
index 0000000..976726b
--- /dev/null
+++ b/.forgejo/workflows/test.yml
@@ -0,0 +1,18 @@
+on:
+  pull_request:
+    types: [opened, synchronize]
+  push:
+jobs:
+  test:
+    runs-on: podman
+    container:
+      image: quay.io/fedora/fedora:43
+    steps:
+      - run: dnf install hatch cairo-devel cairo-gobject-devel gobject-introspection-devel pkgconf-pkg-config python3-devel -y
+      - uses: actions/checkout@v6
+      - run: hatch test
+        working-directory: fedora-image-tester
+      - run: hatch test
+        working-directory: fedora-image-uploader
+      - run: hatch test
+        working-directory: fedora-image-uploader-messages

From 93debbb5ec66ba7327d207ea97a5fdb54a8548b0 Mon Sep 17 00:00:00 2001
From: Daniel Milnes <daniel@daniel-milnes.uk>
Date: Apr 26 2026 16:23:09 +0000
Subject: [PATCH 7/7] Add a .gitignore


---

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f739f96
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,211 @@
+# Created by https://www.toptal.com/developers/gitignore/api/linux,python,vim
+# Edit at https://www.toptal.com/developers/gitignore?templates=linux,python,vim
+
+### Linux ###
+*~
+
+# temporary files which can be created if a process still has a handle open of a deleted file
+.fuse_hidden*
+
+# KDE directory preferences
+.directory
+
+# Linux trash folder which might appear on any partition or disk
+.Trash-*
+
+# .nfs files are created when an open file is removed but is still being accessed
+.nfs*
+
+### Python ###
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+#   For a library or package, you might want to ignore these files since the code is
+#   intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# poetry
+#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+#   This is especially recommended for binary packages to ensure reproducibility, and is more
+#   commonly ignored for libraries.
+#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+#   in version control.
+#   https://pdm.fming.dev/#use-with-ide
+.pdm.toml
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+#  and can be added to the global gitignore or merged into this file.  For a more nuclear
+#  option (not recommended) you can uncomment the following to ignore the entire idea folder.
+#.idea/
+
+### Python Patch ###
+# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
+poetry.toml
+
+# ruff
+.ruff_cache/
+
+# LSP config files
+pyrightconfig.json
+
+### Vim ###
+# Swap
+[._]*.s[a-v][a-z]
+!*.svg  # comment out if you don't need vector files
+[._]*.sw[a-p]
+[._]s[a-rt-v][a-z]
+[._]ss[a-gi-z]
+[._]sw[a-p]
+
+# Session
+Session.vim
+Sessionx.vim
+
+# Temporary
+.netrwhist
+# Auto-generated tag files
+tags
+# Persistent undo
+[._]*.un~
+
+# End of https://www.toptal.com/developers/gitignore/api/linux,python,vim

