Github: automated build and publish of containerized Spring Boot app using GitHub Actions

Github Actions provide the ability to define a build workflow directly in Github.  The workflow steps are defined as yaml and can be triggered by various events, including a code push, branch, or tagging in the repository.

In this article I will detail the steps of creating a simple Spring Boot web application that when tagged with a semantic value (e.g. ‘v1.0.1’), will package it into an OCI-compatible (Docker) image that is published to both Docker Hub and Github Container Registry.

Prerequisites

OpenJDK 17+ for testing local compilation

# view candidate list of JDK
sudo apt search openjdk-* | grep '^openjdk\-' | grep '\-jdk'
sudo apt install openjdk-17-jdk

Github CLI for creating github repo from console

See my article here for installing the Github CLI

Create Spring Boot web starter project

A convenient way of getting the basic layout and scaffolding of a Spring Boot project is to use start.spring.io to download a project starter.

# setup project values
id=spring-boot-github-action-example
artifact_id=$id
SpringAppClassName=SpringMain
version="1.0.0"
groupId="org.fabianlee"
javaVersion=17
springBootVersion=2.7.5

# returns archive containing SpringBoot project, extract
curl -s https://start.spring.io/starter.tgz \
    -d type=gradle-project \
    -d dependencies=web,prometheus,devtools,actuator \
    -d javaVersion=$javaVersion \
    -d bootVersion=$springBootVersion \
    -d groupId=$groupId \
    -d artifactId=$artifact_id \
    -d name=$SpringAppClassName \
    -d version=$version | tar -xzvf -

# validate files have been created in directory
cd $id
ls -l

You now have a directory named “spring-boot-github-action-example” containing all the basic structure and files required for a Spring Boot web application.

You can copy-paste the commands above, or download start-spring-io-webapp.sh from my github.

Test Spring Boot web locally

This minimal web application will create a web server on port 8080 when run locally.  Validate by compiling and running the Spring Boot web app.

# locally compiles Spring Boot jar
./gradlew bootJar

# web server running locally on 8080
./gradlew bootRun

From another console (or even a local browser), you should be able to get a valid HTTP response from port 8080.

# test from another console
$ curl http://localhost:8080/actuator/health
{"status":"UP"}

Add support for building OCI image using Gradle

The previous section illustrated the support for building the Spring Boot jar with the ‘bootJar’ task.  But we need to enrich ‘build.gradle’ to support embedding this jar into an OCI-compatible image.

Dockerfile

The first step is creating a “src/main/resources/Dockerfile”.  You can download my full Dockerfile here, or copy-paste the contents below.

# eclipse-temurin because OpenJDK is deprecated
FROM eclipse-temurin:19.0.1_10-jdk-jammy

# create non-root user and group for security compliance
ARG THE_USER_ID=1001
ARG THE_GROUP_ID=1001
RUN \
  /usr/sbin/groupadd -g $THE_GROUP_ID spring && \
  /usr/sbin/useradd -l -u $THE_USER_ID -G spring -g $THE_GROUP_ID spring && \
  mkdir logs && chgrp spring logs && chmod ug+rwx logs

# run as non-root
USER spring:spring

# main port
EXPOSE 8080

COPY springBoot.jar springBoot.jar
CMD ["java","-jar","springBoot.jar"]

This will create an OCI image that runs springBoot.jar as the non-root “spring” user, using the eclipse-temurin base image (adoptopenjdk image has been deprecated).

build.gradle

Then we need to customize the build.gradle file to support building an OCI image that invokes the Spring Boot jar.  We will add support for building the image in two different ways:

  1. Using Docker – well-know for image building and running
  2. Using Buildah – newer non-daemonized utility that can build OCI images

We only need to build an image using one of these tools, but I am going to add support for both in the build.gradle so we have the flexibility to switch at-will.

You can download my full build.gradle here, or copy-paste the contents below.

plugins {
	id 'org.springframework.boot' version '2.7.5'
	id 'io.spring.dependency-management' version '1.0.15.RELEASE'
	id 'java'
}

// ADDED as source of plugins
repositories {
  mavenCentral()
}

group = 'org.fabianlee'
version = '1.0.0'
sourceCompatibility = '17'

// ADDED want consistent name for jar
bootJar {
  archiveFileName = "springBoot.jar"
}

