diff --git a/.zuul.yaml b/.zuul.yaml
index 7fc899a676..061bf8c5bf 100644
--- a/.zuul.yaml
+++ b/.zuul.yaml
@@ -70,23 +70,49 @@
          ``password``.
 
     abstract: true
-    pre-run: playbooks/zuul/build-image-pre.yaml
-    run: playbooks/zuul/build-image.yaml
-    post-run: playbooks/zuul/build-image-upload.yaml
+    pre-run: playbooks/zuul/build-image/pre.yaml
+    run: playbooks/zuul/build-image/run.yaml
+    post-run: playbooks/zuul/build-image/upload.yaml
+
+- job:
+    name: system-config-promote-image
+    description: |
+      Retag a previously-uploaded docker image.
+
+      .. zuul:jobvar:: images
+
+         .. zuul:jobvar:: repository
+
+            The name of the target repository in dockerhub for the
+            image.
+
+         .. zuul:jobvar:: tags
+
+            A list of tags to be added to the image.  Default: ['latest'].
+
+      .. zuul:jobvar:: credentials
+
+         This should be a secret with two keys: ``username`` and
+         ``password``.
+
+    abstract: true
+    run: playbooks/zuul/build-image/promote.yaml
+    nodeset:
+      nodes: []
 
 - job:
     name: system-config-build-image-gitea
     description: Build a gitea image
     parent: system-config-build-image
     vars:
-      images:
+      images: &gitea_images
         - context: docker/gitea
           target: gitea
           repository: opendevorg/gitea
         - context: docker/gitea
           target: gitea-openssh
           repository: opendevorg/gitea-openssh
-    files:
+    files: &gitea_files
       - docker/gitea/.*
 
 - job:
@@ -97,16 +123,27 @@
       name: credentials
       secret: system-config-dockerhub
 
+- job:
+    name: system-config-promote-image-gitea
+    description: Promote a previously published gitea image to latest
+    parent: system-config-promote-image
+    secrets:
+      name: credentials
+      secret: system-config-dockerhub
+    vars:
+      images: *gitea_images
+    files: *gitea_files
+
 - job:
     name: system-config-build-image-jinja-init
     description: Build a jinja-init image
     parent: system-config-build-image
     vars:
-      images:
+      images: &jinja-init_images
         - context: docker/jinja-init
           target: jinja-init
           repository: opendevorg/jinja-init
-    files:
+    files: &jinja-init_files
       - docker/jinja-init/.*
 
 - job:
@@ -117,6 +154,17 @@
       name: credentials
       secret: system-config-dockerhub
 
+- job:
+    name: system-config-promote-image-jinja-init
+    description: Promote a previously published jinja-init image to latest
+    parent: system-config-promote-image
+    secrets:
+      name: credentials
+      secret: system-config-dockerhub
+    vars:
+      images: *jinja-init_images
+    files: *jinja-init_files
+
 # Role integration jobs.  These test the top-level generic roles/*
 # under Zuul.  The range of platforms should be the same as those for
 # openstack-zuul-jobs.
@@ -413,9 +461,9 @@
         - system-config-run-eavesdrop
         - system-config-run-nodepool
         - system-config-run-docker
-        - system-config-build-image-gitea
-        - system-config-build-image-jinja-init
-    post:
-      jobs:
         - system-config-upload-image-gitea
         - system-config-upload-image-jinja-init
+    promote:
+      jobs:
+        - system-config-promote-image-gitea
+        - system-config-promote-image-jinja-init
diff --git a/docker/gitea/Dockerfile b/docker/gitea/Dockerfile
index d85f8ae9f9..b3782d7d83 100644
--- a/docker/gitea/Dockerfile
+++ b/docker/gitea/Dockerfile
@@ -112,3 +112,4 @@ EXPOSE 22
 VOLUME ["/data"]
 ENTRYPOINT ["/usr/bin/entrypoint"]
 CMD ["/usr/sbin/sshd", "-D"]
