Repository: canonical/charmcraft

Issue:
Charmcraft 3 breaks charms with multiple run-on architectures
### Bug Description

For Jammy and below, charms with multiple `run-on` get the message `Architecture 'amd64-arm64-ppc64el-s390x' is not supported.` (or similar)

### To Reproduce

1. `git clone https://opendev.org/openstack/charm-vault.git`
2. `git switch stable/1.8`
3. `charmcraft pack` (with charmcraft 3)

### Environment

Linux charmcraft 3

### charmcraft.yaml

```shell
type: charm

parts:
  charm:
    plugin: dump
    source: .
    prime:
      - actions/*
      - charmhelpers/*
      - files/*
      - hooks/*
      - lib/*
      - templates/*
      - actions.yaml
      - config.yaml
      - copyright
      - hardening.yaml
      - icon.svg
      - LICENSE
      - Makefile
      - metadata.yaml
      - README.md

bases:
  - build-on:
      - name: ubuntu
        channel: "22.04"
        architectures:
          - amd64
    run-on:
      - name: ubuntu
        channel: "22.04"
        architectures: [amd64, s390x, ppc64el, arm64]
```


### Relevant log output

```shell
2024-06-10 14:08:02.305 Starting charmcraft, version 3.0.0.post46+ga44c03e
2024-06-10 14:08:02.305 Log verbosity level set to BRIEF
2024-06-10 14:08:02.305 Configuring application...
2024-06-10 14:08:02.305 Preparing application...
2024-06-10 14:08:02.306 Build plan: platform=None, build_for=None
2024-06-10 14:08:02.307 Loading project file '/home/lengau/Work/Code/charm-nova-compute/charmcraft.yaml'
2024-06-10 14:08:02.309 Warning: use of 'prime' in a charm part is deprecated and no longer works, see https://juju.is/docs/sdk/include-extra-files-in-a-charm
2024-06-10 14:08:02.339 Architecture 'amd64-arm64-ppc64el-s390x' is not supported.
2024-06-10 14:08:02.340 Traceback (most recent call last):
2024-06-10 14:08:02.340   File "/snap/charmcraft/3838/lib/python3.10/site-packages/craft_application/application.py", line 508, in run
2024-06-10 14:08:02.340     self.services.project = self.get_project(
2024-06-10 14:08:02.340   File "/snap/charmcraft/3838/lib/python3.10/site-packages/craft_application/application.py", line 303, in get_project
2024-06-10 14:08:02.340     yaml_data = self._transform_project_yaml(yaml_data, build_on, build_for)
2024-06-10 14:08:02.340   File "/snap/charmcraft/3838/lib/python3.10/site-packages/craft_application/application.py", line 601, in _transform_project_yaml
2024-06-10 14:08:02.340     self._expand_environment(yaml_data=yaml_data, build_for=build_for)
2024-06-10 14:08:02.340   File "/snap/charmcraft/3838/lib/python3.10/site-packages/craft_application/application.py", line 629, in _expand_environment
2024-06-10 14:08:02.340     info = craft_parts.ProjectInfo(
2024-06-10 14:08:02.340   File "/snap/charmcraft/3838/lib/python3.10/site-packages/craft_parts/infos.py", line 99, in __init__
2024-06-10 14:08:02.340     self._set_machine(arch)
2024-06-10 14:08:02.340   File "/snap/charmcraft/3838/lib/python3.10/site-packages/craft_parts/infos.py", line 333, in _set_machine
2024-06-10 14:08:02.340     raise errors.InvalidArchitecture(arch)
2024-06-10 14:08:02.340 craft_parts.errors.InvalidArchitecture: Architecture 'amd64-arm64-ppc64el-s390x' is not supported.
2024-06-10 14:08:02.340 Make sure the architecture name is correct.
2024-06-10 14:08:02.340 Recommended resolution: Make sure the architecture name is correct.
2024-06-10 14:08:02.340 Full execution log: '/home/lengau/.local/state/charmcraft/log/charmcraft-20240610-140802.305265.log'
```


Discussion:

Patch:
diff --git a/charmcraft/application/commands/analyse.py b/charmcraft/application/commands/analyse.py
index 6b92762e..8b160efe 100644
--- a/charmcraft/application/commands/analyse.py
+++ b/charmcraft/application/commands/analyse.py
@@ -85,8 +85,7 @@ class Analyse(base.CharmcraftCommand):
                 filepath, ignore=ignore, include_ignored=False
             ):
                 emit.progress(str(result), permanent=True)
-                if result.level > max_level:
-                    max_level = result.level
+                max_level = max(result.level, max_level)
                 progress.advance(1)
 
         return max_level.return_code
diff --git a/charmcraft/application/main.py b/charmcraft/application/main.py
index d643eda2..21cd48f9 100644
--- a/charmcraft/application/main.py
+++ b/charmcraft/application/main.py
@@ -20,8 +20,9 @@ import pathlib
 import shutil
 from typing import Any
 
+import craft_application
 import craft_cli
-from craft_application import Application, AppMetadata
+from craft_application import util
 from craft_parts.plugins import plugins
 from overrides import override
 
