Skip to content

Commit 1ed795a

Browse files
committed
feat(docker-promote): extract release-image promotion into a reusable workflow
1 parent 530a5a5 commit 1ed795a

5 files changed

Lines changed: 348 additions & 6 deletions

File tree

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
name: Promote Tested Image to Release Tag
2+
3+
# Promoting does not rebuild anything. The image CI already built, tested and scanned
4+
# for this commit is promoted to the release tag by copying its manifest, so the
5+
# promoted artifact is byte for byte the one that passed the pipeline. A rebuild would
6+
# not be equivalent: the Dockerfile runs `apt-get upgrade`, so the same source produces
7+
# a different image whenever Ubuntu has moved on.
8+
#
9+
# The caller is expected to trigger this workflow from a push of a `vX.Y.Z` tag, or of
10+
# a component tag such as `component-vX.Y.Z` when release-please is configured to include
11+
# the component name, and to pass the registry credentials. The tag is validated against
12+
# the version release-please recorded for that component in .release-please-manifest.json.
13+
14+
on:
15+
workflow_call:
16+
inputs:
17+
ci-workflow:
18+
description: "Workflow file of the CI that built and pushed the tested image (used to look up its run for the commit)"
19+
type: string
20+
default: "ci.yaml"
21+
image-name:
22+
description: "OCI image name (registry/repository) to promote, e.g. docker-regis.iex.ec/iexec-result-proxy"
23+
type: string
24+
required: true
25+
registry:
26+
description: "Registry the image lives in"
27+
type: string
28+
default: "docker.io"
29+
source-tag-prefix:
30+
description: "Prefix of the tag the CI tags production images with, before the short commit sha (e.g. `dev-`)"
31+
type: string
32+
default: "dev-"
33+
security-scan:
34+
description: "Enable Trivy Security Scan"
35+
type: boolean
36+
default: true
37+
trivy-version:
38+
description: "Trivy security scanner version"
39+
type: string
40+
default: "v0.71.0"
41+
secrets:
42+
username:
43+
description: "Registry username"
44+
required: true
45+
password:
46+
description: "Registry password (read and write access to the registry)"
47+
required: true
48+
49+
permissions:
50+
contents: read
51+
# required to look up the CI run that built the tagged commit
52+
actions: read
53+
54+
jobs:
55+
promote:
56+
name: Promote the tested image to the release tag
57+
runs-on: ubuntu-latest
58+
59+
# avoid shell injection through string interpolation
60+
env:
61+
CI_WORKFLOW: ${{ inputs.ci-workflow }}
62+
IMAGE: ${{ inputs.image-name }}
63+
REGISTRY: ${{ inputs.registry }}
64+
SOURCE_TAG_PREFIX: ${{ inputs.source-tag-prefix }}
65+
66+
steps:
67+
- name: Checkout
68+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
69+
with:
70+
fetch-depth: 0
71+
72+
- name: Check the tag and the project version agree
73+
id: check
74+
env:
75+
TAG: ${{ github.ref_name }}
76+
run: |
77+
if [ -z "$(git branch -r --contains "$GITHUB_SHA" 'origin/main')" ]; then
78+
echo "❌ Tag $TAG is not on main. Release tags must be created on main."
79+
exit 1
80+
fi
81+
82+
# release-please records the released version of every component in
83+
# .release-please-manifest.json, committed in the same PR as the version bump,
84+
# so at the tagged commit it holds exactly the version being released.
85+
version="${TAG##*-v}"
86+
if [ "$version" = "$TAG" ]; then
87+
version="${TAG#v}"
88+
component="."
89+
else
90+
component="${TAG%-v$version}"
91+
fi
92+
93+
if [ ! -f ".release-please-manifest.json" ]; then
94+
echo "❌ No .release-please-manifest.json at the repository root."
95+
echo " The project has to be released by release-please in manifest mode."
96+
exit 1
97+
fi
98+
99+
declared=$(COMPONENT="$component" python3 - <<'PY'
100+
import json
101+
import os
102+
from pathlib import Path
103+
104+
component = os.environ["COMPONENT"]
105+
manifest = json.loads(Path(".release-please-manifest.json").read_text())
106+
if component != ".":
107+
print(next((v for path, v in manifest.items() if path == component or path.rsplit("/", 1)[-1] == component), ""))
108+
else:
109+
keys = list(manifest)
110+
print(manifest["."] if "." in manifest else manifest[keys[0]] if len(keys) == 1 else "")
111+
PY
112+
)
113+
114+
if [ -z "$declared" ]; then
115+
echo "❌ No version recorded for component '$component' in .release-please-manifest.json"
116+
exit 1
117+
fi
118+
119+
if [ "$version" != "$declared" ]; then
120+
echo "❌ Tag version ($version) does not match the version release-please recorded for '$component' ($declared)"
121+
exit 1
122+
fi
123+
124+
echo "✅ Tag $TAG matches $component version $declared"
125+
126+
# The CI tags images built from a production branch as
127+
# <source-tag-prefix><short sha>, and the release tag points at that commit.
128+
echo "version=$version" | tee -a "$GITHUB_OUTPUT"
129+
echo "source_tag=${SOURCE_TAG_PREFIX}$(echo "$GITHUB_SHA" | cut -c1-8)" | tee -a "$GITHUB_OUTPUT"
130+
131+
- name: Wait for the CI run that built this commit
132+
env:
133+
GH_TOKEN: ${{ github.token }}
134+
run: |
135+
# Both workflows start from the same push to main: release-please creates this
136+
# tag within a minute, while CI needs several more to build and push the image
137+
# being promoted. So the image is normally still missing when this job starts.
138+
for _ in $(seq 1 60); do
139+
run=$(gh run list --commit "$GITHUB_SHA" --workflow "$CI_WORKFLOW" --limit 1 \
140+
--json status,conclusion \
141+
--jq '.[] | "\(.status) \(.conclusion)"' 2>/dev/null || true)
142+
status=${run%% *}
143+
conclusion=${run##* }
144+
145+
case "$status" in
146+
completed)
147+
if [ "$conclusion" = "success" ]; then
148+
echo "✅ CI succeeded for $GITHUB_SHA"
149+
exit 0
150+
fi
151+
echo "❌ CI for $GITHUB_SHA concluded '$conclusion'; refusing to release"
152+
exit 1
153+
;;
154+
"")
155+
echo "No CI run found yet for $GITHUB_SHA, waiting…"
156+
;;
157+
*)
158+
echo "CI is $status, waiting…"
159+
;;
160+
esac
161+
sleep 30
162+
done
163+
164+
echo "❌ Timed out after 30 minutes waiting for the CI run on $GITHUB_SHA"
165+
exit 1
166+
167+
- name: Login to Docker Registry
168+
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
169+
with:
170+
registry: ${{ inputs.registry }}
171+
username: ${{ secrets.username }}
172+
password: ${{ secrets.password }}
173+
174+
- name: Set up Docker Buildx
175+
# imagetools needs no builder, but this guarantees a buildx recent enough to support --prefer-index
176+
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
177+
178+
- name: Resolve the digest CI built for this commit
179+
id: tested
180+
env:
181+
SOURCE_TAG: ${{ steps.check.outputs.source_tag }}
182+
run: |
183+
digest=$(docker buildx imagetools inspect "$IMAGE:$SOURCE_TAG" \
184+
--format '{{.Manifest.Digest}}')
185+
echo " $SOURCE_TAG -> $digest"
186+
echo "digest=$digest" >> "$GITHUB_OUTPUT"
187+
188+
# Scanning before the promotion, not after: the release tag would land on this
189+
# very digest, so a vulnerability found here must stop the release rather than be
190+
# reported against an image already tagged as released. The digest is unchanged by
191+
# the promotion, so this is a scan of exactly what gets released.
192+
- name: Re-scan the tested image for newly disclosed vulnerabilities
193+
if: ${{ inputs.security-scan }}
194+
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
195+
with:
196+
image-ref: ${{ env.IMAGE }}@${{ steps.tested.outputs.digest }}
197+
format: table
198+
ignore-unfixed: true
199+
vuln-type: 'os,library'
200+
severity: 'CRITICAL,HIGH'
201+
hide-progress: true
202+
exit-code: 1
203+
# same scanner version as the CI build, so a finding here means the
204+
# vulnerability database moved, not the tool
205+
version: ${{ inputs.trivy-version }}
206+
207+
- name: Promote the tested image to the release tag
208+
env:
209+
SOURCE_TAG: ${{ steps.check.outputs.source_tag }}
210+
VERSION: ${{ steps.check.outputs.version }}
211+
DIGEST: ${{ steps.tested.outputs.digest }}
212+
run: |
213+
# --prefer-index=false copies the manifest verbatim. Without it buildx wraps a
214+
# single-source manifest in an OCI index, which gives the release tag a new
215+
# digest and defeats the point of promoting the exact image that was tested.
216+
docker buildx imagetools create --prefer-index=false \
217+
-t "$IMAGE:$VERSION" "$IMAGE:$SOURCE_TAG"
218+
219+
released=$(docker buildx imagetools inspect "$IMAGE:$VERSION" \
220+
--format '{{.Manifest.Digest}}')
221+
echo " $SOURCE_TAG -> $DIGEST"
222+
echo " $VERSION -> $released"
223+
224+
if [ "$released" != "$DIGEST" ]; then
225+
echo "❌ $IMAGE:$VERSION does not point at the tested image."
226+
echo " A buildx too old to honour --prefer-index wraps the manifest in an index."
227+
exit 1
228+
fi
229+
230+
echo "✅ $IMAGE:$VERSION is the image CI built and tested for $GITHUB_SHA"
231+
232+
- name: Summarise the release
233+
env:
234+
SOURCE_TAG: ${{ steps.check.outputs.source_tag }}
235+
VERSION: ${{ steps.check.outputs.version }}
236+
DIGEST: ${{ steps.tested.outputs.digest }}
237+
run: |
238+
{
239+
echo "### Released \`$IMAGE:$VERSION\`"
240+
echo ""
241+
echo "Promoted from \`$SOURCE_TAG\` without rebuilding."
242+
echo ""
243+
echo "| | |"
244+
echo "| --- | --- |"
245+
echo "| Commit | \`$GITHUB_SHA\` |"
246+
echo "| Digest | \`$DIGEST\` |"
247+
} >> "$GITHUB_STEP_SUMMARY"

