Skip to content

Commit 0fbbf8f

Browse files
authored
feat(docker-promote): promote OCI image built by CI workflow on release-please tag (#159)
1 parent fc54a88 commit 0fbbf8f

5 files changed

Lines changed: 374 additions & 6 deletions

File tree

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
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+
security-scan:
30+
description: "Enable Trivy Security Scan"
31+
type: boolean
32+
default: true
33+
trivy-version:
34+
description: "Trivy security scanner version"
35+
type: string
36+
default: "v0.71.0"
37+
secrets:
38+
username:
39+
description: "Registry username"
40+
required: true
41+
password:
42+
description: "Registry password (read and write access to the registry)"
43+
required: true
44+
45+
permissions:
46+
contents: read
47+
# required to look up the CI run that built the tagged commit
48+
actions: read
49+
50+
jobs:
51+
promote:
52+
name: Promote the tested image to the release tag
53+
runs-on: ubuntu-latest
54+
55+
# avoid shell injection through string interpolation
56+
env:
57+
CI_WORKFLOW: ${{ inputs.ci-workflow }}
58+
IMAGE: ${{ inputs.image-name }}
59+
REGISTRY: ${{ inputs.registry }}
60+
61+
steps:
62+
- name: Checkout
63+
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
64+
with:
65+
fetch-depth: 0
66+
67+
- name: Check the tag and the project version agree
68+
id: check
69+
env:
70+
TAG: ${{ github.ref_name }}
71+
run: |
72+
if [ -z "$(git branch -r --contains "$GITHUB_SHA" 'origin/main')" ]; then
73+
echo "❌ Tag $TAG is not on main. Release tags must be created on main."
74+
exit 1
75+
fi
76+
77+
# release-please records the released version of every component in
78+
# .release-please-manifest.json, committed in the same PR as the version bump,
79+
# so at the tagged commit it holds exactly the version being released.
80+
version="${TAG##*-v}"
81+
if [ "$version" = "$TAG" ]; then
82+
version="${TAG#v}"
83+
component="."
84+
else
85+
component="${TAG%-v$version}"
86+
fi
87+
88+
if [ ! -f ".release-please-manifest.json" ]; then
89+
echo "❌ No .release-please-manifest.json at the repository root."
90+
echo " The project has to be released by release-please in manifest mode."
91+
exit 1
92+
fi
93+
94+
declared=$(COMPONENT="$component" python3 - <<'PY'
95+
import json
96+
import os
97+
from pathlib import Path
98+
99+
component = os.environ["COMPONENT"]
100+
manifest = json.loads(Path(".release-please-manifest.json").read_text())
101+
if component != ".":
102+
print(next((v for path, v in manifest.items() if path == component or path.rsplit("/", 1)[-1] == component), ""))
103+
else:
104+
keys = list(manifest)
105+
print(manifest["."] if "." in manifest else manifest[keys[0]] if len(keys) == 1 else "")
106+
PY
107+
)
108+
109+
if [ -z "$declared" ]; then
110+
echo "❌ No version recorded for component '$component' in .release-please-manifest.json"
111+
exit 1
112+
fi
113+
114+
if [ "$version" != "$declared" ]; then
115+
echo "❌ Tag version ($version) does not match the version release-please recorded for '$component' ($declared)"
116+
exit 1
117+
fi
118+
119+
echo "✅ Tag $TAG matches $component version $declared"
120+
121+
echo "version=$version" | tee -a "$GITHUB_OUTPUT"
122+
123+
- name: Wait for the CI run that built this commit
124+
id: wait
125+
env:
126+
GH_TOKEN: ${{ github.token }}
127+
run: |
128+
# Both workflows start from the same push to main: release-please creates this
129+
# tag within a minute, while CI needs several more to build and push the image
130+
# being promoted. So the image is normally still missing when this job starts.
131+
for _ in $(seq 1 60); do
132+
run=$(gh run list --commit "$GITHUB_SHA" --workflow "$CI_WORKFLOW" --limit 1 \
133+
--json status,conclusion,databaseId \
134+
--jq '.[] | "\(.status) \(.conclusion) \(.databaseId)"' 2>/dev/null || true)
135+
status=${run%% *}
136+
rest=${run#* }
137+
conclusion=${rest%% *}
138+
run_id=${rest##* }
139+
140+
case "$status" in
141+
completed)
142+
if [ "$conclusion" = "success" ]; then
143+
echo "✅ CI succeeded for $GITHUB_SHA"
144+
echo "run_id=$run_id" >> "$GITHUB_OUTPUT"
145+
exit 0
146+
fi
147+
echo "❌ CI for $GITHUB_SHA concluded '$conclusion'; refusing to release"
148+
exit 1
149+
;;
150+
"")
151+
echo "No CI run found yet for $GITHUB_SHA, waiting…"
152+
;;
153+
*)
154+
echo "CI is $status, waiting…"
155+
;;
156+
esac
157+
sleep 30
158+
done
159+
160+
echo "❌ Timed out after 30 minutes waiting for the CI run on $GITHUB_SHA"
161+
exit 1
162+
163+
- name: Download the image tag CI built for this commit
164+
id: ci-tag
165+
continue-on-error: true
166+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
167+
with:
168+
name: docker-image-tag
169+
path: ci-image-tag
170+
run-id: ${{ steps.wait.outputs.run_id }}
171+
github-token: ${{ github.token }}
172+
173+
- name: Resolve the source tag to promote
174+
id: source-tag
175+
run: |
176+
if [ ! -f "ci-image-tag/image-tag.txt" ]; then
177+
echo "❌ CI run did not publish the docker-image-tag artifact; cannot determine the image to promote"
178+
echo " The CI workflow ($CI_WORKFLOW) must upload a 'docker-image-tag' artifact containing the tag it pushed"
179+
exit 1
180+
fi
181+
source_tag=$(cat "ci-image-tag/image-tag.txt")
182+
echo "✅ CI recorded image tag $source_tag"
183+
echo "source_tag=$source_tag" | tee -a "$GITHUB_OUTPUT"
184+
185+
- name: Login to Docker Registry
186+
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
187+
with:
188+
registry: ${{ inputs.registry }}
189+
username: ${{ secrets.username }}
190+
password: ${{ secrets.password }}
191+
192+
- name: Set up Docker Buildx
193+
# imagetools needs no builder, but this guarantees a buildx recent enough to support --prefer-index
194+
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
195+
196+
- name: Resolve the digest CI built for this commit
197+
id: tested
198+
env:
199+
SOURCE_TAG: ${{ steps.source-tag.outputs.source_tag }}
200+
run: |
201+
digest=$(docker buildx imagetools inspect "$IMAGE:$SOURCE_TAG" \
202+
--format '{{.Manifest.Digest}}')
203+
echo " $SOURCE_TAG -> $digest"
204+
echo "digest=$digest" >> "$GITHUB_OUTPUT"
205+
206+
# Scanning before the promotion, not after: the release tag would land on this
207+
# very digest, so a vulnerability found here must stop the release rather than be
208+
# reported against an image already tagged as released. The digest is unchanged by
209+
# the promotion, so this is a scan of exactly what gets released.
210+
- name: Re-scan the tested image for newly disclosed vulnerabilities
211+
if: ${{ inputs.security-scan }}
212+
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
213+
with:
214+
image-ref: ${{ env.IMAGE }}@${{ steps.tested.outputs.digest }}
215+
format: table
216+
ignore-unfixed: true
217+
vuln-type: 'os,library'
218+
severity: 'CRITICAL,HIGH'
219+
hide-progress: true
220+
exit-code: 1
221+
# same scanner version as the CI build, so a finding here means the
222+
# vulnerability database moved, not the tool
223+
version: ${{ inputs.trivy-version }}
224+
225+
- name: Promote the tested image to the release tag
226+
env:
227+
SOURCE_TAG: ${{ steps.source-tag.outputs.source_tag }}
228+
VERSION: ${{ steps.check.outputs.version }}
229+
DIGEST: ${{ steps.tested.outputs.digest }}
230+
run: |
231+
# --prefer-index=false copies the manifest verbatim. Without it buildx wraps a
232+
# single-source manifest in an OCI index, which gives the release tag a new
233+
# digest and defeats the point of promoting the exact image that was tested.
234+
docker buildx imagetools create --prefer-index=false \
235+
-t "$IMAGE:$VERSION" "$IMAGE:$SOURCE_TAG"
236+
237+
released=$(docker buildx imagetools inspect "$IMAGE:$VERSION" \
238+
--format '{{.Manifest.Digest}}')
239+
echo " $SOURCE_TAG -> $DIGEST"
240+
echo " $VERSION -> $released"
241+
242+
if [ "$released" != "$DIGEST" ]; then
243+
echo "❌ $IMAGE:$VERSION does not point at the tested image."
244+
echo " A buildx too old to honour --prefer-index wraps the manifest in an index."
245+
exit 1
246+
fi
247+
248+
echo "✅ $IMAGE:$VERSION is the image CI built and tested for $GITHUB_SHA"
249+
250+
- name: Summarise the release
251+
env:
252+
SOURCE_TAG: ${{ steps.source-tag.outputs.source_tag }}
253+
VERSION: ${{ steps.check.outputs.version }}
254+
DIGEST: ${{ steps.tested.outputs.digest }}
255+
run: |
256+
{
257+
echo "### 🚀 Released \`$IMAGE:$VERSION\`"
258+
echo ""
259+
echo "Promoted from \`$SOURCE_TAG\` without rebuilding."
260+
echo ""
261+
echo "<table><tbody>"
262+
echo "<tr><td><b>Commit</b></td><td><code>$GITHUB_SHA</code></td></tr>"
263+
echo "<tr><td><b>Source tag</b></td><td><code>$SOURCE_TAG</code></td></tr>"
264+
echo "<tr><td><b>Digest</b></td><td><code>$DIGEST</code></td></tr>"
265+
echo "</tbody></table>"
266+
} >> "$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: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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+
- Reuses the exact image tag CI recorded for the commit from the `docker-image-tag` artifact, and fails if the CI run did not publish one
17+
- Resolves the source image digest before promotion
18+
- Optionally re-scans the tested digest with Trivy
19+
- Copies the tested manifest to the release tag
20+
- Verifies that the release tag points at the tested digest
21+
22+
## Inputs
23+
24+
| Name | Description | Required | Default |
25+
| --- | --- | --- | --- |
26+
| `ci-workflow` | Workflow file used to find the CI run for the commit | No | `"ci.yaml"` |
27+
| `image-name` | Fully qualified OCI image name, without a tag | Yes | - |
28+
| `registry` | Registry hosting the image | No | `"docker.io"` |
29+
| `security-scan` | Enable the Trivy security scan before promotion | No | `true` |
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+
The source tag is not passed to this workflow: it is fetched from the `docker-image-tag` artifact that the CI run for the tagged commit uploaded (published by `docker-build` when `push: true`).
42+
43+
```yaml
44+
name: Release Docker Image
45+
46+
on:
47+
push:
48+
tags:
49+
- "v*.*.*"
50+
- "*-v*.*.*"
51+
52+
jobs:
53+
promote:
54+
# ⚠️ use tagged version here
55+
uses: iExecBlockchainComputing/github-actions-workflows/.github/workflows/docker-promote.yml@main
56+
with:
57+
image-name: docker-regis.iex.ec/my-service
58+
registry: docker-regis.iex.ec
59+
security-scan: false
60+
secrets:
61+
username: ${{ secrets.NEXUS_USERNAME }}
62+
password: ${{ secrets.NEXUS_PASSWORD }}
63+
```
64+
65+
The flow:
66+
67+
1. **CI workflow** — builds the image, tags it (e.g. via a `prepare` job), pushes it, and uploads the applied tag as the `docker-image-tag` artifact.
68+
2. **Release workflow** — this one. On `vX.Y.Z` (or `component-vX.Y.Z`) it waits for that CI run, downloads the artifact to learn the exact tag CI pushed, and promotes that digest to the release tag.
69+
70+
## Notes
71+
72+
- The release tag must point at a commit that is contained in `main`; the workflow refuses to release a tag created elsewhere.
73+
- 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.
74+
- 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.
75+
- A tag without a component is matched against the `.` package, or against the single entry of the manifest.
76+
- 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`.
77+
- 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.
78+
- The source tag is the tag `docker-build` recorded in the `docker-image-tag` artifact of the CI run for the commit, so any CI tag scheme (`dev-<sha>`, `feature-<sha>`, `cid.<sha>.main`, …) is supported. Promotion fails with a clear error if the CI run did not publish the artifact.
79+
- Set `security-scan: false` to skip the promotion-time Trivy scan. The CI build should still perform its configured scan.
80+
- 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.
81+
- The registry credentials need read and write access because promotion creates the release tag.
82+
83+
## Troubleshooting
84+
85+
- **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.
86+
- **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.
87+
- **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`.
88+
- **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.
89+
- **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.
90+
- **CI did not publish the docker-image-tag artifact** — the CI workflow named by `ci-workflow` must build the image with a `docker-build` version that records and uploads the `docker-image-tag` artifact (`push: true`); promotion cannot infer the tag otherwise.
91+
- **Source image or digest cannot be resolved** — confirm that CI pushed `<image-name>:<source-tag>` to `registry`.
92+
- **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.
93+
- **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+
a85a46fd1ad50e26807800e0ede8109b3e034abd885117e8a844748d64347f1e

0 commit comments

Comments
 (0)