diff --git a/.github/workflows/docker-promote.yml b/.github/workflows/docker-promote.yml
new file mode 100644
index 0000000..819e739
--- /dev/null
+++ b/.github/workflows/docker-promote.yml
@@ -0,0 +1,266 @@
+name: Promote Tested Image to Release Tag
+
+# Promoting does not rebuild anything. The image CI already built, tested and scanned
+# for this commit is promoted to the release tag by copying its manifest, so the
+# promoted artifact is byte for byte the one that passed the pipeline. A rebuild would
+# not be equivalent: the Dockerfile runs `apt-get upgrade`, so the same source produces
+# a different image whenever Ubuntu has moved on.
+#
+# The caller is expected to trigger this workflow from a push of a `vX.Y.Z` tag, or of
+# a component tag such as `component-vX.Y.Z` when release-please is configured to include
+# the component name, and to pass the registry credentials. The tag is validated against
+# the version release-please recorded for that component in .release-please-manifest.json.
+
+on:
+ workflow_call:
+ inputs:
+ ci-workflow:
+ description: "Workflow file of the CI that built and pushed the tested image (used to look up its run for the commit)"
+ type: string
+ default: "ci.yaml"
+ image-name:
+ description: "OCI image name (registry/repository) to promote, e.g. docker-regis.iex.ec/iexec-result-proxy"
+ type: string
+ required: true
+ registry:
+ description: "Registry the image lives in"
+ type: string
+ default: "docker.io"
+ security-scan:
+ description: "Enable Trivy Security Scan"
+ type: boolean
+ default: true
+ trivy-version:
+ description: "Trivy security scanner version"
+ type: string
+ default: "v0.71.0"
+ secrets:
+ username:
+ description: "Registry username"
+ required: true
+ password:
+ description: "Registry password (read and write access to the registry)"
+ required: true
+
+permissions:
+ contents: read
+ # required to look up the CI run that built the tagged commit
+ actions: read
+
+jobs:
+ promote:
+ name: Promote the tested image to the release tag
+ runs-on: ubuntu-latest
+
+ # avoid shell injection through string interpolation
+ env:
+ CI_WORKFLOW: ${{ inputs.ci-workflow }}
+ IMAGE: ${{ inputs.image-name }}
+ REGISTRY: ${{ inputs.registry }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+
+ - name: Check the tag and the project version agree
+ id: check
+ env:
+ TAG: ${{ github.ref_name }}
+ run: |
+ if [ -z "$(git branch -r --contains "$GITHUB_SHA" 'origin/main')" ]; then
+ echo "❌ Tag $TAG is not on main. Release tags must be created on main."
+ exit 1
+ fi
+
+ # release-please records the released version of every component in
+ # .release-please-manifest.json, committed in the same PR as the version bump,
+ # so at the tagged commit it holds exactly the version being released.
+ version="${TAG##*-v}"
+ if [ "$version" = "$TAG" ]; then
+ version="${TAG#v}"
+ component="."
+ else
+ component="${TAG%-v$version}"
+ fi
+
+ if [ ! -f ".release-please-manifest.json" ]; then
+ echo "❌ No .release-please-manifest.json at the repository root."
+ echo " The project has to be released by release-please in manifest mode."
+ exit 1
+ fi
+
+ declared=$(COMPONENT="$component" python3 - <<'PY'
+ import json
+ import os
+ from pathlib import Path
+
+ component = os.environ["COMPONENT"]
+ manifest = json.loads(Path(".release-please-manifest.json").read_text())
+ if component != ".":
+ print(next((v for path, v in manifest.items() if path == component or path.rsplit("/", 1)[-1] == component), ""))
+ else:
+ keys = list(manifest)
+ print(manifest["."] if "." in manifest else manifest[keys[0]] if len(keys) == 1 else "")
+ PY
+ )
+
+ if [ -z "$declared" ]; then
+ echo "❌ No version recorded for component '$component' in .release-please-manifest.json"
+ exit 1
+ fi
+
+ if [ "$version" != "$declared" ]; then
+ echo "❌ Tag version ($version) does not match the version release-please recorded for '$component' ($declared)"
+ exit 1
+ fi
+
+ echo "✅ Tag $TAG matches $component version $declared"
+
+ echo "version=$version" | tee -a "$GITHUB_OUTPUT"
+
+ - name: Wait for the CI run that built this commit
+ id: wait
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ # Both workflows start from the same push to main: release-please creates this
+ # tag within a minute, while CI needs several more to build and push the image
+ # being promoted. So the image is normally still missing when this job starts.
+ for _ in $(seq 1 60); do
+ run=$(gh run list --commit "$GITHUB_SHA" --workflow "$CI_WORKFLOW" --limit 1 \
+ --json status,conclusion,databaseId \
+ --jq '.[] | "\(.status) \(.conclusion) \(.databaseId)"' 2>/dev/null || true)
+ status=${run%% *}
+ rest=${run#* }
+ conclusion=${rest%% *}
+ run_id=${rest##* }
+
+ case "$status" in
+ completed)
+ if [ "$conclusion" = "success" ]; then
+ echo "✅ CI succeeded for $GITHUB_SHA"
+ echo "run_id=$run_id" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo "❌ CI for $GITHUB_SHA concluded '$conclusion'; refusing to release"
+ exit 1
+ ;;
+ "")
+ echo "No CI run found yet for $GITHUB_SHA, waiting…"
+ ;;
+ *)
+ echo "CI is $status, waiting…"
+ ;;
+ esac
+ sleep 30
+ done
+
+ echo "❌ Timed out after 30 minutes waiting for the CI run on $GITHUB_SHA"
+ exit 1
+
+ - name: Download the image tag CI built for this commit
+ id: ci-tag
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: docker-image-tag
+ path: ci-image-tag
+ run-id: ${{ steps.wait.outputs.run_id }}
+ github-token: ${{ github.token }}
+
+ - name: Resolve the source tag to promote
+ id: source-tag
+ run: |
+ if [ ! -f "ci-image-tag/image-tag.txt" ]; then
+ echo "❌ CI run did not publish the docker-image-tag artifact; cannot determine the image to promote"
+ echo " The CI workflow ($CI_WORKFLOW) must upload a 'docker-image-tag' artifact containing the tag it pushed"
+ exit 1
+ fi
+ source_tag=$(cat "ci-image-tag/image-tag.txt")
+ echo "✅ CI recorded image tag $source_tag"
+ echo "source_tag=$source_tag" | tee -a "$GITHUB_OUTPUT"
+
+ - name: Login to Docker Registry
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
+ with:
+ registry: ${{ inputs.registry }}
+ username: ${{ secrets.username }}
+ password: ${{ secrets.password }}
+
+ - name: Set up Docker Buildx
+ # imagetools needs no builder, but this guarantees a buildx recent enough to support --prefer-index
+ uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
+
+ - name: Resolve the digest CI built for this commit
+ id: tested
+ env:
+ SOURCE_TAG: ${{ steps.source-tag.outputs.source_tag }}
+ run: |
+ digest=$(docker buildx imagetools inspect "$IMAGE:$SOURCE_TAG" \
+ --format '{{.Manifest.Digest}}')
+ echo " $SOURCE_TAG -> $digest"
+ echo "digest=$digest" >> "$GITHUB_OUTPUT"
+
+ # Scanning before the promotion, not after: the release tag would land on this
+ # very digest, so a vulnerability found here must stop the release rather than be
+ # reported against an image already tagged as released. The digest is unchanged by
+ # the promotion, so this is a scan of exactly what gets released.
+ - name: Re-scan the tested image for newly disclosed vulnerabilities
+ if: ${{ inputs.security-scan }}
+ uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
+ with:
+ image-ref: ${{ env.IMAGE }}@${{ steps.tested.outputs.digest }}
+ format: table
+ ignore-unfixed: true
+ vuln-type: 'os,library'
+ severity: 'CRITICAL,HIGH'
+ hide-progress: true
+ exit-code: 1
+ # same scanner version as the CI build, so a finding here means the
+ # vulnerability database moved, not the tool
+ version: ${{ inputs.trivy-version }}
+
+ - name: Promote the tested image to the release tag
+ env:
+ SOURCE_TAG: ${{ steps.source-tag.outputs.source_tag }}
+ VERSION: ${{ steps.check.outputs.version }}
+ DIGEST: ${{ steps.tested.outputs.digest }}
+ run: |
+ # --prefer-index=false copies the manifest verbatim. Without it buildx wraps a
+ # single-source manifest in an OCI index, which gives the release tag a new
+ # digest and defeats the point of promoting the exact image that was tested.
+ docker buildx imagetools create --prefer-index=false \
+ -t "$IMAGE:$VERSION" "$IMAGE:$SOURCE_TAG"
+
+ released=$(docker buildx imagetools inspect "$IMAGE:$VERSION" \
+ --format '{{.Manifest.Digest}}')
+ echo " $SOURCE_TAG -> $DIGEST"
+ echo " $VERSION -> $released"
+
+ if [ "$released" != "$DIGEST" ]; then
+ echo "❌ $IMAGE:$VERSION does not point at the tested image."
+ echo " A buildx too old to honour --prefer-index wraps the manifest in an index."
+ exit 1
+ fi
+
+ echo "✅ $IMAGE:$VERSION is the image CI built and tested for $GITHUB_SHA"
+
+ - name: Summarise the release
+ env:
+ SOURCE_TAG: ${{ steps.source-tag.outputs.source_tag }}
+ VERSION: ${{ steps.check.outputs.version }}
+ DIGEST: ${{ steps.tested.outputs.digest }}
+ run: |
+ {
+ echo "### 🚀 Released \`$IMAGE:$VERSION\`"
+ echo ""
+ echo "Promoted from \`$SOURCE_TAG\` without rebuilding."
+ echo ""
+ echo "
"
+ echo "| Commit | $GITHUB_SHA |
"
+ echo "| Source tag | $SOURCE_TAG |
"
+ echo "| Digest | $DIGEST |
"
+ echo "
"
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/README.md b/README.md
index dc403d5..267c6c3 100644
--- a/README.md
+++ b/README.md
@@ -4,14 +4,22 @@ This repository contains a comprehensive collection of reusable GitHub Actions w
## 📋 Available Workflows
-### 🐳 [Build Docker Image](./docker-build)
+### 📝 [Conventional Commits](./conventional-commits)
-Automates the process of building, tagging, and pushing Docker images to Docker Hub. Perfect for projects that require containerization with minimal configuration overhead.
+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.
### ☁️ [Build Docker Image via Docker Build Cloud](./docker-build-cloud)
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.
+### 🐳 [Build Docker Image](./docker-build)
+
+Automates the process of building, tagging, and pushing Docker images to Docker Hub. Perfect for projects that require containerization with minimal configuration overhead.
+
+### [Promote Docker Image](./docker-promote)
+
+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.
+
### 📦 [Release Please](./release-please)
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
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.
-### 📝 [Conventional Commits](./conventional-commits)
-
-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.
-
### ☕ [Java Build](./java-build)
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.
diff --git a/docker-promote/README.md b/docker-promote/README.md
new file mode 100644
index 0000000..986cbf3
--- /dev/null
+++ b/docker-promote/README.md
@@ -0,0 +1,93 @@
+# Docker Promote Workflow
+
+## Overview
+
+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.
+
+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.
+
+> [!IMPORTANT]
+> Promotion never rebuilds the image. CI must have built and pushed the source tag for the tagged commit before this workflow can promote it.
+
+## Features
+
+- Validates that the release tag matches the version release-please recorded for the component
+- Waits for the CI workflow run for the tagged commit to complete successfully
+- 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
+- Resolves the source image digest before promotion
+- Optionally re-scans the tested digest with Trivy
+- Copies the tested manifest to the release tag
+- Verifies that the release tag points at the tested digest
+
+## Inputs
+
+| Name | Description | Required | Default |
+| --- | --- | --- | --- |
+| `ci-workflow` | Workflow file used to find the CI run for the commit | No | `"ci.yaml"` |
+| `image-name` | Fully qualified OCI image name, without a tag | Yes | - |
+| `registry` | Registry hosting the image | No | `"docker.io"` |
+| `security-scan` | Enable the Trivy security scan before promotion | No | `true` |
+| `trivy-version` | Trivy security scanner version | No | `"v0.71.0"` |
+
+## Secrets
+
+| Name | Description | Required |
+| --- | --- | --- |
+| `username` | Registry username | Yes |
+| `password` | Registry password with read and write access | Yes |
+
+## Example Usage
+
+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`).
+
+```yaml
+name: Release Docker Image
+
+on:
+ push:
+ tags:
+ - "v*.*.*"
+ - "*-v*.*.*"
+
+jobs:
+ promote:
+ # ⚠️ use tagged version here
+ uses: iExecBlockchainComputing/github-actions-workflows/.github/workflows/docker-promote.yml@main
+ with:
+ image-name: docker-regis.iex.ec/my-service
+ registry: docker-regis.iex.ec
+ security-scan: false
+ secrets:
+ username: ${{ secrets.NEXUS_USERNAME }}
+ password: ${{ secrets.NEXUS_PASSWORD }}
+```
+
+The flow:
+
+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.
+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.
+
+## Notes
+
+- The release tag must point at a commit that is contained in `main`; the workflow refuses to release a tag created elsewhere.
+- 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.
+- Both `vX.Y.Z` and the release-please component form `-vX.Y.Z` are accepted; everything up to the last `-v` is treated as the component name.
+- A tag without a component is matched against the `.` package, or against the single entry of the manifest.
+- 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`.
+- 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.
+- 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-`, `feature-`, `cid..main`, …) is supported. Promotion fails with a clear error if the CI run did not publish the artifact.
+- Set `security-scan: false` to skip the promotion-time Trivy scan. The CI build should still perform its configured scan.
+- 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.
+- The registry credentials need read and write access because promotion creates the release tag.
+
+## Troubleshooting
+
+- **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.
+- **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.
+- **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`.
+- **Tag version does not match the recorded version** — the tag is expected to be `v` or `-v` followed by the version release-please released for that component.
+- **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.
+- **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.
+- **Source image or digest cannot be resolved** — confirm that CI pushed `:` to `registry`.
+- **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.
+- **Trivy reports CRITICAL or HIGH vulnerabilities** — the promotion is stopped before the release tag is created; update the image and rerun the release.
diff --git a/docker-promote/workflow-sha256 b/docker-promote/workflow-sha256
new file mode 100644
index 0000000..7f535e0
--- /dev/null
+++ b/docker-promote/workflow-sha256
@@ -0,0 +1 @@
+a85a46fd1ad50e26807800e0ede8109b3e034abd885117e8a844748d64347f1e
diff --git a/release-please-config.json b/release-please-config.json
index ae92cb1..8402e8b 100644
--- a/release-please-config.json
+++ b/release-please-config.json
@@ -21,6 +21,10 @@
"component": "docker-build-cloud",
"extra-label": "docker-build-cloud"
},
+ "docker-promote": {
+ "component": "docker-promote",
+ "extra-label": "docker-promote"
+ },
"java-build": {
"component": "java-build",
"extra-label": "java-build"