// ADDED ability to specify docker owner and version on command line
ext.dockerOwner = project.hasProperty('dockerOwner') ? project.getProperty('dockerOwner'):'fabianlee'
ext.dockerVersion = project.hasProperty('dockerVersion') ? project.getProperty('dockerVersion'):'1.0.0'

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-actuator'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	developmentOnly 'org.springframework.boot:spring-boot-devtools'
	runtimeOnly 'io.micrometer:micrometer-registry-prometheus'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
	useJUnitPlatform()
}

// ADDED to make Dockerfile available in build directory
task copyDockerfile(type: Copy) {
  from 'src/main/resources/Dockerfile'
  into 'build/libs'
}

ext.ownerProject = "${dockerOwner}/${project.name}"
// ADDED build OCI image using Docker
task docker(type: Exec) {
    group "OCI"
    dependsOn bootJar
    dependsOn copyDockerfile
    workingDir "${buildDir}/libs"
    commandLine "docker", "build", "-f", "Dockerfile", "-t", "${ownerProject}:${dockerVersion}", "-t", "${ownerProject}:latest", "."
}

// ADDED build OCI image using Buildah
task buildah(type: Exec) {
    group "OCI"
    dependsOn bootJar
    dependsOn copyDockerfile
    workingDir "${buildDir}/libs"
    commandLine "buildah", "bud", "-f", "Dockerfile", "-t", "${ownerProject}:latest", "-t", "docker.io/${ownerProject}:latest","-t", "docker.io/${ownerProject}:${dockerVersion}","-t","ghcr.io/${ownerProject}:latest","-t","ghcr.io/${ownerProject}:${dockerVersion}", "."
}

Create Github repository

Let’s go ahead and create the remote Github repository for this starter project and our Dockerfile and build.gradle modifications.  We will add the Github Action workflows in the next sections.

