Saltar al contenido
mypipelines
Shared (cross-cutting)·Reusable workflow·on: workflow_call

Shared Artifact Docker Ecr

Shared - Docker Artifact to ECR

.github/workflows/shared-artifact-docker-ecr.yml

.github/workflows/shared-artifact-docker-ecr.yml
name: Shared - Docker Artifact to ECR
on:
workflow_call:
inputs:
runner:
description: 'Runner type'
required: false
type: string
default: 'ubuntu-latest'
image_tag:
description: 'Docker image tag. If empty, uses short commit SHA (7 chars)'
required: false
type: string
default: ''
push_latest:
description: 'Also push :latest tag'
required: false
type: boolean
default: false
dockerfile_path:
description: 'Path to Dockerfile (build context)'
required: false
type: string
default: '.'
docker_platform:
description: 'Docker platform (linux/amd64, linux/arm64)'
required: false
type: string
default: 'linux/arm64'
environment:
description: 'GitHub environment (develop, prod)'
required: false
type: string
default: 'develop'
# Optional: Java/Gradle build before Docker (skip for non-Java stacks)
build_from_source:
description: 'Build JAR from source before Docker build (Java projects)'
required: false
type: boolean
default: false
java_version:
description: 'Java version (only when build_from_source: true)'
required: false
type: string
default: '21'
java_distribution:
description: 'Java distribution (only when build_from_source: true)'
required: false
type: string
default: 'temurin'
build_tool:
description: 'Build tool: gradle or maven (only when build_from_source: true)'
required: false
type: string
default: 'gradle'
# Optional: Download pre-built artifact
artifact_name:
description: 'Name of pre-built artifact to download. If empty, no download.'
required: false
type: string
default: ''
artifact_path:
description: 'Path where artifact is downloaded'
required: false
type: string
default: 'bootstrap/build/libs'
aws_role_to_assume:
description: >-
IAM role ARN to assume via GitHub OIDC. When set, no static access
key is sent and AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY are not
read. Empty keeps the static-key path, which is the default so that
existing consumers keep working.
required: false
type: string
default: ''
# Vulnerability scan (Trivy) before the push
vulnerability_gate:
description: >-
Fail the job, before anything is pushed, on a fixable vulnerability at
or above scan_severity. false still scans and reports, without failing.
required: false
type: boolean
default: true
scan_severity:
description: 'Trivy severities the gate acts on'
required: false
type: string
default: 'CRITICAL,HIGH'
trivyignores:
description: 'Path to a Trivy ignore file. Empty means none.'
required: false
type: string
default: ''
ignore_policy:
description: 'Path to a Trivy Rego ignore policy. Empty means none.'
required: false
type: string
default: ''
secrets:
AWS_ACCESS_KEY_ID:
required: true
AWS_SECRET_ACCESS_KEY:
required: true
AWS_REGION:
required: true
GH_PACKAGES_USERNAME:
description: 'GitHub Packages username (for dependencies hosted on GitHub Packages)'
required: false
GH_PACKAGES_TOKEN:
description: 'GitHub Packages read token'
required: false
outputs:
image_tag:
description: 'Docker image tag pushed'
value: ${{ jobs.artifact.outputs.image_tag }}
image_uri:
description: 'Full Docker image URI'
value: ${{ jobs.artifact.outputs.image_uri }}
image_digest:
description: 'Digest of the pushed image (sha256:…), for deploying exactly what was scanned'
value: ${{ jobs.artifact.outputs.image_digest }}
commit_sha:
description: 'Short commit SHA used'
value: ${{ jobs.artifact.outputs.commit_sha }}
# Least privilege for what this workflow actually does. Without it the
# job inherits whatever the calling repository's default happens to be,
# which on older repositories is write-all.
permissions:
contents: read
# Inert unless aws_role_to_assume is set. A reusable workflow cannot
# exceed its own declaration, so the OIDC path needs it declared here.
id-token: write
jobs:
artifact:
name: Build and Push to ECR
runs-on: ${{ inputs.runner }}
environment: ${{ inputs.environment }}
# Two builds and a Trivy DB download; 20 was sized for one build.
timeout-minutes: 30
outputs:
image_tag: ${{ steps.set-tag.outputs.tag }}
image_uri: ${{ steps.push-ecr.outputs.image_uri }}
image_digest: ${{ steps.push-ecr.outputs.image_digest }}
commit_sha: ${{ steps.set-tag.outputs.commit_sha }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Determine image tag
id: set-tag
run: |
COMMIT_SHA="${{ github.sha }}"
SHORT_SHA="${COMMIT_SHA:0:7}"
if [ -n "${{ inputs.image_tag }}" ]; then
TAG="${{ inputs.image_tag }}"
else
TAG="$SHORT_SHA"
fi
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "commit_sha=$SHORT_SHA" >> $GITHUB_OUTPUT
echo "Using image tag: $TAG"
# ============================================
# OPTIONAL: Download pre-built artifact
# ============================================
- name: Download build artifact
if: inputs.artifact_name != ''
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: ${{ inputs.artifact_name }}
path: ${{ inputs.artifact_path }}
# ============================================
# OPTIONAL: Build from source (Java/Gradle/Maven)
# ============================================
- name: Setup JDK ${{ inputs.java_version }}
if: inputs.build_from_source && inputs.artifact_name == ''
uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: ${{ inputs.java_distribution }}
java-version: ${{ inputs.java_version }}
- name: Setup Gradle
if: inputs.build_from_source && inputs.artifact_name == '' && inputs.build_tool == 'gradle'
uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/master' }}
- name: Build JAR (Gradle)
if: inputs.build_from_source && inputs.artifact_name == '' && inputs.build_tool == 'gradle'
env:
GH_PACKAGES_USERNAME: ${{ secrets.GH_PACKAGES_USERNAME }}
GH_PACKAGES_TOKEN: ${{ secrets.GH_PACKAGES_TOKEN }}
run: ./gradlew clean build -x test --no-daemon --build-cache --parallel
- name: Build JAR (Maven)
if: inputs.build_from_source && inputs.artifact_name == '' && inputs.build_tool == 'maven'
env:
GH_PACKAGES_USERNAME: ${{ secrets.GH_PACKAGES_USERNAME }}
GH_PACKAGES_TOKEN: ${{ secrets.GH_PACKAGES_TOKEN }}
run: mvn clean package -DskipTests -B
# ============================================
# AWS + DOCKER BUILD & PUSH
# ============================================
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
# One step, two paths. A long-lived access key in a repository secret
# is a credential nobody rotates and everybody with write access can
# exfiltrate; OIDC mints a token scoped to this repository that
# expires with the job. The keys are only sent when no role is given,
# because passing both makes the action assume the role *with* the
# static key and the key stays in play.
role-to-assume: ${{ inputs.aws_role_to_assume }}
aws-access-key-id: ${{ inputs.aws_role_to_assume == '' && secrets.AWS_ACCESS_KEY_ID || '' }}
aws-secret-access-key: ${{ inputs.aws_role_to_assume == '' && secrets.AWS_SECRET_ACCESS_KEY || '' }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Ensure ECR repository exists
env:
ECR_REPOSITORY: ${{ github.event.repository.name }}
AWS_REGION: ${{ secrets.AWS_REGION }}
run: |
if aws ecr describe-repositories \
--repository-names "$ECR_REPOSITORY" \
--region "$AWS_REGION" >/dev/null 2>&1; then
echo "ECR repository '$ECR_REPOSITORY' already exists."
else
echo "Creating ECR repository '$ECR_REPOSITORY' in region '$AWS_REGION'..."
aws ecr create-repository \
--repository-name "$ECR_REPOSITORY" \
--region "$AWS_REGION" \
--image-tag-mutability MUTABLE \
--image-scanning-configuration scanOnPush=true \
--tags Key=managed-by,Value=ci-templates Key=repository,Value=$ECR_REPOSITORY
echo "ECR repository '$ECR_REPOSITORY' created."
fi
- name: Login to Amazon ECR
id: login-ecr
# No `mask-aws-account-id: false`. The action masks the account id in logs
# by default, and opting out published it on every build: the registry host
# is <account-id>.dkr.ecr.<region>.amazonaws.com. The account id is what an
# attacker needs to guess cross-account role ARNs, and this template runs in
# consumer repositories whose logs are not all equally protected.
uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7
- name: Set up QEMU
uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
# Scan before the push, not after. A pushed tag is already deployable — the
# EKS and EC2 jobs downstream pull it — so a gate after the push would stop
# this pipeline and leave the image in the registry. The first platform
# stands in for the rest: --load takes one platform, and a Dockerfile
# installs the same packages on each.
- name: Build image for scanning
env:
DOCKER_PLATFORM: ${{ inputs.docker_platform }}
DOCKERFILE_PATH: ${{ inputs.dockerfile_path }}
run: |
set -euo pipefail
docker buildx build \
--platform "${DOCKER_PLATFORM%%,*}" \
--no-cache \
--load \
-t local-scan:artifact \
"$DOCKERFILE_PATH"
# Report first with exit-code 0, gate second. If the gate ran alone its
# table would go to the output file and a failure would leave nothing
# readable in the log — which is precisely when somebody needs to read it.
- name: Scan image for vulnerabilities
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: local-scan:artifact
scanners: vuln
vuln-type: os,library
severity: ${{ inputs.scan_severity }}
ignore-unfixed: true
trivyignores: ${{ inputs.trivyignores }}
ignore-policy: ${{ inputs.ignore_policy }}
format: table
output: trivy-artifact.txt
exit-code: '0'
timeout: '15m'
- name: Publish scan result
if: always()
env:
REPORT: trivy-artifact.txt
SCAN_SEVERITY: ${{ inputs.scan_severity }}
GATE: ${{ inputs.vulnerability_gate && 'blocking' || 'report only' }}
run: |
{
echo "### Vulnerability scan"
echo ""
echo "Severity: \`${SCAN_SEVERITY}\`, fixable only. Gate: ${GATE}."
echo ""
echo '```'
if [ -s "${REPORT}" ]; then
head -c 40000 "${REPORT}"
else
echo "No vulnerabilities at or above the gate."
fi
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
- name: Vulnerability gate
if: inputs.vulnerability_gate
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: local-scan:artifact
scanners: vuln
vuln-type: os,library
severity: ${{ inputs.scan_severity }}
ignore-unfixed: true
trivyignores: ${{ inputs.trivyignores }}
ignore-policy: ${{ inputs.ignore_policy }}
format: table
exit-code: '1'
timeout: '15m'
skip-setup-trivy: true
- name: Build and push to ECR
id: push-ecr
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: ${{ github.event.repository.name }}
IMAGE_TAG: ${{ steps.set-tag.outputs.tag }}
run: |
IMAGE_URI="$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG"
TAGS="-t $IMAGE_URI"
if [ "${{ inputs.push_latest }}" = "true" ]; then
TAGS="$TAGS -t $ECR_REGISTRY/$ECR_REPOSITORY:latest"
fi
echo "Building and pushing Docker image: $IMAGE_URI"
echo "Platform: ${{ inputs.docker_platform }}"
# No --no-cache here: the scan build ran without cache moments ago, and
# reusing its layers is what makes the pushed image the scanned one.
# SBOM and provenance travel with the image as attestations, the same as
# shared-build-publish-image. Without them a consumer asking "what is in
# this image, and what built it" has to pull it apart. ECR and GHCR both
# store the resulting OCI index; docker and containerd ignore the
# attestation manifests when they pull.
docker buildx build \
--platform ${{ inputs.docker_platform }} \
$TAGS \
--label "org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
--label "org.opencontainers.image.revision=${{ github.sha }}" \
--label "org.opencontainers.image.created=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \
--label "org.opencontainers.image.title=$ECR_REPOSITORY" \
--label "org.opencontainers.image.version=$IMAGE_TAG" \
--label "org.opencontainers.image.vendor=codehunters" \
--label "build.github.run-id=${{ github.run_id }}" \
--label "build.github.workflow=${{ github.workflow }}" \
--label "build.environment=${{ inputs.environment }}" \
--label "observability.service=$ECR_REPOSITORY" \
--label "observability.environment=${{ inputs.environment }}" \
--label "observability.domain=${ECR_REPOSITORY%%-*}" \
--sbom=true \
--provenance=mode=max \
--metadata-file "$RUNNER_TEMP/build-meta.json" \
--push \
${{ inputs.dockerfile_path }}
echo "image_uri=$IMAGE_URI" >> $GITHUB_OUTPUT
# Without jq the digest is left empty and the deploy falls back to the tag,
# rather than failing a step whose image is already pushed.
DIGEST=""
if command -v jq >/dev/null; then DIGEST=$(jq -r '."containerimage.digest" // empty' "$RUNNER_TEMP/build-meta.json"); fi
if [ -z "$DIGEST" ]; then
echo "::warning::No image digest recorded (jq missing or metadata absent); deploys will fall back to the tag."
fi
echo "image_digest=$DIGEST" >> "$GITHUB_OUTPUT"
echo "### Docker Image Pushed to ECR" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| **Repository** | $ECR_REPOSITORY |" >> $GITHUB_STEP_SUMMARY
echo "| **Tag** | $IMAGE_TAG |" >> $GITHUB_STEP_SUMMARY
echo "| **Platform** | ${{ inputs.docker_platform }} |" >> $GITHUB_STEP_SUMMARY
# Deliberately not the full URI: it starts with the registry host, which
# is <account-id>.dkr.ecr.<region>.amazonaws.com. Log masking does not
# cover the job summary, and the summary outlives the logs. Repository and
# tag are what a reader needs; image_uri is still a job output for the
# jobs downstream.