Repository: canonical/snapcraft

Issue:
Metadata links are ignored in core22 snaps
### Bug Description

Building [this sample `core20` snapcraft.yaml](https://github.com/snapcore/snapcraft/blob/main/tests/spread/general/metadata-links/snapcraft.yaml) from general spread tests, fields such as `issues`, `donation`, `contact`, `source-code`, and `website` are translated to the result's `snap.yaml` under the `links` field. But if I just switch to `base: core22` these links are nowhere to be found in the built snap.

### To Reproduce

- Build this snap: https://github.com/snapcore/snapcraft/blob/main/tests/spread/general/metadata-links/snapcraft.yaml
- Check `meta/snap.yaml` in the resulting snap and look for `links`.
- Edit `snapcraft.yaml` and set `base: core22`.
- Rebuild.
- Look for any links in `meta/snap.yaml`.

### Environment

```sh
$ snap version
snap    2.59.5
snapd   2.59.5
series  16
ubuntu  23.04
kernel  6.2.0-23-generic
```

### snapcraft.yaml

```shell
name: metadata-links
summary: test metadata links
description: |
  Verify that top level metadata links in snapcraft.yaml are created at a top level
  links entry in snap.yaml.
version: "1.0"
grade: stable
confinement: strict
base: core22
issues:
  - https://github.com/acme/project/issues
  - https://bugs.launchpad.net/project/filebug
donation:
  - https://paypal.com
  - https://cafecito.app/
  - https://ko-fi.com/
contact:
  - mailto:project@acme.com
  - team@acme.com
source-code: https://github.com/acme/project.git
website: https://project.acme.org

parts:
  nil:
    plugin: nil
```


### Relevant log output

```shell
$ sudo mount -t squashfs -o ro metadata-links_1.0_amd64-core20.snap tmp
$ grep links: tmp/meta/snap.yaml
links:
$ sudo umount tmp

$ sudo mount -t squashfs -o ro metadata-links_1.0_amd64-core22.snap tmp
$ grep links: tmp/meta/snap.yaml
$
```


### Additional context

_No response_


Discussion:

Resolving patch:
diff --git a/snapcraft/meta/snap_yaml.py b/snapcraft/meta/snap_yaml.py
--- a/snapcraft/meta/snap_yaml.py
+++ b/snapcraft/meta/snap_yaml.py
@@ -26,7 +26,7 @@
 from pydantic_yaml import YamlModel
 
 from snapcraft import errors
-from snapcraft.projects import App, Project
+from snapcraft.projects import App, Project, UniqueStrList
 from snapcraft.utils import get_ld_library_paths, process_version
 
 
@@ -176,6 +176,41 @@ def get_content_dirs(self, installed_path: Path) -> Set[Path]:
         return content_dirs
 
 
+class Links(_SnapMetadataModel):
+    """Metadata links used in snaps."""
+
+    contact: Optional[UniqueStrList]
+    donation: Optional[UniqueStrList]
+    issues: Optional[UniqueStrList]
+    source_code: Optional[UniqueStrList]
+    website: Optional[UniqueStrList]
+
+    @staticmethod
+    def _normalize_value(
+        value: Optional[Union[str, UniqueStrList]]
+    ) -> Optional[List[str]]:
+        if isinstance(value, str):
+            value = [value]
+        return value
+
+    @classmethod
+    def from_project(cls, project: Project) -> "Links":
+        """Create Links from a Project."""
+        return cls(
+            contact=cls._normalize_value(project.contact),
+            donation=cls._normalize_value(project.donation),
+            issues=cls._normalize_value(project.issues),
+            source_code=cls._normalize_value(project.source_code),
+            website=cls._normalize_value(project.website),
+        )
+
+    def __bool__(self) -> bool:
+        """Return True if any of the Links attributes are set."""
+        return any(
+            [self.contact, self.donation, self.issues, self.source_code, self.website]
+        )
+
+
 class SnapMetadata(_SnapMetadataModel):
     """The snap.yaml model.
 
@@ -210,6 +245,7 @@ class Config:
     layout: Optional[Dict[str, Dict[str, str]]]
     system_usernames: Optional[Dict[str, Any]]
     provenance: Optional[str]
+    links: Optional[Links]
 
     @classmethod
     def unmarshal(cls, data: Dict[str, Any]) -> "SnapMetadata":
@@ -403,6 +439,8 @@ def write(project: Project, prime_dir: Path, *, arch: str):
     # project provided assumes and computed assumes
     total_assumes = sorted(project.assumes + list(assumes))
 
+    links = Links.from_project(project)
+
     snap_metadata = SnapMetadata(
         name=project.name,
         title=project.title,
@@ -425,6 +463,7 @@ def write(project: Project, prime_dir: Path, *, arch: str):
         layout=project.layout,
         system_usernames=project.system_usernames,
         provenance=project.provenance,
+        links=links if links else None,
     )
     if project.passthrough:
         for name, value in project.passthrough.items():


Test patch:
diff --git a/tests/spread/general/metadata-links/task.yaml b/tests/spread/general/metadata-links/task.yaml
--- a/tests/spread/general/metadata-links/task.yaml
+++ b/tests/spread/general/metadata-links/task.yaml
@@ -6,10 +6,18 @@ environment:
 prepare: |
   snap install yq
 
+  #shellcheck source=tests/spread/tools/snapcraft-yaml.sh
+  . "$TOOLS_DIR/snapcraft-yaml.sh"
+  set_base snapcraft.yaml
+
 restore: |
   snapcraft clean
   rm -rf ./*.snap
 
+  #shellcheck source=tests/spread/tools/snapcraft-yaml.sh
+  . "$TOOLS_DIR/snapcraft-yaml.sh"
+  restore_yaml snapcraft.yaml
+
 execute: |
   # Create a snap to trigger `snap pack`.
   snapcraft
diff --git a/tests/unit/meta/test_snap_yaml.py b/tests/unit/meta/test_snap_yaml.py
--- a/tests/unit/meta/test_snap_yaml.py
+++ b/tests/unit/meta/test_snap_yaml.py
@@ -124,6 +124,111 @@ def test_assumes(simple_project, new_dir):
     )
 
 
+def test_links_scalars(simple_project, new_dir):
+    snap_yaml.write(
+        simple_project(
+            contact="me@acme.com",
+            issues="https://hubhub.com/issues",
+            donation="https://moneyfornothing.com",
+            source_code="https://closed.acme.com",
+            website="https://acme.com",
+        ),
+        prime_dir=Path(new_dir),
+        arch="amd64",
+    )
+    yaml_file = Path("meta/snap.yaml")
+    assert yaml_file.is_file()
+
+    content = yaml_file.read_text()
+    assert content == textwrap.dedent(
+        """\
+        name: mytest
+        version: 1.29.3
+        summary: Single-line elevator pitch for your amazing snap
+        description: test-description
+        architectures:
+        - amd64
+        base: core22
+        apps:
+          app1:
+            command: bin/mytest
+        confinement: strict
+        grade: stable
+        environment:
+          LD_LIBRARY_PATH: ${SNAP_LIBRARY_PATH}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}
+          PATH: $SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH
+        links:
+          contact:
+          - me@acme.com
+          donation:
+          - https://moneyfornothing.com
+          issues:
+          - https://hubhub.com/issues
+          source-code:
+          - https://closed.acme.com
+          website:
+          - https://acme.com
+        """
+    )
+
+
+def test_links_lists(simple_project, new_dir):
+    snap_yaml.write(
+        simple_project(
+            contact=[
+                "me@acme.com",
+                "you@acme.com",
+            ],
+            issues=[
+                "https://hubhub.com/issues",
+                "https://corner.com/issues",
+            ],
+            donation=["https://moneyfornothing.com", "https://prince.com"],
+            source_code="https://closed.acme.com",
+            website="https://acme.com",
+        ),
+        prime_dir=Path(new_dir),
+        arch="amd64",
+    )
+    yaml_file = Path("meta/snap.yaml")
+    assert yaml_file.is_file()
+
+    content = yaml_file.read_text()
+    assert content == textwrap.dedent(
+        """\
+        name: mytest
+        version: 1.29.3
+        summary: Single-line elevator pitch for your amazing snap
+        description: test-description
+        architectures:
+        - amd64
+        base: core22
+        apps:
+          app1:
+            command: bin/mytest
+        confinement: strict
+        grade: stable
+        environment:
+          LD_LIBRARY_PATH: ${SNAP_LIBRARY_PATH}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}
+          PATH: $SNAP/usr/sbin:$SNAP/usr/bin:$SNAP/sbin:$SNAP/bin:$PATH
+        links:
+          contact:
+          - me@acme.com
+          - you@acme.com
+          donation:
+          - https://moneyfornothing.com
+          - https://prince.com
+          issues:
+          - https://hubhub.com/issues
+          - https://corner.com/issues
+          source-code:
+          - https://closed.acme.com
+          website:
+          - https://acme.com
+        """
+    )
+
+
 @pytest.fixture
 def complex_project():
     snapcraft_yaml = textwrap.dedent(
@@ -1025,3 +1130,60 @@ def test_architectures_all(simple_project, new_dir):
         "${SNAP_LIBRARY_PATH}${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}:"
         "$SNAP/lib:$SNAP/usr/lib\n"
     ) in content
+
+
+##############
+# Test Links #
+##############
+
+
+def test_links_for_scalars(simple_project):
+    project = simple_project(
+        contact="me@acme.com",
+        issues="https://hubhub.com/issues",
+        donation="https://moneyfornothing.com",
+        source_code="https://closed.acme.com",
+        website="https://acme.com",
+    )
+
+    links = snap_yaml.Links.from_project(project)
+
+    assert links.contact == [project.contact]
+    assert links.issues == [project.issues]
+    assert links.donation == [project.donation]
+    assert links.source_code == [project.source_code]
+    assert links.website == [project.website]
+
+    assert bool(links) is True
+
+
+def test_links_for_lists(simple_project):
+    project = simple_project(
+        contact=[
+            "me@acme.com",
+            "you@acme.com",
+        ],
+        issues=[
+            "https://hubhub.com/issues",
+            "https://corner.com/issues",
+        ],
+        donation=["https://moneyfornothing.com", "https://prince.com"],
+    )
+
+    links = snap_yaml.Links.from_project(project)
+
+    assert links.contact == project.contact
+    assert links.issues == project.issues
+    assert links.donation == project.donation
+    assert links.source_code is None
+    assert links.website is None
+
+    assert bool(links) is True
+
+
+def test_no_links(simple_project):
+    project = simple_project()
+
+    links = snap_yaml.Links.from_project(project)
+
+    assert bool(links) is False