@@ -39,7 +40,7 @@ operator development and collaboration.
 See https://charmhub.io/publishing for more information.
 """
 
-APP_METADATA = AppMetadata(
+APP_METADATA = craft_application.AppMetadata(
     name="charmcraft",
     summary=GENERAL_SUMMARY,
     ProjectClass=models.CharmcraftProject,
@@ -56,12 +57,12 @@ PRIME_BEHAVIOUR_CHANGE_MESSAGE = (
 )
 
 
-class Charmcraft(Application):
+class Charmcraft(craft_application.Application):
     """Charmcraft application definition."""
 
     def __init__(
         self,
-        app: AppMetadata,
+        app: craft_application.AppMetadata,
         services: CharmcraftServiceFactory,
     ) -> None:
         super().__init__(app=app, services=services, extra_loggers={"charmcraft"})
@@ -163,6 +164,21 @@ class Charmcraft(Application):
                     for filename in package_files:
                         shutil.move(str(self._work_dir / filename), output_path / filename)
 
+    def _expand_environment(self, yaml_data: dict[str, Any], build_for: str) -> None:
+        """Perform expansion of project environment variables.
+
+        :param yaml_data: The project's yaml data.
+        :param build_for: The architecture to build for.
+        """
+        if "-" in build_for:
+            build_for = util.get_host_architecture()
+            craft_cli.emit.debug(
+                "Expanding environment variables with the host architecture "
+                f"{build_for!r} as the build-for architecture because multiple "
+                "run-on architectures were specified."
+            )
+        super()._expand_environment(yaml_data, build_for)
+
 
 def main() -> int:
     """Run craft-application based charmcraft."""
diff --git a/charmcraft/services/lifecycle.py b/charmcraft/services/lifecycle.py
index cf0bc840..4989793c 100644
--- a/charmcraft/services/lifecycle.py
+++ b/charmcraft/services/lifecycle.py
@@ -16,7 +16,9 @@
 """Service class for running craft lifecycle commands."""
 from __future__ import annotations
 
-from craft_application import services
+from craft_application import services, util
+from craft_cli import emit
+from overrides import override
 
 
 class LifecycleService(services.LifecycleService):
@@ -26,3 +28,30 @@ class LifecycleService(services.LifecycleService):
         """Do Charmcraft-specific setup work."""
         self._manager_kwargs.setdefault("project_name", self._project.name)
         super().setup()
+
+    @override
+    def _get_build_for(self) -> str:
+        build_for = super()._get_build_for()
+        if "-" not in build_for:
+            if self._build_plan and self._build_plan[0].build_for == "all":
+                emit.progress(
+                    "WARNING: Charmcraft does not validate that charms with "
+                    "architecture 'all' are fully architecture agnostic.",
+                    permanent=True,
+                )
+            return build_for
+
+        # Multi-arch builds: Tell craft-parts that we're building for any foreign
+        # architecture (to trick it into trying to cross-compile anything, leading
+        # to more likely failures.)
+        emit.progress(
+            "WARNING: Charmcraft does not validate that charms with multiple "
+            "given architectures are architecture agnostic.",
+            permanent=True,
+        )
+        host_arch = util.get_host_architecture()
+        for arch in build_for.split("-"):
+            if arch != host_arch:
+                return arch
+
+        return host_arch


Test patch:
diff --git a/tests/commands/test_store_api.py b/tests/commands/test_store_api.py
index 686144db..11a22d9a 100644
--- a/tests/commands/test_store_api.py
+++ b/tests/commands/test_store_api.py
@@ -47,7 +47,7 @@ from charmcraft.utils import ResourceOption
 from tests.commands.test_store_client import FakeResponse
 
 
-@pytest.fixture()
+@pytest.fixture
 def client_mock(monkeypatch):
     """Fixture to provide a mocked client."""
     monkeypatch.setattr(platform, "node", lambda: "fake-host")
@@ -56,7 +56,7 @@ def client_mock(monkeypatch):
         yield client_mock
 
 
-@pytest.fixture()
+@pytest.fixture
 def anonymous_client_mock(monkeypatch):
     """Fixture to provide a mocked anonymous client."""
     anonymous_client_mock = MagicMock(spec=AnonymousClient)
diff --git a/tests/commands/test_store_client.py b/tests/commands/test_store_client.py
index 4dfa7860..392e23c4 100644
--- a/tests/commands/test_store_client.py
+++ b/tests/commands/test_store_client.py
@@ -100,7 +100,7 @@ class FakeResponse(requests.Response):
         return self.content
 
 
-@pytest.fixture()
+@pytest.fixture
 def client_class():
     """Return a client instance with craft-store's StoreClient methods mocked."""
 
diff --git a/tests/conftest.py b/tests/conftest.py
index 3cd1591a..1eeecf33 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -38,7 +38,7 @@ from charmcraft.application.main import APP_METADATA
 from charmcraft.models import project
 
 
-@pytest.fixture()
+@pytest.fixture
 def simple_charm():
     return project.BasesCharm(
         type="charm",
@@ -60,7 +60,7 @@ def simple_charm():
     )
 
 
-@pytest.fixture()
+@pytest.fixture
 def mock_store_client():
     client = mock.Mock(spec_set=store.Client)
 
@@ -71,12 +71,12 @@ def mock_store_client():
     return client
 
 
-@pytest.fixture()
+@pytest.fixture
 def mock_store_anonymous_client() -> mock.Mock:
     return mock.Mock(spec_set=store.AnonymousClient)
 
 
