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 "" + echo "" + echo "" + echo "
Commit$GITHUB_SHA
Source tag$SOURCE_TAG
Digest$DIGEST
" + } >> "$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"