+# this comment is here to perform a test run of the job.
diff --git a/playbooks/zuul/build-image-pre.yaml b/playbooks/zuul/build-image/pre.yaml
similarity index 100%
rename from playbooks/zuul/build-image-pre.yaml
rename to playbooks/zuul/build-image/pre.yaml
diff --git a/playbooks/zuul/build-image/promote-retag.yaml b/playbooks/zuul/build-image/promote-retag.yaml
new file mode 100644
index 0000000000..132e75a04e
--- /dev/null
+++ b/playbooks/zuul/build-image/promote-retag.yaml
@@ -0,0 +1,31 @@
+- name: Get dockerhub token
+  no_log: true
+  uri:
+    url: "https://auth.docker.io/token?service=registry.docker.io&scope=repository:{{image.repository}}:pull,push"
+    user: "{{ credentials.username }}"
+    password: "{{ credentials.password }}"
+    force_basic_auth: true
+  register: token
+- name: Get manifest
+  no_log: true
+  uri:
+    url: "https://registry.hub.docker.com/v2/{{image.repository}}/manifests/change_{{zuul.change}}"
+    status_code: 200
+    headers:
+      Accept: "application/vnd.docker.distribution.manifestv2+json"
+      Authorization: "Bearer {{ token.json.token }}"
+    return_content: true
+  register: manifest
+- name: "Put manifest"
+  no_log: true
+  loop: "{{ image.tags | default(['latest']) }}"
+  loop_control:
+    loop_var: new_tag
+  uri:
+    url: "https://registry.hub.docker.com/v2/{{image.repository}}/manifests/{{ new_tag }}"
+    method: PUT
+    status_code: 201
+    body: "{{ manifest.content | string }}"
+    headers:
+      Content-Type: "application/vnd.docker.distribution.manifestv2+json"
+      Authorization: "Bearer {{ token.json.token }}"
diff --git a/playbooks/zuul/build-image/promote.yaml b/playbooks/zuul/build-image/promote.yaml
new file mode 100644
index 0000000000..6bfdd3ad79
--- /dev/null
+++ b/playbooks/zuul/build-image/promote.yaml
@@ -0,0 +1,10 @@
+- hosts: all
+  tasks:
+    - name: Promote dockerhub image
+      when: credentials is defined
+      block:
+        - name: Promote image
+          loop: "{{ images }}"
+          loop_control:
+            loop_var: image
+          include_tasks: promote-retag.yaml
diff --git a/playbooks/zuul/build-image.yaml b/playbooks/zuul/build-image/run.yaml
similarity index 87%
rename from playbooks/zuul/build-image.yaml
rename to playbooks/zuul/build-image/run.yaml
index af41c493a5..afe1b1e2d8 100644
--- a/playbooks/zuul/build-image.yaml
+++ b/playbooks/zuul/build-image/run.yaml
@@ -1,7 +1,7 @@
 - hosts: all
   tasks:
     - name: Build a docker image
-      command: "docker build . {{ target | default(false) | ternary('--target ', '') }}{{ target | default('') }} --tag {{ item.repository }}"
+      command: "docker build . {{ target | default(false) | ternary('--target ', '') }}{{ target | default('') }} --tag {{ item.repository }}:change_{{ zuul.change }}"
       args:
         chdir: "{{ zuul.project.src_dir }}/{{ item.context }}"
       loop: "{{ images }}"
diff --git a/playbooks/zuul/build-image-upload.yaml b/playbooks/zuul/build-image/upload.yaml
similarity index 72%
rename from playbooks/zuul/build-image-upload.yaml
rename to playbooks/zuul/build-image/upload.yaml
index 3e51cacfd6..155ff801b5 100644
--- a/playbooks/zuul/build-image-upload.yaml
+++ b/playbooks/zuul/build-image/upload.yaml
@@ -7,5 +7,5 @@
           command: "docker login -u {{ credentials.username }} -p {{ credentials.password }}"
           no_log: true
         - name: Upload to dockerhub
-          command: "docker push {{ item.repository }}"
-          loop: "{{ images }}"
\ No newline at end of file
+          command: "docker push {{ item.repository }}:change_{{ zuul.change }}"
+          loop: "{{ images }}"