‎README.md‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,22 @@ This repository contains a comprehensive collection of reusable GitHub Actions w
44

55
## 📋 Available Workflows
66

7-
### 🐳 [Build Docker Image](./docker-build)
7+
### 📝 [Conventional Commits](./conventional-commits)
88

9-
Automates the process of building, tagging, and pushing Docker images to Docker Hub. Perfect for projects that require containerization with minimal configuration overhead.
9+
Validates that pull request titles follow the [Conventional Commits](https://www.conventionalcommits.org/) specification for better repository management. Ensures your commit history remains clean and meaningful for improved collaboration.
1010

1111
### ☁️ [Build Docker Image via Docker Build Cloud](./docker-build-cloud)
1212

1313
Builds and pushes a multi-platform Docker image (e.g. `linux/amd64` + `linux/arm64`) to Docker Hub in a single job using Docker Build Cloud's remote builders. No QEMU emulation, no native ARM runners.
1414

15+
### 🐳 [Build Docker Image](./docker-build)
16+
17+
Automates the process of building, tagging, and pushing Docker images to Docker Hub. Perfect for projects that require containerization with minimal configuration overhead.
18+
19+
### [Promote Docker Image](./docker-promote)
20+
21+
Promotes the exact image digest built and tested by CI to a release tag without rebuilding it, with an optional Trivy security scan before promotion.
22+
1523
### 📦 [Release Please](./release-please)
1624

1725
Uses the [release-please-action](https://github.com/googleapis/release-please-action) to automate versioning and changelog generation based on Conventional Commits. This workflow streamlines your release process and ensures consistent version management.
@@ -20,10 +28,6 @@ Uses the [release-please-action](https://github.com/googleapis/release-please-ac
2028

2129
Automates the process of publishing NPM packages to the NPM registry with highly configurable options. Simplifies the package publishing workflow while maintaining security and reliability.
2230

23-
### 📝 [Conventional Commits](./conventional-commits)
24-
25-
Validates that pull request titles follow the [Conventional Commits](https://www.conventionalcommits.org/) specification for better repository management. Ensures your commit history remains clean and meaningful for improved collaboration.
26-
2731
### ☕ [Java Build](./java-build)
2832

2933
Builds, tests and analyses a Gradle-based Java project. Runs the unit tests and a SonarCloud analysis, and optionally hands the built jar to `docker-build` so that the artifact shipped in the image is exactly the one that was tested.

‎docker-promote/README.md‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# Docker Promote Workflow
2+
3+
## Overview
4+
5+
This reusable GitHub Actions workflow promotes an image that was already built, tested, and scanned by CI to a release tag. It copies the tested manifest by digest instead of rebuilding the image, so the released artifact is the same artifact that passed the pipeline.
6+
7+
The calling workflow must trigger promotion from a release tag such as `vX.Y.Z`, or `component-vX.Y.Z` when release-please includes the component name in the tag.
8+
9+
> [!IMPORTANT]
10+
> Promotion never rebuilds the image. CI must have built and pushed the source tag for the tagged commit before this workflow can promote it.
11+
12+
## Features
13+
14+
- Validates that the release tag matches the version release-please recorded for the component
15+
- Waits for the CI workflow run for the tagged commit to complete successfully
16+
- Resolves the source image digest before promotion
17+
- Optionally re-scans the tested digest with Trivy
18+
- Copies the tested manifest to the release tag
19+
- Verifies that the release tag points at the tested digest
20+
21+
## Inputs
22+
23+
| Name | Description | Required | Default |
24+
| --- | --- | --- | --- |
25+
| `ci-workflow` | Workflow file used to find the CI run for the commit | No | `"ci.yaml"` |
26+
| `image-name` | Fully qualified OCI image name, without a tag | Yes | - |
27+
| `registry` | Registry hosting the image | No | `"docker.io"` |
28+
| `security-scan` | Enable the Trivy security scan before promotion | No | `true` |
29+
| `source-tag-prefix` | Prefix used by CI before the short commit SHA | No | `"dev-"` |
30+
| `trivy-version` | Trivy security scanner version | No | `"v0.71.0"` |
31+
32+
## Secrets
33+
34+
| Name | Description | Required |
35+
| --- | --- | --- |
36+
| `username` | Registry username | Yes |
37+
| `password` | Registry password with read and write access | Yes |
38+
39+
## Example Usage
40+
41+
```yaml
42+
name: Release Docker Image
43+
44+
on:
45+
push:
46+
tags:
47+
- "v*.*.*"
48+
- "*-v*.*.*"
49+
50+
jobs:
51+
promote:
52+
# ⚠️ use tagged version here
53+
uses: iExecBlockchainComputing/github-actions-workflows/.github/workflows/docker-promote.yml@main
54+
with:
55+
image-name: docker-regis.iex.ec/my-service
56+
registry: docker-regis.iex.ec
57+
source-tag-prefix: dev-
58+
security-scan: false
59+
secrets:
60+
username: ${{ secrets.NEXUS_USERNAME }}
61+
password: ${{ secrets.NEXUS_PASSWORD }}
62+
```
63+
64+
## Notes
65+
66+
- The release tag must point at a commit that is contained in `main`; the workflow refuses to release a tag created elsewhere.
67+
- The tag version is compared with the version release-please recorded for the component, so the check works for any release type (`simple`, `gradle`, `rust`, `node`, …) without reading project files.
68+
- Both `vX.Y.Z` and the release-please component form `<component>-vX.Y.Z` are accepted; everything up to the last `-v` is treated as the component name.
69+
- A tag without a component is matched against the `.` package, or against the single entry of the manifest.
70+
- A component is matched against a manifest key by full path or by its last segment, so `post-compute` resolves the key `post-compute` and a workspace path such as `crates/post-compute`.
71+
- The project must be released by release-please in manifest mode; `.release-please-manifest.json` is committed in the same PR as the version bump, so at the tagged commit it holds the version being released.
72+
- The source tag is formed as `<source-tag-prefix><short-commit-sha>`, for example `dev-1a2b3c4d`.
73+
- Set `security-scan: false` to skip the promotion-time Trivy scan. The CI build should still perform its configured scan.
74+
- Keep `trivy-version` in sync with the version used by `docker-build` so a promotion finding reflects a vulnerability database update rather than a scanner change.
75+
- The registry credentials need read and write access because promotion creates the release tag.
76+
77+
## Troubleshooting
78+
79+
- **Tag is not on main** — create the release tag from a commit reachable from `main`, or fix the tag that was created from another branch.
80+
- **No .release-please-manifest.json** — the project is not released by release-please in manifest mode; configure release-please with a `packages` map so it maintains the manifest.
81+
- **No version recorded for the component** — the tag prefix is not a package in `.release-please-manifest.json`; check the `component` names in `release-please-config.json`.
82+
- **Tag version does not match the recorded version** — the tag is expected to be `v` or `<component>-v` followed by the version release-please released for that component.
83+
- **Timed out waiting for the CI run** — confirm that `ci-workflow` names the workflow that builds the image and that it has started for the tagged commit.
84+
- **Source image or digest cannot be resolved** — confirm that CI pushed `<image-name>:<source-tag-prefix><short-commit-sha>` to `registry`.
85+
- **Release tag does not point at the tested image** — the registry or buildx version may not support manifest promotion; keep the buildx setup step up to date.
86+
- **Trivy reports CRITICAL or HIGH vulnerabilities** — the promotion is stopped before the release tag is created; update the image and rerun the release.

‎docker-promote/workflow-sha256‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
52180f32c4fc50c4c53b09128a7380cda91d1041f26182c169262862a824fb40

‎release-please-config.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
"component": "docker-build-cloud",
2222
"extra-label": "docker-build-cloud"
2323
},
24+
"docker-promote": {
25+
"component": "docker-promote",
26+
"extra-label": "docker-promote"
27+
},
2428
"java-build": {
2529
"component": "java-build",
2630
"extra-label": "java-build"

0 commit comments

Comments
 (0)