-@pytest.fixture()
+@pytest.fixture
 def service_factory(
     fs,
     fake_project_dir,
@@ -108,7 +108,7 @@ def service_factory(
     return factory
 
 
-@pytest.fixture()
+@pytest.fixture
 def default_build_plan():
     arch = util.get_host_architecture()
     return [
@@ -121,33 +121,33 @@ def default_build_plan():
     ]
 
 
-@pytest.fixture()
+@pytest.fixture
 def fake_project_dir(fs) -> pathlib.Path:
     project_dir = pathlib.Path("/root/project")
     fs.create_dir(project_dir)
     return project_dir
 
 
-@pytest.fixture()
+@pytest.fixture
 def fake_prime_dir(fs) -> pathlib.Path:
     prime_dir = pathlib.Path("/root/prime")
     fs.create_dir(prime_dir)
     return prime_dir
 
 
-@pytest.fixture()
+@pytest.fixture
 def fake_path(fs) -> Iterator[pathlib.Path]:
     """Like tmp_path, but with a fake filesystem."""
     with tempfile.TemporaryDirectory() as tmp_dir:
         yield pathlib.Path(tmp_dir)
 
 
-@pytest.fixture()
+@pytest.fixture
 def global_debug():
     os.environ["CRAFT_DEBUG"] = "1"
 
 
-@pytest.fixture()
+@pytest.fixture
 def new_path(monkeypatch, tmp_path):
     monkeypatch.chdir(tmp_path)
     return tmp_path
@@ -163,7 +163,7 @@ def setup_parts():
     parts.setup_parts()
 
 
-@pytest.fixture()
+@pytest.fixture
 def charmhub_config() -> env.CharmhubConfig:
     """Provide a charmhub config for use in tests"""
     return env.CharmhubConfig(
@@ -194,14 +194,14 @@ def intertests_cleanups():
     callbacks.unregister_all()
 
 
-@pytest.fixture()
+@pytest.fixture
 def responses():
     """Simple helper to use responses module as a fixture, for easier integration in tests."""
     with responses_module.RequestsMock() as rsps:
         yield rsps
 
 
-@pytest.fixture()
+@pytest.fixture
 def prepare_charmcraft_yaml(tmp_path: pathlib.Path):
     """Helper to create a charmcraft.yaml file in disk.
 
@@ -238,7 +238,7 @@ def prepare_file(tmp_path: pathlib.Path, filename: str):
     return prepare
 
 
-@pytest.fixture()
+@pytest.fixture
 def prepare_metadata_yaml(tmp_path: pathlib.Path):
     """Helper to create a metadata.yaml file in disk.
 
@@ -247,7 +247,7 @@ def prepare_metadata_yaml(tmp_path: pathlib.Path):
     return prepare_file(tmp_path, const.METADATA_FILENAME)
 
 
-@pytest.fixture()
+@pytest.fixture
 def prepare_actions_yaml(tmp_path: pathlib.Path):
     """Helper to create a actions.yaml file in disk.
 
@@ -256,7 +256,7 @@ def prepare_actions_yaml(tmp_path: pathlib.Path):
     return prepare_file(tmp_path, const.JUJU_ACTIONS_FILENAME)
 
 
-@pytest.fixture()
+@pytest.fixture
 def prepare_config_yaml(tmp_path: pathlib.Path):
     """Helper to create a config.yaml file in disk.
 
@@ -265,7 +265,7 @@ def prepare_config_yaml(tmp_path: pathlib.Path):
     return prepare_file(tmp_path, const.JUJU_CONFIG_FILENAME)
 
 
-@pytest.fixture()
+@pytest.fixture
 def emitter(emitter):
     """Monkeypatch craft-cli's emitter fixture to easily test the JSON encoded output."""
 
@@ -284,7 +284,7 @@ def emitter(emitter):
     return emitter
 
 
-@pytest.fixture()
+@pytest.fixture
 def assert_output(capsys):
     """Assert that a given string was sent to stdout.
 
@@ -305,7 +305,7 @@ def assert_output(capsys):
     return helper
 
 
-@pytest.fixture()
+@pytest.fixture
 def build_charm_directory():
     def helper(tmp_path, fake_charms, file_type="charm"):
         expected = {}
@@ -324,7 +324,7 @@ def build_charm_directory():
     return helper
 
 
-@pytest.fixture()
+@pytest.fixture
 def stub_extensions(monkeypatch):
     from charmcraft.extensions import registry
 
@@ -334,7 +334,7 @@ def stub_extensions(monkeypatch):
     return extensions_dict
 
 
-@pytest.fixture()
+@pytest.fixture
 def charm_plugin(tmp_path):
     requirement_files = ["reqs1.txt", "reqs2.txt"]
     for req in requirement_files:
@@ -363,7 +363,7 @@ def charm_plugin(tmp_path):
     return plugins.get_plugin(part=part, part_info=part_info, properties=plugin_properties)
 
 
-@pytest.fixture()
+@pytest.fixture
 def bundle_plugin(tmp_path):
     project_dirs = craft_parts.ProjectDirs(work_dir=tmp_path)
     spec = {
diff --git a/tests/extensions/test_extensions.py b/tests/extensions/test_extensions.py
index b93504b1..794b09d9 100644
--- a/tests/extensions/test_extensions.py
+++ b/tests/extensions/test_extensions.py
@@ -98,7 +98,7 @@ class FullExtension(FakeExtension):
         return {"full-extension/new-part": {"plugin": "nil", "source": None}}
 
 
-@pytest.fixture()
+@pytest.fixture
 def fake_extensions(stub_extensions):
     extensions.register(FakeExtension.name, FakeExtension)
     extensions.register(ExperimentalExtension.name, ExperimentalExtension)
diff --git a/tests/extensions/test_registry.py b/tests/extensions/test_registry.py
index 50a78b27..b4cf9f30 100644
--- a/tests/extensions/test_registry.py
+++ b/tests/extensions/test_registry.py
@@ -55,7 +55,7 @@ class FakeExtension3(Extension):
     name = "fake-extension-3"
 
 
-@pytest.fixture()
+@pytest.fixture
 def fake_extensions(stub_extensions):
     fakes = [FakeExtension1, FakeExtension2]
     for ext_class in fakes:
diff --git a/tests/integration/commands/conftest.py b/tests/integration/commands/conftest.py
index 17576e7b..8248b5ce 100644
--- a/tests/integration/commands/conftest.py
+++ b/tests/integration/commands/conftest.py
@@ -20,6 +20,6 @@ import pytest
 from charmcraft import application
 
 
-@pytest.fixture()
+@pytest.fixture
 def config(service_factory):
     return {"app": application.APP_METADATA, "services": service_factory}
diff --git a/tests/integration/commands/test_expand_extensions.py b/tests/integration/commands/test_expand_extensions.py
index 4fbf54d0..bd8c3e61 100644
--- a/tests/integration/commands/test_expand_extensions.py
+++ b/tests/integration/commands/test_expand_extensions.py
@@ -37,7 +37,7 @@ class TestExtension(FakeExtension):
         return {"terms": ["https://example.com/test"]}
 
 
-@pytest.fixture()
+@pytest.fixture
 def fake_extensions(stub_extensions):
     extensions.register(TestExtension.name, TestExtension)
 
diff --git a/tests/integration/commands/test_init.py b/tests/integration/commands/test_init.py
index 813b0be8..a4f63280 100644
--- a/tests/integration/commands/test_init.py
+++ b/tests/integration/commands/test_init.py
@@ -95,7 +95,7 @@ VALID_AUTHORS = [
 ]
 
 
-@pytest.fixture()
+@pytest.fixture
 def init_command():
     return commands.InitCommand({"app": charmcraft.application.APP_METADATA, "services": None})
 
@@ -257,7 +257,7 @@ def test_executable_set(new_path, init_command):
         assert path.stat().st_mode & S_IXALL == S_IXALL
 
 
-@pytest.mark.slow()
+@pytest.mark.slow
 @pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows")
 @pytest.mark.skipif(
     bool(os.getenv("RUNNING_TOX")) and sys.version_info < (3, 11),
diff --git a/tests/integration/commands/test_resource_revisions.py b/tests/integration/commands/test_resource_revisions.py
index 04d15346..3e7f8c63 100644
--- a/tests/integration/commands/test_resource_revisions.py
+++ b/tests/integration/commands/test_resource_revisions.py
@@ -31,7 +31,7 @@ from charmcraft.cmdbase import JSON_FORMAT
 from charmcraft.env import CharmhubConfig
 
 
-@pytest.fixture()
+@pytest.fixture
 def store_mock():
     """The fixture to fake the store layer in all the tests."""
     store_mock = mock.Mock(spec_set=store.Store)
diff --git a/tests/integration/commands/test_set_resource_architectures.py b/tests/integration/commands/test_set_resource_architectures.py
index ffdaefb1..ae40e050 100644
--- a/tests/integration/commands/test_set_resource_architectures.py
+++ b/tests/integration/commands/test_set_resource_architectures.py
@@ -25,7 +25,7 @@ from charmcraft.application.commands import SetResourceArchitecturesCommand
 from tests import get_fake_revision
 
 
-@pytest.fixture()
+@pytest.fixture
 def cmd(service_factory):
     return SetResourceArchitecturesCommand(
         config={"app": application.APP_METADATA, "services": service_factory}
diff --git a/tests/integration/commands/test_store_commands.py b/tests/integration/commands/test_store_commands.py
index 01742b61..873c40cb 100644
--- a/tests/integration/commands/test_store_commands.py
+++ b/tests/integration/commands/test_store_commands.py
@@ -27,7 +27,7 @@ from charmcraft.store.models import Library
 from tests import factory
 
 
-@pytest.fixture()
+@pytest.fixture
 def store_mock():
     """The fixture to fake the store layer in all the tests."""
     store_mock = mock.MagicMock()
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index e47c83af..b65258a6 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -23,14 +23,14 @@ from charmcraft import application, services
 from charmcraft.application import commands
 
 
-@pytest.fixture()
+@pytest.fixture
 def service_factory():
     factory = services.CharmcraftServiceFactory(app=application.APP_METADATA)
     factory.store.client = mock.Mock(spec_set=craft_store.StoreClient)
     return factory
 
 
-@pytest.fixture()
+@pytest.fixture
 def app(monkeypatch, new_path, service_factory):
     monkeypatch.setenv("CRAFT_DEBUG", "1")
     app = application.Charmcraft(app=application.APP_METADATA, services=service_factory)
diff --git a/tests/integration/services/conftest.py b/tests/integration/services/conftest.py
index 693d797f..04704da9 100644
--- a/tests/integration/services/conftest.py
+++ b/tests/integration/services/conftest.py
@@ -24,7 +24,7 @@ from charmcraft import services
 from charmcraft.application.main import APP_METADATA, Charmcraft
 
 
-@pytest.fixture()
+@pytest.fixture
 def service_factory(
     fs: pyfakefs.fake_filesystem.FakeFilesystem, fake_path, simple_charm
 ) -> services.CharmcraftServiceFactory:
diff --git a/tests/integration/services/test_lifecycle.py b/tests/integration/services/test_lifecycle.py
index 1187584a..ff473a44 100644
--- a/tests/integration/services/test_lifecycle.py
+++ b/tests/integration/services/test_lifecycle.py
@@ -16,7 +16,71 @@
 """Integration tests for the lifecycle service."""
 
 
-def test_init_lifecycle(service_factory):
+import distro
+import pytest
+from craft_application import errors, models, util
+from craft_providers import bases
+
+from charmcraft.services import CharmcraftServiceFactory
+
+
+def test_init_lifecycle(service_factory: CharmcraftServiceFactory):
     """Test the setup of a parts lifecycle, implicitly testing setup."""
 
     service_factory.lifecycle._init_lifecycle_manager()
+
+
+def test_lifecycle_build_for_invalid(service_factory: CharmcraftServiceFactory):
+    lifecycle = service_factory.lifecycle
+
+    lifecycle._build_plan = [
+        models.BuildInfo(
+            platform="something",
+            build_on=util.get_host_architecture(),
+            build_for="invalid",
+            base=bases.BaseName(distro.id(), distro.version()),
+        )
+    ]
+
+    with pytest.raises(
+        errors.PartsLifecycleError, match="[Aa]rchitecture '[a-z]+' is not supported"
+    ):
+        lifecycle._init_lifecycle_manager()
+
+
+def test_lifecycle_build_for_all(service_factory: CharmcraftServiceFactory):
+    lifecycle = service_factory.lifecycle
+
+    lifecycle._build_plan = [
+        models.BuildInfo(
+            platform="something",
+            build_on=util.get_host_architecture(),
+            build_for="all",
+            base=bases.BaseName(distro.id(), distro.version()),
+        )
+    ]
+
+    lcm = lifecycle._init_lifecycle_manager()
+
+    assert lcm._target_arch == util.get_host_architecture()
+
+
+def test_lifecycle_build_for_multi(service_factory: CharmcraftServiceFactory):
+    lifecycle = service_factory.lifecycle
+
+    host_arch = util.get_host_architecture()
+    arches = {"arm64", "riscv64", "amd64"} - {host_arch}
+    foreign_arch = next(iter(arches))
+
+    lifecycle._build_plan = [
+        models.BuildInfo(
+            platform="something",
+            build_on=util.get_host_architecture(),
+            build_for=f"{foreign_arch}-{host_arch}",
+            base=bases.BaseName(distro.id(), distro.version()),
+        )
+    ]
+
+    lcm = lifecycle._init_lifecycle_manager()
+
+    assert lcm._target_arch == foreign_arch
diff --git a/tests/integration/services/test_package.py b/tests/integration/services/test_package.py
index 6035e315..cfe9fc0b 100644
--- a/tests/integration/services/test_package.py
+++ b/tests/integration/services/test_package.py
@@ -27,7 +27,7 @@ from charmcraft import const, models, services
 from charmcraft.application.main import APP_METADATA
 
 
-@pytest.fixture()
+@pytest.fixture
 def package_service(fake_path, service_factory, default_build_plan):
     fake_project_dir = fake_path
     svc = services.PackageService(
diff --git a/tests/integration/services/test_store.py b/tests/integration/services/test_store.py
index 05ed870c..f54fe91a 100644
--- a/tests/integration/services/test_store.py
+++ b/tests/integration/services/test_store.py
@@ -21,7 +21,7 @@ import pytest
 from charmcraft import models, services
 
 
-@pytest.fixture()
+@pytest.fixture
 def store_service(service_factory):
     return service_factory.store
 
diff --git a/tests/spread/smoketests/basic/charmcraft-bases-22.04.yaml b/tests/spread/smoketests/basic/charmcraft-bases-22.04.yaml
index 9e89bba8..7ef9504a 100644
--- a/tests/spread/smoketests/basic/charmcraft-bases-22.04.yaml
+++ b/tests/spread/smoketests/basic/charmcraft-bases-22.04.yaml
@@ -10,3 +10,4 @@ bases:
     run-on:
     - name: ubuntu
       channel: "22.04"
+      architectures: [amd64, arm64, riscv64]
diff --git a/tests/spread/smoketests/basic/task.yaml b/tests/spread/smoketests/basic/task.yaml
index 8bf7a3b5..c4ea172c 100644
--- a/tests/spread/smoketests/basic/task.yaml
+++ b/tests/spread/smoketests/basic/task.yaml
@@ -28,7 +28,7 @@ restore: |
 
 execute: |
   cd charm
-  charmcraft pack --verbose
+  charmcraft pack
   test -f *.charm
   unzip -l *.charm | MATCH "src/charm.py"
   unzip -l *.charm | MATCH "venv/ops/charm.py"
diff --git a/tests/test_bases.py b/tests/test_bases.py
index d48d311c..be574315 100644
--- a/tests/test_bases.py
+++ b/tests/test_bases.py
@@ -24,14 +24,14 @@ from charmcraft.models.charmcraft import Base
 from charmcraft.utils import OSPlatform
 
 
-@pytest.fixture()
+@pytest.fixture
 def mock_get_os_platform():
     os_platform = OSPlatform(system="host-OS", release="host-CHANNEL", machine="host-ARCH")
     with patch("charmcraft.bases.get_os_platform", return_value=os_platform) as mock_platform:
         yield mock_platform
 
 
-@pytest.fixture()
+@pytest.fixture
 def mock_get_host_architecture():
     with patch(
         "craft_application.util.get_host_architecture", return_value="host-ARCH"
diff --git a/tests/test_instrum.py b/tests/test_instrum.py
index 5f775fea..b1602857 100644
--- a/tests/test_instrum.py
+++ b/tests/test_instrum.py
@@ -25,7 +25,7 @@ from charmcraft import instrum
 from charmcraft.instrum import Timer, _Measurements
 
 
-@pytest.fixture()
+@pytest.fixture
 def fake_times():
     """Provide times from 5 by 10."""
     with patch("charmcraft.instrum.time", side_effect=list(range(5, 100, 10))):
diff --git a/tests/test_snap.py b/tests/test_snap.py
index 6103720c..8ed617a1 100644
--- a/tests/test_snap.py
+++ b/tests/test_snap.py
@@ -27,7 +27,7 @@ from charmcraft.snap import (
 )
 
 
-@pytest.fixture()
+@pytest.fixture
 def mock_snap_config():
     with mock.patch("charmcraft.snap.snaphelpers.SnapConfig", autospec=True) as mock_snap_config:
         yield mock_snap_config
diff --git a/tests/unit/commands/test_lifecycle.py b/tests/unit/commands/test_lifecycle.py
index cc773e3d..04a210fa 100644
--- a/tests/unit/commands/test_lifecycle.py
+++ b/tests/unit/commands/test_lifecycle.py
@@ -58,7 +58,7 @@ def get_namespace(
     )
 
 
-@pytest.fixture()
+@pytest.fixture
 def pack(service_factory: services.ServiceFactory) -> lifecycle.PackCommand:
     return lifecycle.PackCommand({"app": application.APP_METADATA, "services": service_factory})
 
diff --git a/tests/unit/commands/test_list_extensions.py b/tests/unit/commands/test_list_extensions.py
index 769c81d6..ef8dc495 100644
--- a/tests/unit/commands/test_list_extensions.py
+++ b/tests/unit/commands/test_list_extensions.py
@@ -49,7 +49,7 @@ class YourFakeExtension(FakeExtension):
         return {"terms": ["https://example.com/test2"]}
 
 
-@pytest.fixture()
+@pytest.fixture
 def fake_extensions(stub_extensions):
     extensions.register(MyFakeExtension.name, MyFakeExtension)
     extensions.register(YourFakeExtension.name, YourFakeExtension)
diff --git a/tests/unit/parts/test_reactive.py b/tests/unit/parts/test_reactive.py
index 080715cf..f9eda5ca 100644
--- a/tests/unit/parts/test_reactive.py
+++ b/tests/unit/parts/test_reactive.py
@@ -31,7 +31,7 @@ from charmcraft.parts import reactive
 pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="Windows not [yet] supported")
 
 
-@pytest.fixture()
+@pytest.fixture
 def charm_exe(tmp_path):
     """Provide a fake charm executable."""
     charm_bin = pathlib.Path(tmp_path, "mock_bin", "charm")
@@ -44,7 +44,7 @@ def charm_exe(tmp_path):
     return charm_bin
 
 
-@pytest.fixture()
+@pytest.fixture
 def broken_charm_exe(tmp_path):
     """Provide a fake charm executable that fails to run."""
     charm_bin = pathlib.Path(tmp_path, "mock_bin", "charm")
@@ -54,7 +54,7 @@ def broken_charm_exe(tmp_path):
     return charm_bin
 
 
-@pytest.fixture()
+@pytest.fixture
 def spec(tmp_path):
     """Provide a common spec to build the different artifacts."""
     return {
@@ -67,12 +67,12 @@ def spec(tmp_path):
     }
 
 
-@pytest.fixture()
+@pytest.fixture
 def plugin_properties(spec):
     return reactive.ReactivePluginProperties.unmarshal(spec)
 
 
-@pytest.fixture()
+@pytest.fixture
 def plugin(tmp_path, plugin_properties, spec):
     project_dirs = craft_parts.ProjectDirs(work_dir=tmp_path)
     part_spec = plugins.extract_part_properties(spec, plugin_name="reactive")
@@ -153,7 +153,7 @@ def test_validate_broken_charm(plugin, plugin_properties, broken_charm_exe):
     assert raised.value.reason == "charm tools failed with error code 2"
 
 
-@pytest.fixture()
+@pytest.fixture
 def build_dir(tmp_path):
     build_dir = tmp_path / const.BUILD_DIRNAME
     build_dir.mkdir()
@@ -161,7 +161,7 @@ def build_dir(tmp_path):
     return build_dir
 
 
-@pytest.fixture()
+@pytest.fixture
 def install_dir(tmp_path):
     install_dir = tmp_path / "install"
     install_dir.mkdir()
@@ -169,7 +169,7 @@ def install_dir(tmp_path):
     return install_dir
 
 
-@pytest.fixture()
+@pytest.fixture
 def fake_run():
     patcher = patch("subprocess.run")
     yield patcher.start()
diff --git a/tests/unit/services/test_analysis.py b/tests/unit/services/test_analysis.py
index ce24461f..d3c3d9eb 100644
--- a/tests/unit/services/test_analysis.py
+++ b/tests/unit/services/test_analysis.py
@@ -83,21 +83,21 @@ LINTER_NAMES = frozenset(linter.name for linter in STUB_LINTERS)
 ALL_CHECKER_NAMES = ATTRIBUTE_CHECKER_NAMES | LINTER_NAMES
 
 
-@pytest.fixture()
+@pytest.fixture
 def mock_temp_dir(monkeypatch):
     mock_obj = mock.MagicMock(spec=tempfile.TemporaryDirectory)
     monkeypatch.setattr(tempfile, "TemporaryDirectory", mock.Mock(return_value=mock_obj))
     return mock_obj
 
 
-@pytest.fixture()
+@pytest.fixture
 def mock_zip_file(monkeypatch):
     mock_obj = mock.MagicMock(spec=zipfile.ZipFile)
     monkeypatch.setattr(zipfile, "ZipFile", mock.Mock(return_value=mock_obj))
     return mock_obj
 
 
-@pytest.fixture()
+@pytest.fixture
 def analysis_service():
     return analysis.AnalysisService(app=application.APP_METADATA, services=None)
 
diff --git a/tests/unit/services/test_image.py b/tests/unit/services/test_image.py
index 61022d1e..f6fdd014 100644
--- a/tests/unit/services/test_image.py
+++ b/tests/unit/services/test_image.py
@@ -28,18 +28,18 @@ import pytest
 from charmcraft import application, const, services, utils
 
 
-@pytest.fixture()
+@pytest.fixture
 def mock_docker() -> mock.Mock:
     return mock.Mock(spec_set=docker.DockerClient)
 
 
-@pytest.fixture()
+@pytest.fixture
 def mock_skopeo(fake_process) -> mock.Mock:
     fake_process.register(["/skopeo", "--version"])
     return mock.Mock(wraps=utils.Skopeo(skopeo_path="/skopeo"))
 
 
-@pytest.fixture()
+@pytest.fixture
 def image_service(service_factory, mock_skopeo, mock_docker) -> services.ImageService:
     service = services.ImageService(app=application.APP_METADATA, services=service_factory)
     service._skopeo = mock_skopeo
diff --git a/tests/unit/services/test_lifecycle.py b/tests/unit/services/test_lifecycle.py
new file mode 100644
index 00000000..cb0e0964
--- /dev/null
+++ b/tests/unit/services/test_lifecycle.py
@@ -0,0 +1,79 @@
+# Copyright 2023-2024 Canonical Ltd.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# For further info, check https://github.com/canonical/charmcraft
+"""Unit tests for the lifecycle service."""
+
+import pytest
+from craft_application import models, util
+from craft_cli.pytest_plugin import RecordingEmitter
+from craft_providers import bases
+
+from charmcraft.services.lifecycle import LifecycleService
+
+HOST_ARCH = util.get_host_architecture()
+
+
+@pytest.fixture
+def service(service_factory) -> LifecycleService:
+    return service_factory.lifecycle
+
+
+@pytest.mark.parametrize(
+    ("plan_build_for", "expected"),
+    [
+        ("amd64", "amd64"),
+        ("arm64", "arm64"),
+        ("riscv64", "riscv64"),
+        ("all", HOST_ARCH),
+        # Test multi-arch charms
+        (f"{HOST_ARCH}-foreign", "foreign"),
+        (f"foreign-{HOST_ARCH}", "foreign"),
+    ],
+)
+def test_get_build_for_values(service: LifecycleService, plan_build_for: str, expected: str):
+    service._build_plan = [
+        models.BuildInfo(
+            base=bases.BaseName("ubuntu", "22.04"),
+            platform="something",
+            build_on=HOST_ARCH,
+            build_for=plan_build_for,
+        )
+    ]
+
+    assert service._get_build_for() == expected
+
+
+def test_build_for_warns_on_all(service: LifecycleService, emitter: RecordingEmitter):
+    service._build_plan[0].build_for = "all"
+
+    assert service._get_build_for() == HOST_ARCH
+
+    emitter.assert_progress(
+        "WARNING: Charmcraft does not validate that charms with architecture 'all' "
+        "are fully architecture agnostic.",
+        permanent=True,
+    )
+
+
+def test_build_for_warns_on_multi(service: LifecycleService, emitter: RecordingEmitter):
+    service._build_plan[0].build_for = f"{HOST_ARCH}-foreign"
+
+    assert service._get_build_for() == "foreign"
+
+    emitter.assert_progress(
+        "WARNING: Charmcraft does not validate that charms with multiple "
+        "given architectures are architecture agnostic.",
+        permanent=True,
+    )
diff --git a/tests/unit/services/test_package.py b/tests/unit/services/test_package.py
index 85968423..affaa7bf 100644
--- a/tests/unit/services/test_package.py
+++ b/tests/unit/services/test_package.py
@@ -43,7 +43,7 @@ MANIFEST_WITH_ATTRIBUTE = models.Manifest(
 )
 
 
-@pytest.fixture()
+@pytest.fixture
 def package_service(fake_path, simple_charm, service_factory, default_build_plan):
     fake_project_dir = fake_path / "project"
     fake_project_dir.mkdir(parents=True)
diff --git a/tests/unit/services/test_provider.py b/tests/unit/services/test_provider.py
index ff358323..b55f6c48 100644
--- a/tests/unit/services/test_provider.py
+++ b/tests/unit/services/test_provider.py
@@ -23,7 +23,7 @@ from craft_providers import bases
 from charmcraft import models, services
 
 
-@pytest.fixture()
+@pytest.fixture
 def provider_service(
     fake_path: pathlib.Path,
     service_factory: services.CharmcraftServiceFactory,
diff --git a/tests/unit/services/test_store.py b/tests/unit/services/test_store.py
index 53d2daf7..8407d6ce 100644
--- a/tests/unit/services/test_store.py
+++ b/tests/unit/services/test_store.py
@@ -32,7 +32,7 @@ from charmcraft.store import client
 from tests import get_fake_revision
 
 
-@pytest.fixture()
+@pytest.fixture
 def store(service_factory) -> services.StoreService:
     store = services.StoreService(app=application.APP_METADATA, services=service_factory)
     store.client = mock.Mock(spec_set=client.Client)
diff --git a/tests/unit/store/test_client.py b/tests/unit/store/test_client.py
index ccc9df1a..e739272e 100644
--- a/tests/unit/store/test_client.py
+++ b/tests/unit/store/test_client.py
@@ -22,12 +22,12 @@ import pytest
 from charmcraft import store
 
 
-@pytest.fixture()
+@pytest.fixture
 def client() -> store.Client:
     return store.Client(api_base_url="http://charmhub.local")
 
 
-@pytest.fixture()
+@pytest.fixture
 def anonymous_client() -> store.AnonymousClient:
     return store.AnonymousClient("http://charmhub.local", "http://storage.charmhub.local")
 
diff --git a/tests/unit/test_application.py b/tests/unit/test_application.py
index ae3b8f8b..c8cd2865 100644
--- a/tests/unit/test_application.py
+++ b/tests/unit/test_application.py
@@ -15,9 +15,13 @@
 # For further info, check https://github.com/canonical/charmcraft
 """Unit tests for application class."""
 import textwrap
+from unittest import mock
 
+import craft_application
+import craft_cli.pytest_plugin
 import pyfakefs.fake_filesystem
 import pytest
+from craft_application import util
 
 from charmcraft import application, errors, services
 from charmcraft.application.main import PRIME_BEHAVIOUR_CHANGE_MESSAGE
@@ -264,3 +268,31 @@ def test_deprecated_prime_warning_not_raised_in_managed_mode(
     app = application.Charmcraft(app=application.APP_METADATA, services=service_factory)
 
     app._extra_yaml_transform(charm_yaml, build_for=None, build_on="riscv64")
+
+
+@pytest.mark.parametrize(
+    "build_for",
+    [
+        "amd64-arm64",
+        "s390x-ppc64el-riscv64",
+    ],
+)
+def test_expand_environment_multi_arch(
+    monkeypatch: pytest.MonkeyPatch,
+    emitter: craft_cli.pytest_plugin.RecordingEmitter,
+    service_factory: services.CharmcraftServiceFactory,
+    build_for,
+) -> None:
+    mock_parent_expand_environment = mock.Mock()
+    monkeypatch.setattr(
+        craft_application.Application, "_expand_environment", mock_parent_expand_environment
+    )
+    app = application.Charmcraft(app=application.APP_METADATA, services=service_factory)
+
+    app._expand_environment({}, build_for)
+
+    emitter.assert_debug(
+        "Expanding environment variables with the host architecture "
+        f"{util.get_host_architecture()!r} as the build-for architecture "
+        "because multiple run-on architectures were specified."
+    )
diff --git a/tests/unit/test_charm_builder.py b/tests/unit/test_charm_builder.py
index 9ab99664..24fe5d30 100644
--- a/tests/unit/test_charm_builder.py
+++ b/tests/unit/test_charm_builder.py
@@ -30,7 +30,7 @@ pytestmark = [
 REQUIREMENTS_FILES = [pytest.param("", id="empty"), "ops~=2.5", "requests\nops"]
 
 
-@pytest.fixture()
+@pytest.fixture
 def builder(fs: FakeFilesystem) -> charm_builder.CharmBuilder:
     fs.cwd = "/root"
     fs.makedirs(const.BUILD_DIRNAME)
diff --git a/tests/unit/utils/test_cli.py b/tests/unit/utils/test_cli.py
index 83aba4b0..971f3679 100644
--- a/tests/unit/utils/test_cli.py
+++ b/tests/unit/utils/test_cli.py
@@ -47,19 +47,19 @@ COMPOUND_DICTS_STRATEGY = strategies.dictionaries(
 )
 
 
-@pytest.fixture()
+@pytest.fixture
 def mock_isatty():
     with patch("sys.stdin.isatty", return_value=True) as mock_isatty:
         yield mock_isatty
 
 
-@pytest.fixture()
+@pytest.fixture
 def mock_input():
     with patch("charmcraft.utils.cli.input", return_value="") as mock_input:
         yield mock_input
 
 
-@pytest.fixture()
+@pytest.fixture
 def mock_is_charmcraft_running_in_managed_mode():
     with patch(
         "charmcraft.utils.cli.is_charmcraft_running_in_managed_mode", return_value=False
diff --git a/tests/unit/utils/test_skopeo.py b/tests/unit/utils/test_skopeo.py
index af2c6668..45ce80c9 100644
--- a/tests/unit/utils/test_skopeo.py
+++ b/tests/unit/utils/test_skopeo.py
@@ -87,7 +87,7 @@ def test_get_global_command(fake_process, kwargs, expected):
     assert skopeo.get_global_command() == ["/skopeo", *expected]
 
 
-@pytest.fixture()
+@pytest.fixture
 def fake_skopeo(fake_process):
     fake_process.register(["/skopeo", "--version"])
     return Skopeo(skopeo_path="/skopeo")