diff --git a/doc/source/general-roles.rst b/doc/source/general-roles.rst
index c81eb4efd..5bfb30046 100644
--- a/doc/source/general-roles.rst
+++ b/doc/source/general-roles.rst
@@ -15,6 +15,7 @@ General Purpose Roles
 .. zuul:autorole:: emit-job-header
 .. zuul:autorole:: enable-fips
 .. zuul:autorole:: enable-netconsole
+.. zuul:autorole:: encrypt-file
 .. zuul:autorole:: ensure-bazelisk
 .. zuul:autorole:: ensure-dhall
 .. zuul:autorole:: ensure-dstat-graph
diff --git a/roles/encrypt-file/README.rst b/roles/encrypt-file/README.rst
new file mode 100644
index 000000000..d214c8e2a
--- /dev/null
+++ b/roles/encrypt-file/README.rst
@@ -0,0 +1,34 @@
+encrypt-file
+
+Import GPG keys and encrypt a file
+
+**Role Variables**
+
+.. zuul:rolevar:: encrypt_file
+   :default: *undefined*
+
+   A *string* with the full path to a log file to encrypt, or a *list*
+   of *string* values of full paths to encrypt.  Must be defined.
+   Resulting file(s) will have ``.gpg`` added.
+
+.. zuul:rolevar:: encrypt_file_recipients
+   :default: []
+
+   List of recipients who will be able to decrypt the file(s).  This
+   should be a list of ``name`` keys that exist in
+   ``encrypt_file_keys``.
+
+.. zuul:rolevar:: encrypt_file_keys
+   :default: []
+
+   Keys available to encrypt the file with.  Each entry is a
+   dictionary with keys
+
+   * ``name`` : a freeform string identifier
+   * ``key_id``: the GPG key ID
+   * ``gpg_asc``: the GPG ASCII-armored public key.  If the public-key
+     is not already available, it will be imported to GPG.
+
+   It is intended that this is a global-variable, and specific files
+   to be encrypted then choose a subset of keys in this variable for
+   encryption.
diff --git a/roles/encrypt-file/defaults/main.yaml b/roles/encrypt-file/defaults/main.yaml
new file mode 100644
index 000000000..cd7daf6a1
--- /dev/null
+++ b/roles/encrypt-file/defaults/main.yaml
@@ -0,0 +1 @@
+encrypt_file_keys: []
diff --git a/roles/encrypt-file/tasks/import-key.yaml b/roles/encrypt-file/tasks/import-key.yaml
new file mode 100644
index 000000000..b90120aa6
--- /dev/null
+++ b/roles/encrypt-file/tasks/import-key.yaml
@@ -0,0 +1,38 @@
+- name: Check for existing key
+  command: |
+    gpg --list-keys {{ zj_encrypt_file.key_id }}
+  register: _key_exists
+  # A found key returns 0, a missing key returns 2
+  failed_when: _key_exists.rc != 0 and _key_exists.rc != 2
+
+- name: Install key
+  when: _key_exists.rc != 0
+  block:
+    - name: Create temporary keyfile
+      tempfile:
+        state: file
+      register: _keyfile
+
+    - name: Copy keyfile material  # noqa risky-file-permissions
+      copy:
+        content: '{{ zj_encrypt_file.gpg_asc }}'
+        dest: '{{ _keyfile.path }}'
+
+    - name: Import key
+      command: |
+        gpg --import {{ _keyfile.path }}
+
+    # Strip all whitespace and take the second line of output, which
+    # is the fingerprint, then import this at "I trust fully" level.
+    # This was a pain to figure out as gpg really wants to communicate
+    # with a tty if you do something obvious like "gpg --edit-key <id>
+    # ...".  And what is menu option number "5" is actually "6" in the
+    # ownertrust db!
+    - name: Trust key
+      shell: |
+          echo $(gpg --fingerprint {{ zj_encrypt_file.key_id }} | sed -n  "s/ //g;2 p"):6: | gpg --import-ownertrust
+
+    - name: Remove temporary keyfile
+      file:
+        path: '{{ _keyfile.path }}'
+        state: absent
diff --git a/roles/encrypt-file/tasks/main.yaml b/roles/encrypt-file/tasks/main.yaml
new file mode 100644
index 000000000..277a35565
--- /dev/null
+++ b/roles/encrypt-file/tasks/main.yaml
@@ -0,0 +1,37 @@
+- name: Validate input file
+  fail:
+    msg: 'Must define "encrypt_file"'
+  when: encrypt_file is undefined
+
+- name: Ensure gpg2 installed
+  package:
+    name: gnupg2
+    state: present
+
+- name: Check for required keys
+  fail:
+    msg: 'Name {{ zj_recipient_name }} not in encrypt_file_keys'
+  when: zj_recipient_name not in encrypt_file_keys | map(attribute="name")
+  loop: '{{ encrypt_file_recipients }}'
+  loop_control:
+    loop_var: zj_recipient_name
+
+- name: Build recipient list
+  set_fact:
+    _recipients: '{{ encrypt_file_keys | selectattr("name", "in", encrypt_file_recipients) | list }}'
+
+- name: Install keys
+  include_tasks: import-key.yaml
+  loop: '{{ _recipients }}'
+  loop_control:
+    loop_var: zj_encrypt_file
+
+- name: Build recipient list
+  set_fact:
+    _recipients_cmd: '--recipient={{ _recipients | map(attribute="key_id") | join(" --recipient=") }}'
+
+- name: Encrypt file
+  command: 'gpg2 --encrypt --output {{ zj_encrypt_file }}.gpg {{ _recipients_cmd }} {{ zj_encrypt_file }}'
+  loop: '{{ [ encrypt_file ] if encrypt_file is string else encrypt_file }}'
+  loop_control:
+    loop_var: zj_encrypt_file
diff --git a/test-playbooks/encrypt-file.yaml b/test-playbooks/encrypt-file.yaml
new file mode 100644
index 000000000..1f4c799b0
--- /dev/null
+++ b/test-playbooks/encrypt-file.yaml
@@ -0,0 +1,114 @@
+- hosts: all
+  tasks:
+
+    - name: Make a fake file
+      tempfile:
+        state: file
+      register: _tempfile
+
+    - name: Add some data
+      copy:
+        content: 'Hello, I am encrypted'
+        dest: '{{ _tempfile.path }}'
+
+    - name: Setup encryption variables
+      set_fact:
+        encrypt_file_keys:
+          - name: 'zuul-jobs-test-1'
+            key_id: '0xD0A3C69F209B3B8E'
+            gpg_asc: |
+                -----BEGIN PGP PUBLIC KEY BLOCK-----
+
+                mDMEYgxZHhYJKwYBBAHaRw8BAQdAl9ZJaMvAc4kcO2mWFCZ5Em0xl7kRc3QYgtg0
+                +98sqoO0EHp1dWwtam9icy10ZXN0LTGIlAQTFgoAPBYhBKNWMSQoy8kXXIv/T9Cj
+                xp8gmzuOBQJiDFkeAhsDBQsJCAcCAyICAQYVCgkICwIEFgIDAQIeBwIXgAAKCRDQ
+                o8afIJs7jqlUAQCVxaINjS5/rd1oCAp19lHrscMhFmQBOtxyU7CTDoCrCAEAh9mH
+                scfOGRWhxiwg0+dXe6RE/C3Kk13kdN8pfTOIGA24OARiDFkeEgorBgEEAZdVAQUB
+                AQdAl53uFFzrhnTTmq0YbDRtQ5KrMJYYNahImPzzrvVajW4DAQgHiHgEGBYKACAW
+                IQSjVjEkKMvJF1yL/0/Qo8afIJs7jgUCYgxZHgIbDAAKCRDQo8afIJs7jqE7AP9M
+                LRe/tJ+SeHexDI1m9tmo6xcID7UOJW8eIuuwi3kjZgEAl+WqJfqjBxJmBWIjTZcV
+                zA2T4i8ViORhXLo0oohQVwE=
+                =/liI
+                -----END PGP PUBLIC KEY BLOCK-----
+
+          - name: 'zuul-jobs-test-2'
+            key_id: '0x4E1BA7A3AB674E6F'
+            gpg_asc: |
+                -----BEGIN PGP PUBLIC KEY BLOCK-----
+
+                mDMEYgxZkRYJKwYBBAHaRw8BAQdA1/5ta4i1G+NGqRWtlFuzZUmDHZP5uMt1gguX
+                WcXfoGW0EHp1dWwtam9icy10ZXN0LTKIlAQTFgoAPBYhBN0G/+apoMfgIYAX404b
+                p6OrZ05vBQJiDFmRAhsDBQsJCAcCAyICAQYVCgkICwIEFgIDAQIeBwIXgAAKCRBO
+                G6ejq2dOb4BkAQDoHczJaUH0LRgZUE+tkxhyqYY7kevX65vxe6vAqFci1gD/VvtA
+                lYrnQPqGG1GqRqy7cIsCfnI5lzAlL2Q2tYdO6A24OARiDFmREgorBgEEAZdVAQUB
+                AQdAZsFeMnJ7FGzNXf2SbGrVvYjgX397PaY6xAQoFWe4IQQDAQgHiHgEGBYKACAW
+                IQTdBv/mqaDH4CGAF+NOG6ejq2dObwUCYgxZkQIbDAAKCRBOG6ejq2dOb0t2AP9b
+                6Lv4BKjblZOhxJbsB9qvQbeyYunCLP07lSHBEhggNQEA+Luzhn3uitf4lyNbv6cS
+                9p2BPtxrLR4Ab20opteZ7w0=
+                =is5k
+                -----END PGP PUBLIC KEY BLOCK-----
+
+          - name: 'zuul-jobs-test-3'
+            key_id: '0FDF4D29F272F75A'
+            gpg_asc: |
+                -----BEGIN PGP PUBLIC KEY BLOCK-----
+
+                mDMEYgxZmxYJKwYBBAHaRw8BAQdAVUbvG30ucCb6ztQ/iuis7fX5FXssxSfbl/n8
+                vl/gyse0EHp1dWwtam9icy10ZXN0LTOIlAQTFgoAPBYhBHfjn1eq18PQIZ3qNA/f
+                TSnycvdaBQJiDFmbAhsDBQsJCAcCAyICAQYVCgkICwIEFgIDAQIeBwIXgAAKCRAP
+                300p8nL3WqgFAQCNhAEx7yC7UMV81IhiWMCDLK66eeHF16CiqPMabwlOEAD/V1cQ
+                NYCJekbq8GEcB3i36yIMPJokrPmXf6mkebs6vA24OARiDFmbEgorBgEEAZdVAQUB
+                AQdAPGKpDC3HpbRCJYkMzwY2NybY+/+G1beIjlDjpaxf/mIDAQgHiHgEGBYKACAW
+                IQR3459XqtfD0CGd6jQP300p8nL3WgUCYgxZmwIbDAAKCRAP300p8nL3WlQpAQC1
+                qwAAr63kwKKszAN+J32EGSaXp+dsR04367XacSJ3aQD/Tu6q45tF0t4G0dQIpzxT
+                jnHN/zEM7eyW45Jf/V8migI=
+                =CRYD
+                -----END PGP PUBLIC KEY BLOCK-----
+
+    - name: Encrypt file
+      include_role:
+        name: encrypt-file
+      vars:
+        encrypt_file: '{{ _tempfile.path }}'
+        encrypt_file_recipients:
+          - zuul-jobs-test-2
+          - zuul-jobs-test-3
+
+    - name: Remove temporary file
+      file:
+        path: '{{ _tempfile.path }}'
+        state: absent
+      when: _tempfile.path is defined
+
+    - name: Check output file
+      stat:
+        path: '{{ _tempfile.path }}.gpg'
+      register: _output
+
+    - name: Ensure exists
+      fail:
+        msg: 'Output file not found'
+      when: not _output.stat.exists
+
+    - name: Dump gpg packets
+      command: gpg --list-packets '{{ _tempfile.path }}.gpg'
+      register: _gpg_output
+      # Because it can't decrypt, gpg give an error.  But we're
+      # interested in the encryption packets so expect this.
+      failed_when: _gpg_output.rc != 2
+
+    - name: Show gpg command output
+      debug:
+        var: _gpg_output
+
+    - name: Validate packets
+      assert:
+        that:
+          - "'zuul-jobs-test-1' not in _gpg_output.stdout"
+          - "'zuul-jobs-test-2' in _gpg_output.stdout"
+          - "'zuul-jobs-test-3' in _gpg_output.stdout"
+
+    - name: Remove output file
+      file:
+        path: '{{ _tempfile.path }}.gpg'
+        state: absent
diff --git a/zuul-tests.d/general-roles-jobs.yaml b/zuul-tests.d/general-roles-jobs.yaml
index f72113f4c..a3358a4da 100644
--- a/zuul-tests.d/general-roles-jobs.yaml
+++ b/zuul-tests.d/general-roles-jobs.yaml
@@ -95,6 +95,14 @@
           A+tEIZhDiZh2NZoXXAKqV3pH6nOF9kPgRymy7de7BCoQx3rB7YgXpOk=
           -----END RSA PRIVATE KEY-----
 
+- job:
+    name: zuul-jobs-test-encrypt-file
+    description: |
+      Test encrypt-file role
+    files:
+      - roles/encrypt-file/.*
+    run: test-playbooks/encrypt-file.yaml
+
 - job:
     name: zuul-jobs-test-base-roles
     description: |
@@ -749,6 +757,7 @@
         - zuul-jobs-test-add-authorized-keys
         - zuul-jobs-test-add-gpgkey
         - zuul-jobs-test-add-sshkey
+        - zuul-jobs-test-encrypt-file
         - zuul-jobs-test-base-roles-centos-7
         - zuul-jobs-test-base-roles-centos-8-stream
         - zuul-jobs-test-base-roles-centos-9-stream