[stable/zed] Implement collection release process (#1085)

build: build all images + run tests using them (#1069)
ci: fix promotion jobs (#1072)
[stable/zed] Drop conventional-commit requirement (#1082)
[stable/zed] fix(ipmi-exporter): Ignore additional sensor IDs (BP/Entity Presence) (#1077)
Implement collection release process (#1083)
diff --git a/Earthfile b/Earthfile
index 3de16b3..24a1027 100644
--- a/Earthfile
+++ b/Earthfile
@@ -69,10 +69,6 @@
   RUN pip install -r requirements.txt
   SAVE IMAGE --cache-hint
 
-build.venv.dev:
-  FROM +build.venv --only main,dev
-  SAVE ARTIFACT /venv
-
 build.venv.runtime:
   FROM +build.venv --only main
   SAVE ARTIFACT /venv
@@ -105,14 +101,6 @@
   ARG REGISTRY=ghcr.io/vexxhost/atmosphere
   SAVE IMAGE --push ${REGISTRY}:${tag}
 
-pin-images:
-  FROM +build.venv.dev
-  COPY roles/defaults/vars/main.yml /defaults.yml
-  COPY build/pin-images.py /usr/local/bin/pin-images
-  ARG REGISTRY=ghcr.io/vexxhost/atmosphere
-  RUN --no-cache /usr/local/bin/pin-images --registry ${REGISTRY} /defaults.yml /pinned.yml
-  SAVE ARTIFACT /pinned.yml AS LOCAL roles/defaults/vars/main.yml
-
 gh:
   FROM alpine:3
   RUN apk add --no-cache github-cli
diff --git a/build/pin-images.py b/build/pin-images.py
index 644d0a2..d35ca65 100755
--- a/build/pin-images.py
+++ b/build/pin-images.py
@@ -126,7 +126,9 @@
     parser.add_argument(
         "src", help="Path for default values file", type=argparse.FileType("r")
     )
-    parser.add_argument("dst", help="Path for output file", type=argparse.FileType("w"))
+    parser.add_argument(
+        "dst", help="Path for output file", type=argparse.FileType("r+")
+    )
     parser.add_argument(
         "-r",
         "--registry",
diff --git a/tox.ini b/tox.ini
index 0727620..684a265 100644
--- a/tox.ini
+++ b/tox.ini
@@ -22,6 +22,14 @@
 commands =
   {posargs}
 
+[testenv:pin-digests]
+deps =
+  oslo_config
+  oslo_log
+  ruyaml
+commands =
+  python3 {toxinidir}/build/pin-images.py roles/defaults/vars/main.yml roles/defaults/vars/main.yml
+
 [testenv:molecule-keycloak]
 commands =
   molecule test -s keycloak
diff --git a/zuul.d/jobs.yaml b/zuul.d/jobs.yaml
index 0ba4adb..31fcd3e 100644
--- a/zuul.d/jobs.yaml
+++ b/zuul.d/jobs.yaml
@@ -13,6 +13,18 @@
 # under the License.
 
 - job:
+    name: atmosphere-build-collection
+    pre-run: zuul.d/playbooks/build-collection/pre.yml
+    run: zuul.d/playbooks/build-collection/run.yml
+
+- job:
+    name: atmosphere-publish-collection
+    parent: atmosphere-build-collection
+    post-run: zuul.d/playbooks/build-collection/publish.yml
+    secrets:
+      - ansible_galaxy_info
+
+- job:
     name: atmosphere-molecule
     parent: tox
     abstract: true
diff --git a/zuul.d/playbooks/build-collection/pre.yml b/zuul.d/playbooks/build-collection/pre.yml
new file mode 100644
index 0000000..5b3ea59
--- /dev/null
+++ b/zuul.d/playbooks/build-collection/pre.yml
@@ -0,0 +1,37 @@
+# Copyright (c) 2024 VEXXHOST, Inc.
+#
+# 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.
+
+- hosts: all
+  roles:
+    - ensure-python
+    - ensure-pip
+    - ensure-tox
+
+- name: Install Ansible
+  hosts: all
+  vars:
+    ansible_venv_path: '{{ ansible_user_dir }}/.local/ansible'
+    ensure_ansible_version: ''
+  tasks:
+    - name: Create local venv
+      command: '{{ ensure_pip_virtualenv_command }} {{ ansible_venv_path }}'
+
+    - name: Install Ansible to local venv
+      command: '{{ ansible_venv_path }}/bin/pip install ansible{{ ensure_ansible_version }}'
+
+    - name: Export installed ansible paths
+      set_fact:
+        ansible_executable: '{{ ansible_venv_path }}/bin/ansible'
+        ansible_galaxy_executable: '{{ ansible_venv_path }}/bin/ansible-galaxy'
+        cacheable: true
diff --git a/zuul.d/playbooks/build-collection/publish.yml b/zuul.d/playbooks/build-collection/publish.yml
new file mode 100644
index 0000000..0c2f8eb
--- /dev/null
+++ b/zuul.d/playbooks/build-collection/publish.yml
@@ -0,0 +1,60 @@
+# Copyright (c) 2024 VEXXHOST, Inc.
+#
+# 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.
+
+- hosts: all
+  tasks:
+    - name: Find tarballs in the source directory.
+      find:
+        file_type: file
+        paths: "{{ zuul.project.src_dir }}"
+        patterns: "*.tar.gz"
+      register: result
+
+    - name: Display stat for tarballs and wheels.
+      stat:
+        path: "{{ item.path }}"
+      with_items: "{{ result.files }}"
+
+    - name: Publish content to Ansible Galaxy
+      block:
+        - name: Create ansible.cfg configuration file tempfile
+          tempfile:
+            state: file
+            suffix: .cfg
+          register: _ansiblecfg_tmp
+
+        - name: Create ansible.cfg configuration file
+          copy:
+            dest: "{{ _ansiblecfg_tmp.path }}"
+            mode: 0600
+            content: |
+              [galaxy]
+              server_list = release_galaxy
+
+              [galaxy_server.release_galaxy]
+              url = {{ ansible_galaxy_info.url }}
+              token = {{ ansible_galaxy_info.token }}
+
+        - name: Publish collection to Ansible Galaxy / Automation Hub
+          environment:
+            ANSIBLE_CONFIG: "{{ _ansiblecfg_tmp.path }}"
+          ansible.builtin.shell: |
+            {{ ansible_galaxy_executable }} collection publish -vvv {{ item.path }}
+          args:
+            chdir: "{{ zuul.project.src_dir }}"
+          loop: "{{ result.files }}"
+
+      always:
+        - name: Shred ansible-galaxy credentials
+          command: "shred {{ _ansiblecfg_tmp.path }}"
diff --git a/zuul.d/playbooks/build-collection/run.yml b/zuul.d/playbooks/build-collection/run.yml
new file mode 100644
index 0000000..0560231
--- /dev/null
+++ b/zuul.d/playbooks/build-collection/run.yml
@@ -0,0 +1,32 @@
+# Copyright (c) 2024 VEXXHOST, Inc.
+#
+# 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.
+
+- name: Build collection
+  hosts: all
+  tasks:
+    - name: Pin all image digests
+      ansible.builtin.include_role:
+        name: tox
+      vars:
+        tox_envlist: pin-digests
+
+    - name: Print out the new image manifest file
+      ansible.builtin.command: |
+        cat {{ zuul.project.src_dir }}/roles/defaults/vars/main.yml
+
+    - name: Build Ansible collection
+      ansible.builtin.shell: |
+        {{ ansible_galaxy_executable }} collection build -vvv .
+      args:
+        chdir: '{{ zuul.project.src_dir }}'
diff --git a/zuul.d/project.yaml b/zuul.d/project.yaml
index 51aba1a..7089996 100644
--- a/zuul.d/project.yaml
+++ b/zuul.d/project.yaml
@@ -16,6 +16,7 @@
     merge-mode: squash-merge
     check:
       jobs:
+        - atmosphere-build-collection
         - atmosphere-molecule-aio-keycloak:
             dependencies: &molecule_check_dependencies
               - name: atmosphere-build-container-image-barbican
@@ -78,6 +79,7 @@
             dependencies: *molecule_check_dependencies
     gate:
       jobs:
+        - atmosphere-build-collection
         - atmosphere-molecule-aio-keycloak:
             dependencies: &molecule_gate_dependencies
               - name: atmosphere-upload-container-image-barbican
@@ -138,3 +140,6 @@
             dependencies: *molecule_gate_dependencies
         - atmosphere-molecule-csi-rbd:
             dependencies: *molecule_gate_dependencies
+    release:
+      jobs:
+        - atmosphere-publish-collection
diff --git a/zuul.d/secrets.yaml b/zuul.d/secrets.yaml
index 5208efb..0c59229 100644
--- a/zuul.d/secrets.yaml
+++ b/zuul.d/secrets.yaml
@@ -30,6 +30,22 @@
             o39FIIhSmehvrYJziGYUgf4JY1B6ktBtFc9l78WeoJRHNce+viSSkBj1fhbUaI=
 
 - secret:
+    name: ansible_galaxy_info
+    data:
+      url: https://galaxy.ansible.com
+      token: !encrypted/pkcs1-oaep
+        - GlYV1vSho2Q5FmS2awPcOVKuatGFm7rjrlUl9LpOdqbQa49ZxxEPAJtOcQWm77NYCDsFa
+          BhD3XBdH8QGgGqy0PqRgw48/kDw+3eVrXsBnaAUO583ElbMumcZdevYxHPRibR3FESinU
+          zDmc4VIAGJRkE5D0QYyp6jtJhhcaKUnBKNz3qvyTW4Lh03PHIuUR2UcaopJbfJiU+xMcE
+          gHZj9UZ7HwIE//q10euv/mxDwyICkdcU9UuWrNm16WdzIVtpwygJTaQNRo7pFN3POgmps
+          aNILKXp7Hfp0J6Hx1Hc7GmpJ9EmyYaNyktvOSf4jqpZCJvQ5CRWKHJC+jryHYBxOoT524
+          hU3Hoc32DOnytb1EZwzwu4iJbRMe1xEHWqBf9wpf3sV6B5Pvc7/IHTnU91/dlawOh5eOp
+          8wq45eO5w0c+PcITu9OUhWULKhEJcPunGNr0e96wdfK7L4khiPopHUTSbFF4dOhfF1GfV
+          GgFTakyVg9jKYKre0aLGW2Gah3gzXuX2IQ9XGXebsnFLdtQL5ac7ET0hKDR8tZBGrwKj6
+          c8bL2vzVKjOOD+sSnv4h7l+p8igtjczyVV83pn6dJ/v1GCMCFzGdOhaKJ2DIO1KtKK4jV
+          c80+tpz0x/Cr/4Ld4rJU4mqqC8Y3Kk6AC2cNzsiYh1iPlpw+D/yoE0Lgugjjzc=
+
+- secret:
     name: cosign_key
     data:
       public: |