# create remote repo (the 'id/repo' from output will need to be used below)
$ CURDIR=${PWD##*/}
$ gh repo create $CURDIR --public
✓ Created repository fabianlee/spring-boot-github-action-example on GitHub

# initialize repo and add files
git init
git add *
git add .gitignore

# commit files and create main branch
git commit -a -m "first commit"
git branch -M main

# push files to remote Github repo
# use id/repo value from above for remote name
git remote add origin https://github.com/fabianlee/spring-boot-github-action-example.git
git push -u origin main

View remote Github repository in browser

The remote repository URL is shown by using the command below.

$ git remote -v
origin	https://github.com/fabianlee/spring-boot-github-action-example.git (fetch)
origin	https://github.com/fabianlee/spring-boot-github-action-example.git (push)

Pulling this URL up in your browser should looking something like the screenshot below.

Adding Github Action, overview

We will be adding a Github Action workflow by adding a yaml manifest file into the “.github/workflows” directory of our repository.

Workflows support various triggering events, we will choose to trigger when there is push to the repository of a tag that looks like a semantic version.

These workflows are run remotely on Github-hosted runners.

Add Github Action for OCI Image build and publish

Our workflow will kick off when any new semantic tag (e.g. v1.0.1) is pushed to the repository.  I will go over key areas below, but download the full version of github-actions-buildOCI-image.yml from my github.

Trigger

on:
  push:
    tags: ['v*']

Java and Gradle setup

      - name: setup Java
        uses: actions/setup-java@v3
        with:
          java-version: '17'
          distribution: adopt
          cache: gradle

Build OCI image

Then we have gradle either build the OCI image with Docker or Buildah. You only need to build with one or the other, I simply wanted you to have an example of how each would be used.

env:
  BUILD_OCI_WITH: buildah # docker|buildah

...
     # (CONDITIONAL) build using docker
      - name: Execute Gradle build of OCI with Docker
        run: ./gradlew docker -PdockerVersion=${{ steps.getversion.outputs.VERSION }}
        if: ${{ env.BUILD_OCI_WITH == 'docker' }}

      # (CONDITIONAL) build using buildah
      - name: Execute Gradle build of OCI with Buildah
        run: ./gradlew buildah -PdockerVersion=${{ steps.getversion.outputs.VERSION }}
        if: ${{ env.BUILD_OCI_WITH == 'buildah' }}

Publish OCI image

This OCI image needs to be published to a registry.  I will provide examples of pushing to both Docker Hub and Github Container Registry.

env:
  PUSH_TO_DOCKERHUB: true
  PUSH_TO_GITHUBCR: true     

...

      # Push to Github CR
      - run: echo push to Github Container Registry ${{ env.PUSH_TO_GITHUBCR }}
      - name: Buildah push to Github Container Registry
        uses: redhat-actions/push-to-registry@v2
        with:
          image: ${{ env.FULL_IMAGE_NAME }}
          tags: ${{ steps.getversion.outputs.VERSION }} latest
          registry: ${{ env.GH_REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
          extra-args: |
            --disable-content-trust
        if: ${{ env.PUSH_TO_GITHUBCR == 'true' }}

      # Push to Docker Hub
      - run: echo push to Docker Hub ${{ env.PUSH_TO_DOCKERHUB }}
      - name: Buildah push to Docker Hub
        uses: redhat-actions/push-to-registry@v2
        env:
          USER: ${{ secrets.DOCKER_USERNAME }}
          PASS: ${{ secrets.DOCKER_TOKEN }}
        with:
          image: ${{ env.FULL_IMAGE_NAME }}
          tags: ${{ steps.getversion.outputs.VERSION }} latest
          registry: docker.io
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_TOKEN }}
          extra-args: |
            --disable-content-trust
        if: ${{ env.PUSH_TO_DOCKERHUB == 'true' && env.USER!='' && env.PASS!='' }}

You may notice that the Docker Hub push uses a “secrets.DOCKER_USERNAME” and “secrets.DOCKER_TOKEN”.  These are your Docker Hub username and personal access token that need to be added as secrets in this Github repository.   The “secrets.GITHUB_TOKEN” used for pushing to the Github Container Registry is a special variable already populated by Github.

Create a Docker Hub personal access token using the documentation provided here.   Then you can either go to “Settings” > “Secrets” > “Actions” in your Github web UI and press “New Repository Secret”.  Or you can use the Github CLI to create repository secrets.

# prepend a space ' ' to the commands, avoids sensitive info going to history
 echo <yourDockerId> | gh secret set DOCKER_USERNAME
 echo <yourDockerPAT> | gh secret set DOCKER_TOKEN

Trigger Github Workflow

As mentioned earlier, this workflow is invoked by pushing a tag that looks like a semantic version.  Here are the git commands for creating a tag and pushing it remotely.

# check for any changes that might need to be checked in first
git status

# create new tag that triggers workflow, push tag
newtag=v1.0.0
git tag $newtag && git push origin $newtag

This will almost immediately create a workflow which you can view in real-time by going to your Github repository > Actions tab.

You can view the real-time progress by clicking into the task.  The icon will indicate when the workflow is complete.

Validate Published images

Docker Hub

You can visit Docker Hub and sign-in with your Docker username to validate whether the image was published.  You should be able to see your image and its tagged version similar to the screenshot below.

Github Container Registry

The Github Container Registry image can be reached from the Github web UI.  Click on “Packages” > spring-boot-github-action-example, and the available image and tags will be displayed.

 

REFERENCES

stackoverflow, github action invoking python

github marketplace, redhat-actions/push-to-registry

github source, redhat-actions/push-to-registry

github source, slackapi/slack-github-action

jozala.com, reasons to use palantir plugin for docker instead of plain exec

github cli, create repo

github cli, create secret

NOTES

(Optional) Local OCI image creation for development/tests

The primary purpose of this article is to enable remote Github Actions to build this OCI image, so you do NOT need Docker or Buildah installed locally.  However, in the real-world when there are many iterations of active development on the code, so for testing the container environment, you will clearly want to build and test the image locally.

local Docker build test

We are going to enable the remote Github Actions to build this OCI image using Docker.  But IF you wanted to test this build with Docker locally, you would need to install Docker per my article here, then run:

# build using local Docker daemon
./gradlew docker

# newly built image should now be available
docker images

local Buildah build test

We are going to enable the remote Github Actions to build this OCI image using Buildah.  But IF you wanted to test this build with Buildah locally, you would need to install Buildah per my article here, then run:

# build using local Buildah utility (non-daemonized)
./gradlew buildah

# newly built image should now be available
buildah images