Have you ever thought about starting a new project with a release pipeline? I’m talking about automation that will deliver your application to a real environment. I know, it sounds a bit strange to kick off a project with CI/CD stuff. It’s like releasing the thing that doesn’t yet exist. It’s like writing a test for a code that isn't even written. But wait, that’s a well-known technique called Test Driven Development!
So I’m talking about something similar here. Before writing any piece of code, prepare your future service for the real world. Everyone should start building their app from “core”. But please make sure your core is deployable. Your clients/customers/whoever would like to see your service being accessible not only from your local dev machine.
I haven’t found any mention of ‘Release Driven Development’ on the internet (bumping it here, I’m first). Someone (read: me) has to prepare a big theoretical article describing what RDD is in detail, and how it’s related to Continuous Deployment and Agile methodologies. But now let’s have some fun. I’m going to show you how to start with RDD (I already love this acronym!) with a very simple SpringBoot app, Github Actions, and DigitalOcean.
We’ll build a minimalistic release pipeline based on GitHub Actions done right. On the main branch push, it will build and test your Gradle module, tag it with a new version, and deploy it to DigitalOcean.
We are building something like this:
Key points:
Git is the source of truth for release info. Read Step 4 for more details.
Store release version as a Git tag.
No Gradle release plugin. Only a plugin that fetches the latest tag.
Github Actions as a CI/CD platform.
Software development should be iterative, this article is no exception. Consider this a guide. Each step is a separate commit in the repo. And of course, feel free to experiment at any step. Or go ahead and check out the project repo at GitHub.
We start with a very simple app generated by Spring Initializr. Add Spring Web dependency (we are going to be serious) before hitting Generate button.
Your project should look similar to this:
Go ahead to settings.gradle
and change rootProject.nam
e to something better than demo. Not sure that Deployinator (this is how I named mine) is better, but Dr. Doofenshmirtz would love it.
Next, we want to make sure our java application knows the version of… itself. We have to pass the Gradle project version (which is currently 0.0.1-SNAPSHOT) inside our application.
To make it happen, we need to create a build.properties under src/main/resources and add the next line to it:
info.build.version = ${version}
and this piece of code to build.gradle
:
processResources {
filesMatching("build.properties") {
expand(project.properties)
}
}
Wait what?
Why create a new properties file if there are already empty application.properties?
Let me explain. First, to keep things separated. Use application.properties - for any business or app-related configuration. Use build.properties - for any build/Gradle-related stuff.
Second, Gradle properties expansion clashes with Spring expansion. If you filter application.properties with Gradle, you have to use an escaped /${}
placeholder for any Spring injections, because the classic ${}
one is used by Gradle. It’s not efficient to use non-standard placeholders everywhere for the sake of one property from Gradle.
There are other options available to pass the Gradle project version into the app, like adding it to the manifest or using buildInfo. But I like the one mentioned above more, because it is the most transparent approach. Also, it works without additional magic when you do :bootRun
or run a Main class from an IDE.
Don’t forget to add a new build.properties file as a property source to Spring:
import org.springframework.context.annotation.PropertySource;
@SpringBootApplication
@PropertySource("classpath:build.properties")
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
Commit with changes for the step is here
Let’s introduce VersionController.
It will print out the current app’s version:
@RestController
public class VersionController {
private static final Logger logger = LoggerFactory.getLogger(VersionController.class);
@Value("${info.build.version}")
private String version;
@PostConstruct
public void printVersion() {
// check version in logs
logger.info("You deployed me well! My version is {}", version);
}
@GetMapping("/")
public String helloWorld() {
return version;
}
}
As far as we are done with our app (yes, that’s it), let’s make sure we have everything working
> ./gradlew :bootRun
and find the next line in the output logs:
Go to the terminal and check out HTTP:
> curl localhost:8080/version
> 0.0.1-SNAPSHOT
Now it’s time to talk about version management. There are several approaches (of course) to storing version info:
Keep it under a build tool (Gradle, Maven, etc) as a property.
Like project.version = 0.0.1-SNAPSHOT
inside build.gradle
I remember those days when almost every project was using Maven Release Plugin. Those shiny commits “Prepare Release ..”
and “Prepare next dev iteration…”
with version bumping. Such a… mess.
I believe release responsibility should be taken away from build tools. They have to know how to test/assembly/build resources and artifacts. They should not be aware of the release, it’s a part that nowadays could be easily delegated to CI/CD platforms. Single responsibility principle. So if the build tool is not releasing, there is no point to store versions there and use them as a source of truth.
Store versions in Git.
Git - is a version control system. At least based on the description it’s a healthy idea to store the application version there. Store - to have a tag that uniquely identifies a release for example. A tag named 0.0.1-SNAPSHOT or v12-my-first-release. It’s more flexible, you can reassign a tag to another commit and easily add/remove features from an upcoming release. You can simply track release history by looking into the Git log. In this approach, a build tool is just a follower - takes info from Git and does its job.
It's possible to argue for different version management. If you are not concerned yet, just give it a try for a new project. In this tutorial we are keeping things simple and flexible, so Git is the best choice.
Folk wisdom: If you have a problem and you want to solve it using Gradle, now you have 2 problems.
Let’s make Gradle aware of what’s going on in the repo. A simple gradle-git-plugin solves our problem. It’s even more awesome than I originally thought because it’s not based on JGit, it just reads the .git folder. Which means less configuration and more predictable results.
Changes in build.gradle:
plugins {
id 'java'
id 'org.springframework.boot' version '3.0.4'
id 'io.spring.dependency-management' version '1.1.0'
id 'com.palantir.git-version' version '2.0.0'
}
group = 'com.example'
version = gitVersion()
sourceCompatibility = '17'
Let’s see what we have for a project version in logs.
Good!
We see a short hash of a commit (we don’t have tags yet) + dirty flag. The flag means there are uncommitted files in the repo. This is nice to have because it softly warns: you probably have something missing in your repo. But it’s not blocking the release process which gives flexibility in case of an exceptional situation (anything could happen, even releasing a midnight fix from your laptop).
After committing all the changes and adding a tag named v1-manualReleaseHere we see in the logs:
Alright, now we have a version flow from Git -> Gradle -> Java app
It’s hard to imagine a modern app without docker, so let’s quickly containerize what we have.
The simplest Dockerfile could look like this:
FROM eclipse-temurin:17
COPY build/libs/deployinator-*.jar /app/deployinator.jar
ENTRYPOINT java -jar /app/deployinator.jar
Build and push to make sure everything is ok:
> docker build -t <your_name_here>/deployinator .
> docker tag <your_name_here>/deployinator <your_name_here>/deployinator:latest
> docker push <your_name_here>/deployinator:latest
It’s time to gather everything in one place. We are going to have one pipeline under .github/workflows/createRelease.yml. While you can find the whole working pipeline here, let’s focus on the most important parts.
First of all, declare the version of the current release. We need an env var for this to be able to access it later:
- name: Create Release Version
run: echo "RELEASE_VERSION=v${{github.run_number}}-${GITHUB_SHA::7}" >> $GITHUB_ENV
The version looks like v12-123abcd, where 12 is a pipeline (job) execution number. Last 7 - short commit hash
SemVer (versions like 1.2.3) is a de-facto standard in the industry. Here I propose to use run_number of the job for several reasons. First, you don’t have to fetch previous tags at all (when you have thousands of releases it might be cumbersome). The second is simplicity. You don’t need logic to increase the latest release number (from 1.2.3 to 1.2.4 for example) because run_number is monotonically increasing for free (no SMS or registration required).
Having a short sha in version number is a super important thing. It frequently happens when you have to quickly match your release with the corresponding Git commit to understand what exactly is deployed. When you open your cloud console and see that version 1.2.10 is deployed - it’s pretty hard to guess whether your midnight fix is in or not. You have to take this version and go to the bug-tracking system or somewhere to get a clue about what 1.2.10 stands for. But when you have a hash in a version you can just grab it and check what you need even locally.
Next is building a Jar file. Under the hood, Gradle Git plugin will fetch the current version from the tag and use it for naming the jar.
- name: Build
uses: gradle/gradle-build-action@v2
with:
arguments: clean build
Then docker image. Make sure to add the required secrets (DOCKERHUB_USERNAME and DOCKERHUB_TOKEN) to GitHub to make this step work:
- name: Build & Push Docker Image
uses: docker/build-push-action@v4
with:
context: .
tags: |
${{ secrets.DOCKERHUB_USERNAME }}/deployinator:latest
${{ secrets.DOCKERHUB_USERNAME }}/deployinator:${{env.RELEASE_VERSION}}
push: true
We tag the image with both - latest and release versions. But never rely on or deploy the latest, it’s just for convenience. You should always know exactly what’s deployed and the specific version of the app your clients are working with.
Now, as we know our artifact is tested, built, and fully packed, we can claim the release as a success by pushing the tag to a Git repo. Yes, deployment still can fail. But if it fails because of a broken release - it’s fine from a release standpoint (obviously it’s not from a common sense standpoint). We just have a “bad” release. But it’s still a release, right?
And the last step is deployment. We are going to deploy a Deployinator (my university’s major was tautology) to DigitalOcean App Platform. In a few words, App Platform is a solution that can host docker images with minimal config and infrastructure being defined. Each App can have one or multiple components, which are the building blocks of your service. In our case, one app and one component are more than enough.
According to official documentation for DigitalOcean GitHub action, it works only when updating the app. So for the very first run you have to create an App manually. So please do that and put your DigitalOcean token together with the App name into GitHub secrets in advance (see var names below).
- name: Deploy to DigitalOcean
if: ${{ env.DEPLOY_TO_DIGITAL_OCEAN == 'true' }}
uses: digitalocean/app_action@main
with:
app_name: ${{ secrets.DIGITALOCEAN_APP_NAME }}
token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }}
images: '[
{
"name": "deployinator-component",
"image":{
"registry": "${{ secrets.DOCKERHUB_USERNAME }}",
"registry_type": "DOCKER_HUB",
"repository": "deployinator",
"tag": "${{env.RELEASE_VERSION}}"
}
}
]'
Now it’s time to do a final check. Go to your DigitalOcean’s App and click Live App button
Ta-dam! It doesn’t show the version!
Ah, that’s fine. It’s because our VersionContoller is on the /version
path. So add /version
to the URL and see something like this:
Everyone likes it when an application is releasable. Otherwise, it doesn’t make sense, no one needs an app that has never been deployed. But when starting a new project most of the teams are ok deploying their shiny new service manually. Why?
Daily 15 mins deploy can easily turn out to be 5h+ per month! Do you want to spend hours manually deploying your application, or do you want to spend those time doing something more fun, like staring at a wall? Okay, maybe it’s not that fun, but you got the idea.
So save your engineering time, right from the very beginning.