name: Java - Docker Artifact to GitHub Registry
on: workflow_call: inputs: runner: description: 'Runner type' required: false type: string default: 'ubuntu-latest' java_version: description: 'Java version' required: false type: string default: '21' java_distribution: description: 'Java distribution' required: false type: string default: 'temurin' 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' required: false type: string default: '.' docker_platform: description: 'Docker platform (linux/amd64, linux/arm64, linux/amd64,linux/arm64)' required: false type: string default: 'linux/arm64' artifact_name: description: 'Name of pre-built JAR artifact to download. If empty, builds from source.' required: false type: string default: '' jar_path: description: 'Path where JAR files are expected by the Dockerfile' required: false type: string default: 'bootstrap/build/libs' environment: description: 'GitHub environment (develop, prod)' required: false type: string default: 'develop'
# 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: ''
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 packages: write
jobs: artifact: name: Build and Push to GitHub Registry 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-ghcr.outputs.image_uri }} image_digest: ${{ steps.push-ghcr.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"
# Download pre-built JAR if artifact_name is provided - name: Download build artifact if: inputs.artifact_name != '' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ inputs.artifact_name }} path: ${{ inputs.jar_path }}
# Build from source if no artifact provided - name: Setup JDK ${{ inputs.java_version }} if: 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.artifact_name == '' 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 if: inputs.artifact_name == '' 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: Login to GitHub Container Registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }}
- 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 # deploy 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 GitHub Registry id: push-ghcr env: GHCR_REGISTRY: ghcr.io/${{ github.repository_owner }} GHCR_REPOSITORY: ${{ github.event.repository.name }} IMAGE_TAG: ${{ steps.set-tag.outputs.tag }} run: | # GitHub Registry requires lowercase GHCR_REGISTRY=$(echo "$GHCR_REGISTRY" | tr '[:upper:]' '[:lower:]') GHCR_REPOSITORY=$(echo "$GHCR_REPOSITORY" | tr '[:upper:]' '[:lower:]')
IMAGE_URI="$GHCR_REGISTRY/$GHCR_REPOSITORY:$IMAGE_TAG"
TAGS="-t $IMAGE_URI" if [ "${{ inputs.push_latest }}" = "true" ]; then TAGS="$TAGS -t $GHCR_REGISTRY/$GHCR_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=$GHCR_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 "observability.service=$GHCR_REPOSITORY" \ --label "observability.environment=${{ inputs.environment }}" \ --label "observability.domain=${GHCR_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 GitHub Registry" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY echo "| **Registry** | $GHCR_REGISTRY |" >> $GITHUB_STEP_SUMMARY echo "| **Repository** | $GHCR_REPOSITORY |" >> $GITHUB_STEP_SUMMARY echo "| **Tag** | $IMAGE_TAG |" >> $GITHUB_STEP_SUMMARY echo "| **Platform** | ${{ inputs.docker_platform }} |" >> $GITHUB_STEP_SUMMARY echo "| **Full URI** | $IMAGE_URI |" >> $GITHUB_STEP_SUMMARYJava (Spring Boot)·Reusable workflow·on: workflow_call
Java Artifact Docker Github
Java - Docker Artifact to GitHub Registry
.github/workflows/java-artifact-docker-github.yml