Script 'mail_helper' called by obssrc
Hello community,
here is the log from the commit of package ansible-core-2.18 for
openSUSE:Factory checked in at 2026-03-26 21:09:27
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/ansible-core-2.18 (Old)
and /work/SRC/openSUSE:Factory/.ansible-core-2.18.new.8177 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "ansible-core-2.18"
Thu Mar 26 21:09:27 2026 rev:6 rq:1342687 version:2.18.15
Changes:
--------
--- /work/SRC/openSUSE:Factory/ansible-core-2.18/ansible-core-2.18.changes
2026-03-11 20:58:47.730523068 +0100
+++
/work/SRC/openSUSE:Factory/.ansible-core-2.18.new.8177/ansible-core-2.18.changes
2026-03-27 06:36:02.852694177 +0100
@@ -1,0 +2,13 @@
+Wed Mar 25 13:11:00 UTC 2026 - Johannes Kastl
<[email protected]>
+
+- update to 2.18.15:
+ * Minor Changes
+ - ansible-test - Add container/remote aliases for more loosely
+ specifying managed test environments.
+ - ansible-test - Add support for using the Ansible Core CI
+ service from GitHub Actions.
+ * Bugfixes
+ - rpm_key - Use librpm library API instead of gpg utility to
+ support version 6 PGP keys (#86157).
+
+-------------------------------------------------------------------
Old:
----
ansible_core-2.18.14.tar.gz
ansible_core-2.18.14.tar.gz.sha256
New:
----
ansible_core-2.18.15.tar.gz
ansible_core-2.18.15.tar.gz.sha256
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Other differences:
------------------
++++++ ansible-core-2.18.spec ++++++
--- /var/tmp/diff_new_pack.N8e4go/_old 2026-03-27 06:36:03.604725172 +0100
+++ /var/tmp/diff_new_pack.N8e4go/_new 2026-03-27 06:36:03.604725172 +0100
@@ -43,7 +43,7 @@
%endif
Name: ansible-core-2.18
-Version: 2.18.14
+Version: 2.18.15
Release: 0
Summary: Radically simple IT automation
License: GPL-3.0-or-later
++++++ ansible_core-2.18.14.tar.gz -> ansible_core-2.18.15.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/ansible_core-2.18.14/PKG-INFO
new/ansible_core-2.18.15/PKG-INFO
--- old/ansible_core-2.18.14/PKG-INFO 2026-02-23 23:49:04.000000000 +0100
+++ new/ansible_core-2.18.15/PKG-INFO 2026-03-23 18:38:33.000000000 +0100
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: ansible-core
-Version: 2.18.14
+Version: 2.18.15
Summary: Radically simple IT automation
Author: Ansible Project
Project-URL: Homepage, https://ansible.com/
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/ansible_core-2.18.14/ansible_core.egg-info/PKG-INFO
new/ansible_core-2.18.15/ansible_core.egg-info/PKG-INFO
--- old/ansible_core-2.18.14/ansible_core.egg-info/PKG-INFO 2026-02-23
23:49:04.000000000 +0100
+++ new/ansible_core-2.18.15/ansible_core.egg-info/PKG-INFO 2026-03-23
18:38:33.000000000 +0100
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: ansible-core
-Version: 2.18.14
+Version: 2.18.15
Summary: Radically simple IT automation
Author: Ansible Project
Project-URL: Homepage, https://ansible.com/
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/ansible_core-2.18.14/ansible_core.egg-info/SOURCES.txt
new/ansible_core-2.18.15/ansible_core.egg-info/SOURCES.txt
--- old/ansible_core-2.18.14/ansible_core.egg-info/SOURCES.txt 2026-02-23
23:49:04.000000000 +0100
+++ new/ansible_core-2.18.15/ansible_core.egg-info/SOURCES.txt 2026-03-23
18:38:33.000000000 +0100
@@ -3371,6 +3371,7 @@
test/integration/targets/roles_var_inheritance/roles/nested_dep/meta/main.yml
test/integration/targets/roles_var_inheritance/roles/nested_dep/tasks/main.yml
test/integration/targets/rpm_key/aliases
+test/integration/targets/rpm_key/files/TEST-V6-NON-PQC-KEY.cert
test/integration/targets/rpm_key/meta/main.yml
test/integration/targets/rpm_key/tasks/main.yaml
test/integration/targets/rpm_key/tasks/rpm_key.yaml
@@ -4142,6 +4143,7 @@
test/lib/ansible_test/_internal/venv.py
test/lib/ansible_test/_internal/ci/__init__.py
test/lib/ansible_test/_internal/ci/azp.py
+test/lib/ansible_test/_internal/ci/gha.py
test/lib/ansible_test/_internal/ci/local.py
test/lib/ansible_test/_internal/classification/__init__.py
test/lib/ansible_test/_internal/classification/common.py
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/ansible_core-2.18.14/changelogs/CHANGELOG-v2.18.rst
new/ansible_core-2.18.15/changelogs/CHANGELOG-v2.18.rst
--- old/ansible_core-2.18.14/changelogs/CHANGELOG-v2.18.rst 2026-02-23
23:49:04.000000000 +0100
+++ new/ansible_core-2.18.15/changelogs/CHANGELOG-v2.18.rst 2026-03-23
18:38:33.000000000 +0100
@@ -4,6 +4,26 @@
.. contents:: Topics
+v2.18.15
+========
+
+Release Summary
+---------------
+
+| Release Date: 2026-03-23
+| `Porting Guide
<https://docs.ansible.com/ansible-core/2.18/porting_guides/porting_guide_core_2.18.html>`__
+
+Minor Changes
+-------------
+
+- ansible-test - Add container/remote aliases for more loosely specifying
managed test environments.
+- ansible-test - Add support for using the Ansible Core CI service from GitHub
Actions.
+
+Bugfixes
+--------
+
+- rpm_key - Use librpm library API instead of gpg utility to support version 6
PGP keys (https://github.com/ansible/ansible/issues/86157).
+
v2.18.14
========
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/ansible_core-2.18.14/changelogs/changelog.yaml
new/ansible_core-2.18.15/changelogs/changelog.yaml
--- old/ansible_core-2.18.14/changelogs/changelog.yaml 2026-02-23
23:49:04.000000000 +0100
+++ new/ansible_core-2.18.15/changelogs/changelog.yaml 2026-03-23
18:38:33.000000000 +0100
@@ -773,6 +773,39 @@
- ansible-test-api-endpoint.yml
- ansible-test-spare-tire.yml
release_date: '2026-02-17'
+ 2.18.15:
+ changes:
+ release_summary: '| Release Date: 2026-03-23
+
+ | `Porting Guide
<https://docs.ansible.com/ansible-core/2.18/porting_guides/porting_guide_core_2.18.html>`__
+
+ '
+ codename: Fool in the Rain
+ fragments:
+ - 2.18.15_summary.yaml
+ release_date: '2026-03-23'
+ 2.18.15rc1:
+ changes:
+ bugfixes:
+ - rpm_key - Use librpm library API instead of gpg utility to support
version
+ 6 PGP keys (https://github.com/ansible/ansible/issues/86157).
+ minor_changes:
+ - ansible-test - Add container/remote aliases for more loosely
specifying managed
+ test environments.
+ - ansible-test - Add support for using the Ansible Core CI service from
GitHub
+ Actions.
+ release_summary: '| Release Date: 2026-03-16
+
+ | `Porting Guide
<https://docs.ansible.com/ansible-core/2.18/porting_guides/porting_guide_core_2.18.html>`__
+
+ '
+ codename: Fool in the Rain
+ fragments:
+ - 2.18.15rc1_summary.yaml
+ - 86237-rpm-key-librpm.yml
+ - ansible-test-completion-aliases.yml
+ - ansible-test-github-actions.yml
+ release_date: '2026-03-16'
2.18.1rc1:
changes:
bugfixes:
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/ansible_core-2.18.14/lib/ansible/module_utils/ansible_release.py
new/ansible_core-2.18.15/lib/ansible/module_utils/ansible_release.py
--- old/ansible_core-2.18.14/lib/ansible/module_utils/ansible_release.py
2026-02-23 23:49:04.000000000 +0100
+++ new/ansible_core-2.18.15/lib/ansible/module_utils/ansible_release.py
2026-03-23 18:38:33.000000000 +0100
@@ -17,6 +17,6 @@
from __future__ import annotations
-__version__ = '2.18.14'
+__version__ = '2.18.15'
__author__ = 'Ansible, Inc.'
__codename__ = "Fool in the Rain"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/ansible_core-2.18.14/lib/ansible/modules/rpm_key.py
new/ansible_core-2.18.15/lib/ansible/modules/rpm_key.py
--- old/ansible_core-2.18.14/lib/ansible/modules/rpm_key.py 2026-02-23
23:49:04.000000000 +0100
+++ new/ansible_core-2.18.15/lib/ansible/modules/rpm_key.py 2026-03-23
18:38:33.000000000 +0100
@@ -22,6 +22,7 @@
description:
- Key that will be modified. Can be a url, a file on the managed node,
or a keyid if the key
already exists in the database.
+ - This can also be the fingerprint when attempting to delete an
already installed key.
type: str
required: true
state:
@@ -85,17 +86,325 @@
RETURN = r'''#'''
+import ctypes
+import ctypes.util
+import hashlib
import re
import os.path
import tempfile
+import typing as _t
# import module snippets
from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.urls import fetch_url
+from ansible.module_utils.compat.version import LooseVersion
from ansible.module_utils.common.text.converters import to_native
+# Type alias for ctypes pointer to uint8 array (packet data)
+# Using Any here because ctypes._Pointer is private, but documenting the
actual type
+PktPointer = _t.Any # Actually: ctypes.POINTER(ctypes.c_uint8)
+
+
+class LibRPM:
+ """
+ Wrapper for librpm PGP key functions.
+
+ The APIs in librpm vary across different versions. Since this module must
work on a variety of
+ systems, we are extremely limited in the API calls that we can guarantee
will be available.
+ """
+
+ # Constants
+ PGPTAG_PUBLIC_KEY = 6
+ PGPTAG_PUBLIC_SUBKEY = 14
+
+ def __init__(self):
+ # Load the librpm library
+ if not (lib_path := ctypes.util.find_library('rpm')):
+ raise ImportError("Error: Could not find librpm library")
+
+ self._lib = ctypes.CDLL(lib_path)
+ self._libc = ctypes.CDLL(None)
+
+ # void free(void *ptr)
+ self._libc.free.argtypes = [ctypes.c_void_p]
+ self._libc.free.restype = None
+
+ # pgpArmor pgpParsePkts(const char *armor, uint8_t **pkt, size_t
*pktlen)
+ self._lib.pgpParsePkts.argtypes = [
+ ctypes.c_char_p,
+ ctypes.POINTER(ctypes.POINTER(ctypes.c_uint8)),
+ ctypes.POINTER(ctypes.c_size_t)
+ ]
+ self._lib.pgpParsePkts.restype = ctypes.c_int
+
+ # Identify the version of the RPM library
+ _lib_rpmversion = ctypes.c_char_p.in_dll(self._lib, "RPMVERSION")
+ self._rpmversion = _lib_rpmversion.value.decode()
+
+ @property
+ def using_librpm6(self) -> bool:
+ """
+ Check if the librpm version in use is at least version 6.0.0.
+
+ RPM version 6.0.0 and higher uses fingerprints instead of short key ID
everywhere. This changes
+ how we must approach certain operations, such as key deletion from the
rpmdb.
+ """
+ if LooseVersion(self._rpmversion) >= LooseVersion('6.0.0'):
+ return True
+ return False
+
+ def _parse_armor(self, armor: str) -> tuple[PktPointer | None, int]:
+ """
+ Parse ASCII armored PGP data using pgpParsePkts().
+ Returns (pkt, pktlen) tuple or (None, 0) on error.
+ """
+ pkt = ctypes.POINTER(ctypes.c_uint8)()
+ pktlen = ctypes.c_size_t()
+
+ armor_bytes = armor.encode()
+ result = self._lib.pgpParsePkts(armor_bytes, ctypes.byref(pkt),
ctypes.byref(pktlen))
+
+ if result < 0 or not pkt:
+ return None, 0
+
+ return pkt, pktlen.value
+
+ def _parse_packet_header(self, pkt: PktPointer, offset: int, pktlen: int)
-> tuple[int | None, int, int]:
+ """
+ Parse a PGP packet header to get tag and packet length.
+ Returns (tag, body_length, header_length) or (None, 0, 0) on error.
+
+ Per RFC 9580 - Section 4.2: Packet Headers
+ https://www.rfc-editor.org/rfc/rfc9580.html#name-packet-headers
+ """
+ if offset >= pktlen:
+ return None, 0, 0
+
+ tag_byte = pkt[offset]
+
+ # Check if it's a new format packet (bit 6 set)
+ if tag_byte & 0x40:
+ # New format
+ tag = tag_byte & 0x3f # bits 0-5 are packet type ID
+ offset += 1
+
+ if offset >= pktlen:
+ return None, 0, 0
+
+ first_len_byte = pkt[offset]
+
+ if first_len_byte < 192:
+ # One-octet length
+ return tag, first_len_byte, 2
+ elif first_len_byte < 224:
+ # Two-octet length
+ if offset + 1 >= pktlen:
+ return None, 0, 0
+ length = ((first_len_byte - 192) << 8) + pkt[offset + 1] + 192
+ return tag, length, 3
+ elif first_len_byte == 255:
+ # Five-octet length
+ if offset + 4 >= pktlen:
+ return None, 0, 0
+ length = (pkt[offset + 1] << 24) | (pkt[offset + 2] << 16) | \
+ (pkt[offset + 3] << 8) | pkt[offset + 4]
+ return tag, length, 6
+ else:
+ # Partial body length (not supported here)
+ return None, 0, 0
+ else:
+ # Old format
+ tag = (tag_byte >> 2) & 0x0f
+ length_type = tag_byte & 0x03
+
+ if length_type == 0:
+ # One-octet length
+ if offset + 1 >= pktlen:
+ return None, 0, 0
+ return tag, pkt[offset + 1], 2
+ elif length_type == 1:
+ # Two-octet length
+ if offset + 2 >= pktlen:
+ return None, 0, 0
+ length = (pkt[offset + 1] << 8) | pkt[offset + 2]
+ return tag, length, 3
+ elif length_type == 2:
+ # Four-octet length
+ if offset + 4 >= pktlen:
+ return None, 0, 0
+ length = (pkt[offset + 1] << 24) | (pkt[offset + 2] << 16) | \
+ (pkt[offset + 3] << 8) | pkt[offset + 4]
+ return tag, length, 5
+ else:
+ # Indeterminate length (not supported)
+ return None, 0, 0
+
+ def _find_key_packets(self, pkt: PktPointer, pktlen: int) ->
list[tuple[int, int]]:
+ """
+ Walk the packet stream and find all PGPTAG_PUBLIC_KEY and
PGPTAG_PUBLIC_SUBKEY packets.
+ Returns list of (offset, total_packet_length) tuples.
+ """
+ key_packets: list[tuple[int, int]] = []
+ offset = 0
+
+ while offset < pktlen:
+ tag, body_len, header_len = self._parse_packet_header(pkt, offset,
pktlen)
+
+ if tag is None:
+ break
+
+ if tag in (self.PGPTAG_PUBLIC_KEY, self.PGPTAG_PUBLIC_SUBKEY):
+ # Found a key packet
+ total_len = header_len + body_len
+ key_packets.append((offset, total_len))
+
+ # Move to next packet
+ offset += header_len + body_len
+
+ return key_packets
+
+ def _get_key_version(self, pkt: PktPointer, offset: int, pktlen: int) ->
int | None:
+ """
+ Get the version byte from a key packet.
+ Returns version number (4 or 6) or None on error.
+ """
+ tag, dummy, header_len = self._parse_packet_header(pkt, offset, pktlen)
+ if tag is None:
+ return None
+
+ # Extract packet body (skip the packet header)
+ body_offset = offset + header_len
+ if body_offset >= pktlen:
+ return None
+
+ # First byte of body is the version
+ return pkt[body_offset]
+
+ def _compute_v4_fingerprint(self, pkt: PktPointer, offset: int, pktlen:
int) -> str | None:
+ """
+ Compute V4 fingerprint from packet data.
+ For V4 keys, fingerprint = SHA-1(0x99 || 2-byte-length || packet_body)
+ Per RFC 4880 Section 12.2
+ """
+ tag, body_len, header_len = self._parse_packet_header(pkt, offset,
pktlen)
+
+ if tag is None:
+ return None
+
+ # Extract packet body (skip the packet header)
+ body_offset = offset + header_len
+ if body_offset + body_len > pktlen:
+ return None
+
+ # Check if it's a V4 key (first byte of body should be 0x04)
+ if pkt[body_offset] != 0x04:
+ return None
+
+ # Build the data for fingerprint: 0x99 || 2-byte length || body
+ fp_data = bytearray()
+ fp_data.append(0x99) # V4 public key packet tag
+ fp_data.append((body_len >> 8) & 0xFF) # Length high byte
+ fp_data.append(body_len & 0xFF) # Length low byte
+
+ # Append the packet body
+ for i in range(body_len):
+ fp_data.append(pkt[body_offset + i])
+
+ # Compute SHA-1 hash
+ return hashlib.sha1(fp_data).hexdigest().upper()
+
+ def _compute_v6_fingerprint(self, pkt: PktPointer, offset: int, pktlen:
int) -> str | None:
+ """
+ Compute V6 fingerprint from packet data.
+ For V6 keys, fingerprint = SHA-256(0x9B || 4-byte-length ||
packet_body)
+ Per RFC 9580 Section 5.5.4
+ """
+ tag, body_len, header_len = self._parse_packet_header(pkt, offset,
pktlen)
+
+ if tag is None:
+ return None
+
+ # Extract packet body (skip the packet header)
+ body_offset = offset + header_len
+ if body_offset + body_len > pktlen:
+ return None
+
+ # Check if it's a V6 key (first byte of body should be 0x06)
+ if pkt[body_offset] != 0x06:
+ return None
+
+ # Build the data for fingerprint: 0x9B || 4-byte length || body
+ fp_data = bytearray()
+ fp_data.append(0x9B) # V6 public key packet tag
+ fp_data.append((body_len >> 24) & 0xFF) # Length byte 1 (MSB)
+ fp_data.append((body_len >> 16) & 0xFF) # Length byte 2
+ fp_data.append((body_len >> 8) & 0xFF) # Length byte 3
+ fp_data.append(body_len & 0xFF) # Length byte 4 (LSB)
+
+ # Append the packet body
+ for i in range(body_len):
+ fp_data.append(pkt[body_offset + i])
+
+ # Compute SHA-256 hash
+ return hashlib.sha256(fp_data).hexdigest().upper()
+
+ def identify_keys(self, armor: str) -> list[dict[str, str]]:
+ """Return a list of dicts with key ID (8-byte) and fingerprint for the
primary key and each subkey"""
+ key_info: list[dict[str, str]] = []
+
+ pkt, pktlen = self._parse_armor(armor)
+ if not pkt:
+ raise Exception("Unable to parse PGP key armor")
+
+ # Find all key packets in the stream and compute their fingerprints.
+ key_packets = self._find_key_packets(pkt, pktlen)
+
+ for offset, dummy in key_packets:
+ # Detect key version
+ version = self._get_key_version(pkt, offset, pktlen)
+
+ if version == 0x04:
+ # V4 key
+ computed_fp = self._compute_v4_fingerprint(pkt, offset, pktlen)
+ if computed_fp:
+ # V4: Key ID is the last 8 bytes (16 hex chars) of the
fingerprint
+ keyid_from_fp = computed_fp[-16:]
+ key_info.append({'keyid': keyid_from_fp, 'fingerprint':
computed_fp})
+ elif version == 0x06:
+ # V6 key
+ computed_fp = self._compute_v6_fingerprint(pkt, offset, pktlen)
+ if computed_fp:
+ # V6: Key ID is the first 8 bytes (16 hex chars) of the
fingerprint
+ keyid_from_fp = computed_fp[:16]
+ key_info.append({'keyid': keyid_from_fp, 'fingerprint':
computed_fp})
+ else:
+ raise Exception(f"Unhandled key version {version:#04x}")
+
+ self._libc.free(pkt)
+
+ return key_info
-def is_pubkey(string):
+ def get_key_ids_from_armor(self, armor: str) -> list[str]:
+ """
+ Get the key IDs from the primary PGP key, and all subkeys of that key,
from the ASCII armored key.
+
+ 'armor' is expected to be a single ASCII armored PGP key (v4 or v6).
The primary key should be the
+ first item in the results, followed by its subkeys. Returned key IDs
are 8-byte (16 hex characters)
+ in length. This must be accounted for if comparing against the short
key ID (4-bytes).
+ """
+ return [key['keyid'] for key in self.identify_keys(armor)]
+
+ def get_fingerprints_from_armor(self, armor: str) -> list[str]:
+ """
+ Get the fingerprints from the primary PGP key, and all subkeys of that
key, from the ASCII armored key.
+
+ 'armor' is expected to be a single ASCII armored PGP key (v4 or v6).
The primary key should be the
+ first item in the results, followed by its subkeys.
+ """
+ return [key['fingerprint'] for key in self.identify_keys(armor)]
+
+
+def is_pubkey(string: str) -> bool:
"""Verifies if string is a pubkey"""
pgp_regex = ".*?(-----BEGIN PGP PUBLIC KEY BLOCK-----.*?-----END PGP
PUBLIC KEY BLOCK-----).*"
return bool(re.match(pgp_regex, to_native(string,
errors='surrogate_or_strict'), re.DOTALL))
@@ -103,13 +412,14 @@
class RpmKey(object):
- def __init__(self, module):
+ def __init__(self, module: AnsibleModule) -> None:
# If the key is a url, we need to check if it's present to be
idempotent,
# to do that, we need to check the keyid, which we can get from the
armor.
keyfile = None
should_cleanup_keyfile = False
self.module = module
self.rpm = self.module.get_bin_path('rpm', True)
+ self.rpmkeys = self.module.get_bin_path('rpmkeys', True)
state = module.params['state']
key = module.params['key']
fingerprint = module.params['fingerprint']
@@ -120,9 +430,7 @@
fingerprint = [fingerprint]
fingerprints = set(f.replace(' ', '').upper() for f in fingerprint)
- self.gpg = self.module.get_bin_path('gpg')
- if not self.gpg:
- self.gpg = self.module.get_bin_path('gpg2', required=True)
+ self.librpm = LibRPM()
if '://' in key:
keyfile = self.fetch_key(key)
@@ -137,6 +445,8 @@
self.module.fail_json(msg="Not a valid key %s" % key)
keyid = self.normalize_keyid(keyid)
+ self.installed_keys = self.get_installed_keys()
+
if state == 'present':
if self.is_key_imported(keyid):
module.exit_json(changed=False)
@@ -161,7 +471,7 @@
else:
module.exit_json(changed=False)
- def fetch_key(self, url):
+ def fetch_key(self, url: str) -> str:
"""Downloads a key from url, returns a valid path to a gpg key"""
rsp, info = fetch_url(self.module, url)
if info['status'] != 200:
@@ -177,7 +487,7 @@
tmpfile.close()
return tmpname
- def normalize_keyid(self, keyid):
+ def normalize_keyid(self, keyid: str) -> str:
"""Ensure a keyid doesn't have a leading 0x, has leading or trailing
whitespace, and make sure is uppercase"""
ret = keyid.strip().upper()
if ret.startswith('0x'):
@@ -188,70 +498,133 @@
return ret
def getkeyid(self, keyfile):
- stdout, stderr = self.execute_command([self.gpg, '--no-tty',
'--batch', '--with-colons', '--fixed-list-mode', keyfile])
- for line in stdout.splitlines():
- line = line.strip()
- if line.startswith('pub:'):
- return line.split(':')[4]
-
- self.module.fail_json(msg="Unexpected gpg output")
+ with open(keyfile, "r") as key_fd:
+ key_ids = self.librpm.get_key_ids_from_armor(key_fd.read())
+ if not key_ids:
+ self.module.fail_json(msg="Failed to get keyid")
+ return key_ids[0]
def getfingerprints(self, keyfile):
- stdout, stderr = self.execute_command([
- self.gpg, '--no-tty', '--batch', '--with-colons',
- '--fixed-list-mode', '--import', '--import-options', 'show-only',
- '--dry-run', keyfile
- ])
-
- fingerprints = set()
-
- for line in stdout.splitlines():
- line = line.strip()
- if line.startswith('fpr:'):
- # As mentioned here,
- #
- #
https://git.gnupg.org/cgi-bin/gitweb.cgi?p=gnupg.git;a=blob_plain;f=doc/DETAILS
- #
- # The description of the `fpr` field says
- #
- # "fpr :: Fingerprint (fingerprint is in field 10)"
- #
- fingerprints.add(line.split(':')[9])
-
- if fingerprints:
- return fingerprints
-
- self.module.fail_json(msg="Unexpected gpg output")
-
- def is_keyid(self, keystr):
- """Verifies if a key, as provided by the user is a keyid"""
+ with open(keyfile, "r") as key_fd:
+ fingerprints =
self.librpm.get_fingerprints_from_armor(key_fd.read())
+ if not fingerprints:
+ self.module.fail_json(msg="Failed to get fingerprint")
+ return frozenset(fingerprints)
+
+ def is_keyid(self, keystr: str) -> re.Match[str] | None:
+ """
+ Verifies if a key, as provided by the user, is a key ID.
+
+ Note that this allows the short form of the key ID (4-bytes, or 8 hex
characters), used in older
+ versions of RPM, while a full key ID is 8-bytes, or 16 hex characters.
+ """
+ keystr = keystr.replace(' ', '')
return re.match('(0x)?[0-9a-f]{8}', keystr, flags=re.IGNORECASE)
- def execute_command(self, cmd):
+ def execute_command(self, cmd: str | list[str]) -> tuple[str, str]:
rc, stdout, stderr = self.module.run_command(cmd,
use_unsafe_shell=True)
if rc != 0:
self.module.fail_json(msg=stderr)
return stdout, stderr
- def is_key_imported(self, keyid):
- cmd = self.rpm + ' -q gpg-pubkey'
+ def get_installed_keys(self) -> list[dict[str, str]]:
+ """
+ Get the key ID and fingerprint for every key installed on the system.
+
+ This will grab the armor string for every key reported from `rpm -q
gpg-pubkey` and parse
+ it to obtain the key ID and fingerprint, including subkeys.
+ """
+ installed_keys = []
+
+ cmd = self.rpm + ' -q gpg-pubkey'
rc, stdout, stderr = self.module.run_command(cmd)
if rc != 0: # No key is installed on system
- return False
- cmd += ' --qf "%{description}" | ' + self.gpg + ' --no-tty --batch
--with-colons --fixed-list-mode -'
+ return []
+ cmd += ' --qf "%{description}"'
stdout, stderr = self.execute_command(cmd)
+
+ # Split the content into individual key blocks
+ key_blocks = []
+ current_block = []
+ in_key_block = False
+
for line in stdout.splitlines():
- if keyid in line.split(':')[4]:
- return True
+ if line.strip() == '-----BEGIN PGP PUBLIC KEY BLOCK-----':
+ in_key_block = True
+ current_block = [line]
+ elif line.strip() == '-----END PGP PUBLIC KEY BLOCK-----':
+ current_block.append(line)
+ key_blocks.append('\n'.join(current_block))
+ current_block = []
+ in_key_block = False
+ elif in_key_block:
+ current_block.append(line)
+
+ for armor_string in key_blocks:
+ installed_keys.extend(self.librpm.identify_keys(armor_string))
+
+ return installed_keys
+
+ def is_key_imported(self, keyid: str) -> bool:
+ """Check the supplied key ID value against the currently installed
keys."""
+ keyid_len = len(keyid)
+ if keyid in [k['keyid'][-keyid_len:] for k in self.installed_keys]:
+ return True
+
+ # Allow the user supplied key to also be a fingerprint
+ if keyid in [f['fingerprint'] for f in self.installed_keys]:
+ return True
+
return False
def import_key(self, keyfile):
if not self.module.check_mode:
self.execute_command([self.rpm, '--import', keyfile])
- def drop_key(self, keyid):
+ def _drop_key_rpm6(self, keyid: str) -> None:
+ """
+ Remove the key with the given key ID from the keyring using RPM 6+
method.
+
+ RPM version 6+ uses fingerprints and the 'rpmkeys --delete' command.
+ """
+ fingerprints = []
+ keyid_len = len(keyid)
+
+ for installed in self.installed_keys:
+ if keyid == installed['keyid'][-keyid_len:]:
+ fingerprints.append(installed['fingerprint'])
+ # We allow the user supplied 'key' to also be the full fingerprint.
+ elif keyid == installed['fingerprint']:
+ fingerprints.append(installed['fingerprint'])
+
+ if not fingerprints:
+ self.module.fail_json(msg=f"Supplied key ID {keyid} is not
installed.")
+ elif len(fingerprints) == 1:
+ self.execute_command([self.rpmkeys, '--delete', fingerprints[0]])
+ else:
+ self.module.fail_json(msg=f"Supplied key ID {keyid} matches more
than one fingerprint. Try using the fingerprint instead.")
+
+ def _drop_key_rpm4(self, keyid: str) -> None:
+ """
+ Remove the key with the given key ID from the keyring using RPM 4
method.
+
+ Older RPM versions use short form key ID (4-bytes) and 'rpm --erase'
command.
+ """
+ # If keyid is actually a fingerprint, we need to get the associated
key ID and use it.
+ for installed in self.installed_keys:
+ if keyid == installed['fingerprint']:
+ keyid = installed['keyid']
+ break
+
+ self.execute_command([self.rpm, '--erase', '--allmatches',
"gpg-pubkey-%s" % keyid[-8:].lower()])
+
+ def drop_key(self, keyid: str) -> None:
+ """Remove the key with the given key ID from the keyring."""
if not self.module.check_mode:
- self.execute_command([self.rpm, '--erase', '--allmatches',
"gpg-pubkey-%s" % keyid[-8:].lower()])
+ if self.librpm.using_librpm6:
+ self._drop_key_rpm6(keyid)
+ else:
+ self._drop_key_rpm4(keyid)
def main():
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/ansible_core-2.18.14/lib/ansible/release.py
new/ansible_core-2.18.15/lib/ansible/release.py
--- old/ansible_core-2.18.14/lib/ansible/release.py 2026-02-23
23:49:04.000000000 +0100
+++ new/ansible_core-2.18.15/lib/ansible/release.py 2026-03-23
18:38:33.000000000 +0100
@@ -17,6 +17,6 @@
from __future__ import annotations
-__version__ = '2.18.14'
+__version__ = '2.18.15'
__author__ = 'Ansible, Inc.'
__codename__ = "Fool in the Rain"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore' old/ansible_core-2.18.14/pyproject.toml
new/ansible_core-2.18.15/pyproject.toml
--- old/ansible_core-2.18.14/pyproject.toml 2026-02-23 23:49:04.000000000
+0100
+++ new/ansible_core-2.18.15/pyproject.toml 2026-03-23 18:38:33.000000000
+0100
@@ -1,5 +1,5 @@
[build-system]
-requires = ["setuptools >= 66.1.0, <= 82.0.0", "wheel == 0.45.1"] # lower
bound to support controller Python versions, upper bound for latest version
tested at release
+requires = ["setuptools >= 66.1.0, <= 82.0.1", "wheel == 0.45.1"] # lower
bound to support controller Python versions, upper bound for latest version
tested at release
build-backend = "setuptools.build_meta"
[project]
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/ansible_core-2.18.14/test/integration/targets/rpm_key/files/TEST-V6-NON-PQC-KEY.cert
new/ansible_core-2.18.15/test/integration/targets/rpm_key/files/TEST-V6-NON-PQC-KEY.cert
---
old/ansible_core-2.18.14/test/integration/targets/rpm_key/files/TEST-V6-NON-PQC-KEY.cert
1970-01-01 01:00:00.000000000 +0100
+++
new/ansible_core-2.18.15/test/integration/targets/rpm_key/files/TEST-V6-NON-PQC-KEY.cert
2026-03-23 18:38:33.000000000 +0100
@@ -0,0 +1,27 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+Comment: 451A 6DBE 3B0F 51EA FBEF 3585 D53F 0277 62A6 C0DE A0A8 4EE5 7DB3
87B0 1651 4BBC
+Comment: <[email protected]>
+Comment: Ansible Example
+
+xioGaWachxsAAAAgPh2gtnpD585bPVCmyST0RA3XAn6rc0oP4lTMSZ5GjQXCrAYf
+GwoAAAA9BYJpZpyHBYkFpI+9AwsJBwMVCggCmwECHgkiIQZFGm2+Ow9R6vvvNYXV
+PwJ3YqbA3qCoTuV9s4ewFlFLvAAAAAADziCYy5PwGbFr0+ZCRuWbw54j5otd0SrF
+h5zJih5TVryL1PNLMtQZt1XtLICOSBrtgbCMIhntaxW4O6ZHmv2p4BQIjt7kNWZA
+/AJcfn57iGdoFrZD8pMTWKYR66R+FForhwXNETxpbmZvQHJlZGhhdC5jb20+wqwG
+ExsKAAAAPQWCaWachwWJBaSPvQMLCQcDFQoIApsBAh4JIiEGRRptvjsPUer77zWF
+1T8Cd2KmwN6gqE7lfbOHsBZRS7wAAAAA+NogVNCvF3Y5SSpUF4cyH9NGRE9Gjdml
+KVefqvmh4T0g0JyM/FdRmxgkzRTaUdqjRmVMLg53ScY0AwaGjuhiEjVBe2GGQrtu
+aiupfyGvcAnUxNwmhoHvh0BeOu3vQwlCk14IzQ9BbnNpYmxlIEV4YW1wbGXCrwYT
+GwoAAABABYJpZpyHBYkFpI+9AwsJBwMVCggCmQECmwECHgkiIQZFGm2+Ow9R6vvv
+NYXVPwJ3YqbA3qCoTuV9s4ewFlFLvAAAAAAowSC8pmjMJom99WiMkM19I0qzNbFm
+YwCTpfirAk4v89ggIUw6eTF1oKlk6WTUeVeK2nBGHd2cZlV6NtKAlCllPqLq0rlp
+4ZxVo6cfUKxNjdnbTrE4Seamc0hYtLG49TEkaATOKgZpZpyHGwAAACAGbi2A12S/
+VoxdCm9I5VPSmsUHRO0DznHeB/VEG8NKCsLAewYYGwoAAADMBYJpZpyHBYkFpI+9
+ApsCmaAGGRsKAAAAKQWCaWachyIhBgiuhwe1s82YIvPJXs0dT6zFgNVk7juLtL8s
+Zx6H2pNDAAAAAILuILtOYzI43a9SImbCmkWStIxesTFKhFBMvaMPJOrErcWGjiga
+Ikq7NVpmSfyVs5s0DJrr7c3TJ0+Fneku/PCnppCh8140qfhlKVx+ePwpv41jQcK9
+uj8jTdK3I8Pn6yRGAyIhBkUabb47D1Hq++81hdU/AndipsDeoKhO5X2zh7AWUUu8
+AAAAAIufIPo2sGq+KZSbp1QV4/A65DtPpSK0oSwML/B4OrPQLQ8+9iuZ2CB5iHJ6
+4xQ4OvyKGslJU6fY0A23WgFguZdIvfIAvRLo2WqFkOhyBzJc55u5IU5ukGWn8AY0
+bgG7sR6hBA==
+-----END PGP PUBLIC KEY BLOCK-----
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/ansible_core-2.18.14/test/integration/targets/rpm_key/tasks/main.yaml
new/ansible_core-2.18.15/test/integration/targets/rpm_key/tasks/main.yaml
--- old/ansible_core-2.18.14/test/integration/targets/rpm_key/tasks/main.yaml
2026-02-23 23:49:04.000000000 +0100
+++ new/ansible_core-2.18.15/test/integration/targets/rpm_key/tasks/main.yaml
2026-03-23 18:38:33.000000000 +0100
@@ -1,7 +1,3 @@
- - name: Skip RHEL 10.1 until rpm_key has been updated
- meta: end_play
- when: ansible_distribution == "RedHat" and ansible_distribution_version ==
"10.1"
-
- when: ansible_os_family == "RedHat"
block:
@@ -23,8 +19,10 @@
always:
+ # This will fail if the tests leave no keys in the key store
- name: Remove all GPG keys from key ring
shell: rpm -q gpg-pubkey | xargs rpm -e
+ ignore_errors: yes
- name: Restore the previously installed GPG keys
command: rpm --import {{ (remote_tmp_dir + '/pubkeys') | quote }}
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/ansible_core-2.18.14/test/integration/targets/rpm_key/tasks/rpm_key.yaml
new/ansible_core-2.18.15/test/integration/targets/rpm_key/tasks/rpm_key.yaml
---
old/ansible_core-2.18.14/test/integration/targets/rpm_key/tasks/rpm_key.yaml
2026-02-23 23:49:04.000000000 +0100
+++
new/ansible_core-2.18.15/test/integration/targets/rpm_key/tasks/rpm_key.yaml
2026-03-23 18:38:33.000000000 +0100
@@ -123,6 +123,16 @@
assert:
that: key_id_idempotence is not changed
+- name: Add key to test deleting by fingerprint
+ rpm_key:
+ state: present
+ key: "{{ test_key_url }}"
+
+- name: Remove key by using fingerprint
+ rpm_key:
+ state: absent
+ key: "{{ primary_fingerprint }}"
+
- name: Add very first key on system again
rpm_key:
state: present
@@ -227,3 +237,68 @@
that:
- result is success
- result is not changed
+
+# Test PGPv6 keys
+- name: Test PGPv6 Keys
+ block:
+
+ # Reset to test PGPv6 keys validation
+ - name: Remove all keys from key ring
+ shell: rpm -q gpg-pubkey | xargs rpm -e
+
+ - name: Copy v6 key to remote
+ copy:
+ src: "files/{{ test_v6_key_file }}"
+ dest: "{{ remote_tmp_dir }}/{{ test_v6_key_file }}"
+ mode: "0644"
+
+ - name: Import v6 key
+ rpm_key:
+ state: present
+ key: "{{ remote_tmp_dir }}/{{ test_v6_key_file }}"
+ fingerprint: "{{ invalid_fingerprint }}"
+ register: result
+ failed_when: result is success
+
+ - name: Verify invalid fingerprint failure
+ assert:
+ that:
+ - result is success
+ - result is not changed
+ - result.msg is contains 'does not match any key fingerprints'
+
+ - name: Import v6 key
+ rpm_key:
+ state: present
+ key: "{{ remote_tmp_dir }}/{{ test_v6_key_file }}"
+ fingerprint: "{{ test_v6_key_fingerprint }}"
+
+ - name: Import v6 key (idempotent)
+ rpm_key:
+ state: present
+ key: "{{ remote_tmp_dir }}/{{ test_v6_key_file }}"
+ fingerprint: "{{ test_v6_key_fingerprint }}"
+ register: key_idempotence
+
+ - name: Verify idempotence
+ assert:
+ that: key_idempotence is not changed
+
+ - name: Delete v6 key
+ rpm_key:
+ state: absent
+ key: "{{ test_v6_key_keyid }}"
+
+ - name: Import v6 key again
+ rpm_key:
+ state: present
+ key: "{{ remote_tmp_dir }}/{{ test_v6_key_file }}"
+ fingerprint: "{{ test_v6_key_fingerprint }}"
+
+ - name: Delete v6 key by fingerprint
+ rpm_key:
+ state: absent
+ key: "{{ test_v6_key_fingerprint }}"
+
+ when: (ansible_facts['distribution'] == "RedHat" and
ansible_facts['distribution_version'] is version('10.1', '>='))
+ or (ansible_facts['distribution'] == "Fedora" and
ansible_facts['distribution_version'] is version('43', '>='))
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/ansible_core-2.18.14/test/integration/targets/rpm_key/vars/main.yml
new/ansible_core-2.18.15/test/integration/targets/rpm_key/vars/main.yml
--- old/ansible_core-2.18.14/test/integration/targets/rpm_key/vars/main.yml
2026-02-23 23:49:04.000000000 +0100
+++ new/ansible_core-2.18.15/test/integration/targets/rpm_key/vars/main.yml
2026-03-23 18:38:33.000000000 +0100
@@ -6,3 +6,6 @@
invalid_fingerprint: 1111 1111 1111 1111 1111 1111 1111 1111 1111 1111
primary_fingerprint: 66D1 5FDD 8728 7219 C8E1 5478 D200 CD70 2853 E6D0
sub_key_fingerprint: E617 DCD4 065C 2AFC 0B2C F7A7 BA8B C08C 0F69 1F94
+test_v6_key_file: "TEST-V6-NON-PQC-KEY.cert"
+test_v6_key_fingerprint:
"451A6DBE3B0F51EAFBEF3585D53F027762A6C0DEA0A84EE57DB387B016514BBC"
+test_v6_key_keyid: "451A6DBE3B0F51EA"
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/ansible_core-2.18.14/test/lib/ansible_test/_data/completion/docker.txt
new/ansible_core-2.18.15/test/lib/ansible_test/_data/completion/docker.txt
--- old/ansible_core-2.18.14/test/lib/ansible_test/_data/completion/docker.txt
2026-02-23 23:49:04.000000000 +0100
+++ new/ansible_core-2.18.15/test/lib/ansible_test/_data/completion/docker.txt
2026-03-23 18:38:33.000000000 +0100
@@ -1,7 +1,7 @@
base image=quay.io/ansible/base-test-container:7.7.0
python=3.13,3.8,3.9,3.10,3.11,3.12
default image=quay.io/ansible/default-test-container:10.9.0
python=3.13,3.8,3.9,3.10,3.11,3.12 context=collection
default image=quay.io/ansible/ansible-core-test-container:10.9.0
python=3.13,3.8,3.9,3.10,3.11,3.12 context=ansible-core
-alpine320 image=quay.io/ansible/alpine320-test-container:8.1.0 python=3.12
cgroup=none audit=none
-fedora40 image=quay.io/ansible/fedora40-test-container:8.1.0 python=3.12
+alpine320 image=quay.io/ansible/alpine320-test-container:8.1.0 python=3.12
cgroup=none audit=none alias=alpine
+fedora40 image=quay.io/ansible/fedora40-test-container:8.1.0 python=3.12
alias=fedora
ubuntu2204 image=quay.io/ansible/ubuntu2204-test-container:8.1.0 python=3.10
-ubuntu2404 image=quay.io/ansible/ubuntu2404-test-container:8.1.0 python=3.12
+ubuntu2404 image=quay.io/ansible/ubuntu2404-test-container:8.1.0 python=3.12
alias=ubuntu
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/ansible_core-2.18.14/test/lib/ansible_test/_data/completion/remote.txt
new/ansible_core-2.18.15/test/lib/ansible_test/_data/completion/remote.txt
--- old/ansible_core-2.18.14/test/lib/ansible_test/_data/completion/remote.txt
2026-02-23 23:49:04.000000000 +0100
+++ new/ansible_core-2.18.15/test/lib/ansible_test/_data/completion/remote.txt
2026-03-23 18:38:33.000000000 +0100
@@ -1,15 +1,15 @@
-alpine/3.20 python=3.12 become=doas_sudo provider=aws arch=x86_64
+alpine/3.20 python=3.12 become=doas_sudo provider=aws arch=x86_64
alias=alpine/3,alpine/latest
alpine become=doas_sudo provider=aws arch=x86_64
-fedora/40 python=3.12 become=sudo provider=aws arch=x86_64
+fedora/40 python=3.12 become=sudo provider=aws arch=x86_64 alias=fedora/latest
fedora become=sudo provider=aws arch=x86_64
-freebsd/13.5 python=3.11 python_dir=/usr/local/bin become=su_sudo provider=aws
arch=x86_64
-freebsd/14.1 python=3.9,3.11 python_dir=/usr/local/bin become=su_sudo
provider=aws arch=x86_64
+freebsd/13.5 python=3.11 python_dir=/usr/local/bin become=su_sudo provider=aws
arch=x86_64 alias=freebsd/13
+freebsd/14.1 python=3.9,3.11 python_dir=/usr/local/bin become=su_sudo
provider=aws arch=x86_64 alias=freebsd/14,freebsd/latest
freebsd python_dir=/usr/local/bin become=su_sudo provider=aws arch=x86_64
-macos/14.3 python=3.11 python_dir=/usr/local/bin become=sudo
provider=parallels arch=x86_64
+macos/14.3 python=3.11 python_dir=/usr/local/bin become=sudo
provider=parallels arch=x86_64 alias=macos/14,macos/latest
macos python_dir=/usr/local/bin become=sudo provider=parallels arch=x86_64
-rhel/9.7 python=3.9,3.12 become=sudo provider=aws arch=x86_64
-rhel/10.1 python=3.12 become=sudo provider=aws arch=x86_64
+rhel/9.7 python=3.9,3.12 become=sudo provider=aws arch=x86_64 alias=rhel/9
+rhel/10.1 python=3.12 become=sudo provider=aws arch=x86_64
alias=rhel/10,rhel/latest
rhel become=sudo provider=aws arch=x86_64
ubuntu/22.04 python=3.10 become=sudo provider=aws arch=x86_64
-ubuntu/24.04 python=3.12 become=sudo provider=aws arch=x86_64
+ubuntu/24.04 python=3.12 become=sudo provider=aws arch=x86_64
alias=ubuntu/latest
ubuntu become=sudo provider=aws arch=x86_64
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/ansible_core-2.18.14/test/lib/ansible_test/_data/completion/windows.txt
new/ansible_core-2.18.15/test/lib/ansible_test/_data/completion/windows.txt
--- old/ansible_core-2.18.14/test/lib/ansible_test/_data/completion/windows.txt
2026-02-23 23:49:04.000000000 +0100
+++ new/ansible_core-2.18.15/test/lib/ansible_test/_data/completion/windows.txt
2026-03-23 18:38:33.000000000 +0100
@@ -1,4 +1,4 @@
windows/2016 provider=aws arch=x86_64 connection=winrm+http
windows/2019 provider=aws arch=x86_64 connection=winrm+https
-windows/2022 provider=aws arch=x86_64 connection=winrm+https
+windows/2022 provider=aws arch=x86_64 connection=winrm+https
alias=windows/latest
windows provider=aws arch=x86_64 connection=winrm+https
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/ansible_core-2.18.14/test/lib/ansible_test/_internal/ci/gha.py
new/ansible_core-2.18.15/test/lib/ansible_test/_internal/ci/gha.py
--- old/ansible_core-2.18.14/test/lib/ansible_test/_internal/ci/gha.py
1970-01-01 01:00:00.000000000 +0100
+++ new/ansible_core-2.18.15/test/lib/ansible_test/_internal/ci/gha.py
2026-03-23 18:38:33.000000000 +0100
@@ -0,0 +1,106 @@
+"""Support code for working with GitHub Actions."""
+
+from __future__ import annotations
+
+import os
+import typing as t
+
+from ..config import (
+ CommonConfig,
+ TestConfig,
+)
+
+from ..util import (
+ ApplicationError,
+ MissingEnvironmentVariable,
+)
+
+from . import (
+ AuthContext,
+ CIProvider,
+ GeneratingAuthHelper,
+)
+
+CODE = 'gha'
+JOB_ID_ENV_VAR = 'ANSIBLE_TEST_GHA_JOB_ID'
+ARTIFACT_ID_ENV_VAR = 'ANSIBLE_TEST_GHA_SSH_KEY_ARTIFACT_ID'
+
+
+class GitHubActions(CIProvider):
+ """CI provider implementation for GitHub Actions."""
+
+ def __init__(self) -> None:
+ self.auth = GitHubActionsAuthHelper()
+
+ @staticmethod
+ def is_supported() -> bool:
+ """Return True if this provider is supported in the current running
environment."""
+ return JOB_ID_ENV_VAR in os.environ and ARTIFACT_ID_ENV_VAR in
os.environ
+
+ @property
+ def code(self) -> str:
+ """Return a unique code representing this provider."""
+ return CODE
+
+ @property
+ def name(self) -> str:
+ """Return descriptive name for this provider."""
+ return 'GitHub Actions'
+
+ def generate_resource_prefix(self) -> str:
+ """Return a resource prefix specific to this CI provider."""
+ keys = [
+ 'GITHUB_REPOSITORY',
+ JOB_ID_ENV_VAR,
+ ]
+
+ try:
+ segments = [os.environ[key] for key in keys]
+ except KeyError as ex:
+ raise MissingEnvironmentVariable(name=ex.args[0]) from None
+
+ prefix = '-'.join(['gha'] + segments)
+
+ return prefix
+
+ def get_base_commit(self, args: CommonConfig) -> str:
+ """Return the base commit or an empty string."""
+ return ''
+
+ def detect_changes(self, args: TestConfig) -> t.Optional[list[str]]:
+ """Initialize change detection."""
+ return None
+
+ def supports_core_ci_auth(self) -> bool:
+ """Return True if Ansible Core CI is supported."""
+ return True
+
+ def prepare_core_ci_request(self, config: dict[str, object], context:
AuthContext) -> dict[str, object]:
+ try:
+ owner, name = os.environ['GITHUB_REPOSITORY'].split('/', 1)
+
+ request: dict[str, object] = dict(
+ type="gha:ssh",
+ config=config,
+ repository_owner=owner,
+ repository_name=name,
+ job_id=int(os.environ[JOB_ID_ENV_VAR]),
+ artifact_id=int(os.environ[ARTIFACT_ID_ENV_VAR]),
+ )
+ except KeyError as ex:
+ raise MissingEnvironmentVariable(name=ex.args[0]) from None
+
+ self.auth.sign_request(request, context)
+
+ return request
+
+ def get_git_details(self, args: CommonConfig) -> t.Optional[dict[str,
t.Any]]:
+ """Return details about git in the current environment."""
+ return None
+
+
+class GitHubActionsAuthHelper(GeneratingAuthHelper):
+ """Authentication helper for GitHub Actions."""
+
+ def generate_key_pair(self) -> None:
+ raise ApplicationError(f'Missing SSH private key:
{self.private_key_file}')
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/ansible_core-2.18.14/test/lib/ansible_test/_internal/cli/compat.py
new/ansible_core-2.18.15/test/lib/ansible_test/_internal/cli/compat.py
--- old/ansible_core-2.18.14/test/lib/ansible_test/_internal/cli/compat.py
2026-02-23 23:49:04.000000000 +0100
+++ new/ansible_core-2.18.15/test/lib/ansible_test/_internal/cli/compat.py
2026-03-23 18:38:33.000000000 +0100
@@ -19,7 +19,6 @@
display,
filter_args,
sorted_versions,
- str_to_version,
)
from ..docker_util import (
@@ -30,6 +29,7 @@
docker_completion,
remote_completion,
filter_completion,
+ windows_completion,
)
from ..host_configs import (
@@ -68,9 +68,7 @@
def get_fallback_remote_controller() -> str:
"""Return the remote fallback platform for the controller."""
- platform = 'freebsd' # lower cost than RHEL and macOS
- candidates = [item for item in
filter_completion(remote_completion()).values() if item.controller_supported
and item.platform == platform]
- fallback = sorted(candidates, key=lambda value:
str_to_version(value.version), reverse=True)[0]
+ fallback = [item for name, item in
filter_completion(remote_completion()).items() if item.controller_supported and
name == "freebsd/latest"][0]
return fallback.name
@@ -350,17 +348,17 @@
if docker_config.controller_supported:
if controller_python(options.python) or not options.python:
- controller = DockerConfig(name=options.docker,
python=native_python(options),
+ controller = DockerConfig(name=docker_config.name,
python=native_python(options),
privileged=options.docker_privileged, seccomp=options.docker_seccomp,
memory=options.docker_memory)
targets = controller_targets(mode, options, controller)
else:
controller_fallback = f'docker:{options.docker}',
f'--docker {options.docker} --python {options.python}', FallbackReason.PYTHON
- controller = DockerConfig(name=options.docker)
+ controller = DockerConfig(name=docker_config.name)
targets = controller_targets(mode, options, controller)
else:
controller_fallback = f'docker:{docker_fallback}', f'--docker
{options.docker}', FallbackReason.ENVIRONMENT
controller = DockerConfig(name=docker_fallback)
- targets = [DockerConfig(name=options.docker,
python=native_python(options),
+ targets = [DockerConfig(name=docker_config.name,
python=native_python(options),
privileged=options.docker_privileged,
seccomp=options.docker_seccomp, memory=options.docker_memory)]
else:
if not options.python:
@@ -385,23 +383,25 @@
if remote_config.controller_supported:
if controller_python(options.python) or not options.python:
- controller = PosixRemoteConfig(name=options.remote,
python=native_python(options), provider=options.remote_provider,
+ controller = PosixRemoteConfig(name=remote_config.name,
python=native_python(options), provider=options.remote_provider,
arch=options.remote_arch)
targets = controller_targets(mode, options, controller)
else:
controller_fallback = f'remote:{options.remote}',
f'--remote {options.remote} --python {options.python}', FallbackReason.PYTHON
- controller = PosixRemoteConfig(name=options.remote,
provider=options.remote_provider, arch=options.remote_arch)
+ controller = PosixRemoteConfig(name=remote_config.name,
provider=options.remote_provider, arch=options.remote_arch)
targets = controller_targets(mode, options, controller)
else:
context, reason = f'--remote {options.remote}',
FallbackReason.ENVIRONMENT
controller = None
- targets = [PosixRemoteConfig(name=options.remote,
python=native_python(options), provider=options.remote_provider,
arch=options.remote_arch)]
+ targets = [PosixRemoteConfig(name=remote_config.name,
python=native_python(options), provider=options.remote_provider,
+ arch=options.remote_arch)]
elif mode == TargetMode.SHELL and
options.remote.startswith('windows/'):
if options.python and options.python not in
CONTROLLER_PYTHON_VERSIONS:
raise ControllerNotSupportedError(f'--python {options.python}')
+ name =
resolve_windows_names([options.remote.removeprefix("windows/")])[0]
controller = OriginConfig(python=native_python(options))
- targets = [WindowsRemoteConfig(name=options.remote,
provider=options.remote_provider, arch=options.remote_arch)]
+ targets = [WindowsRemoteConfig(name=name,
provider=options.remote_provider, arch=options.remote_arch)]
else:
if not options.python:
raise PythonVersionUnspecifiedError(f'--remote
{options.remote}')
@@ -470,8 +470,8 @@
"""Return a list of non-POSIX targets if the target mode is non-POSIX."""
if mode == TargetMode.WINDOWS_INTEGRATION:
if options.windows:
- targets = [WindowsRemoteConfig(name=f'windows/{version}',
provider=options.remote_provider, arch=options.remote_arch)
- for version in options.windows]
+ names = resolve_windows_names(options.windows)
+ targets = [WindowsRemoteConfig(name=name,
provider=options.remote_provider, arch=options.remote_arch) for name in names]
else:
targets = [WindowsInventoryConfig(path=options.inventory)]
elif mode == TargetMode.NETWORK_INTEGRATION:
@@ -495,6 +495,16 @@
return targets
+def resolve_windows_names(versions: list[str]) -> list[str]:
+ """Resolve a list of Windows versions into version names, resolving any
aliases."""
+ windows_completions = filter_completion(windows_completion())
+
+ names = [f'windows/{version}' for version in versions] # map versions to
names
+ names = [windows_completions[name].name if name in windows_completions
else name for name in names] # resolve aliases
+
+ return names
+
+
def default_targets(
mode: TargetMode,
controller: ControllerHostConfig,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/ansible_core-2.18.14/test/lib/ansible_test/_internal/cli/environments.py
new/ansible_core-2.18.15/test/lib/ansible_test/_internal/cli/environments.py
---
old/ansible_core-2.18.14/test/lib/ansible_test/_internal/cli/environments.py
2026-02-23 23:49:04.000000000 +0100
+++
new/ansible_core-2.18.15/test/lib/ansible_test/_internal/cli/environments.py
2026-03-23 18:38:33.000000000 +0100
@@ -593,10 +593,10 @@
def get_windows_platform_choices() -> list[str]:
- """Return a list of supported Windows versions matching the given
prefix."""
- return sorted(f'windows/{windows.version}' for windows in
filter_completion(windows_completion()).values())
+ """Return a list of supported Windows version names."""
+ return sorted(filter_completion(windows_completion()))
def get_windows_version_choices() -> list[str]:
"""Return a list of supported Windows versions."""
- return sorted(windows.version for windows in
filter_completion(windows_completion()).values())
+ return sorted(name.removeprefix("windows/") for name in
get_windows_platform_choices())
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn'
'--exclude=.svnignore'
old/ansible_core-2.18.14/test/lib/ansible_test/_internal/completion.py
new/ansible_core-2.18.15/test/lib/ansible_test/_internal/completion.py
--- old/ansible_core-2.18.14/test/lib/ansible_test/_internal/completion.py
2026-02-23 23:49:04.000000000 +0100
+++ new/ansible_core-2.18.15/test/lib/ansible_test/_internal/completion.py
2026-03-23 18:38:33.000000000 +0100
@@ -5,6 +5,8 @@
import dataclasses
import enum
import os
+import re
+import sys
import typing as t
from .constants import (
@@ -16,6 +18,8 @@
ANSIBLE_TEST_DATA_ROOT,
cache,
read_lines_without_comments,
+ str_to_version,
+ InternalError,
)
from .data import (
@@ -60,6 +64,11 @@
def is_default(self) -> bool:
"""True if the completion entry is only used for defaults, otherwise
False."""
+ @property
+ def sort_key(self) -> tuple[str, tuple[int, ...]]:
+ """Key used for sorting completion entries."""
+ return '', (0,)
+
@dataclasses.dataclass(frozen=True)
class PosixCompletionConfig(CompletionConfig, metaclass=abc.ABCMeta):
@@ -113,6 +122,16 @@
arch: t.Optional[str] = None
@property
+ def sort_key(self) -> tuple[str, tuple[int, ...]]:
+ """Key used for sorting completion entries."""
+ try:
+ version = str_to_version(self.version)
+ except ValueError:
+ version = (sys.maxsize,)
+
+ return self.platform, version
+
+ @property
def platform(self) -> str:
"""The name of the platform."""
return self.name.partition('/')[0]
@@ -175,6 +194,19 @@
placeholder: bool = False
@property
+ def sort_key(self) -> tuple[str, tuple[int, ...]]:
+ """Key used for sorting completion entries."""
+ match = re.match('^(?P<platform>[a-z]+)(?P<version>[0-9]*)$',
self.name)
+ platform = match.group('platform')
+
+ try:
+ version = str_to_version(match.group('version'))
+ except ValueError:
+ version = (sys.maxsize,)
+
+ return platform, version
+
+ @property
def is_default(self) -> bool:
"""True if the completion entry is only used for defaults, otherwise
False."""
return False
@@ -262,12 +294,24 @@
context = 'ansible-core'
items = {name: data for name, data in [parse_completion_entry(line) for
line in lines] if data.get('context', context) == context}
+ aliases: dict[tuple[str, str], dict[str, str]] = {}
+ aliases_seen: set[str] = set()
- for item in items.values():
+ for item_name, item in items.items():
item.pop('context', None)
item.pop('placeholder', None)
+ if alias := item.pop('alias', None):
+ for aliased_name in alias.split(','):
+ if aliased_name in aliases_seen:
+ raise InternalError(f"Duplicate alias {aliased_name!r}
found for {name!r} completion.")
+
+ aliases_seen.add(aliased_name)
+ aliases[(aliased_name, item_name)] = item
+
completion = {name: completion_type(name=name, **data) for name, data in
items.items()}
+ completion |= {an[0]: completion_type(name=an[1], **data) for an, data in
aliases.items()}
+ completion = dict(sorted(completion.items(), key=lambda entry:
entry[1].sort_key))
return completion
++++++ ansible_core-2.18.14.tar.gz.sha256 -> ansible_core-2.18.15.tar.gz.sha256
++++++
---
/work/SRC/openSUSE:Factory/ansible-core-2.18/ansible_core-2.18.14.tar.gz.sha256
2026-03-11 20:58:47.790525539 +0100
+++
/work/SRC/openSUSE:Factory/.ansible-core-2.18.new.8177/ansible_core-2.18.15.tar.gz.sha256
2026-03-27 06:36:03.036701761 +0100
@@ -1 +1 @@
-da9ff29f673e2cf2632ddd266883977b6602559c65cbd0cfc4d48e70306e85d5
ansible_core-2.18.14.tar.gz
+577effbe1dba09cecc2b03ca767f693b3f16a773d5c3cbdb8348a633b41f3e72
ansible_core-2.18.15.tar.gz