visit
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, , and DigitalOcean.
Key points:
Git is the source of truth for release info. Read Step 4 for more details.
We start with a very simple app generated by . Add Spring Web dependency (we are going to be serious) before hitting Generate button.
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 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 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 or using . 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);
}
}
Let’s introduce VersionController.
@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;
}
}
> ./gradlew :bootRun
> curl localhost:8080/version
> 0.0.1-SNAPSHOT
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.
Let’s make Gradle aware of what’s going on in the repo. A simple 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.
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'
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
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
> 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 , let’s focus on the most important parts.
- 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.
- 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.
And the last step is deployment. We are going to deploy a Deployinator (my university’s major was tautology) to . 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 for DigitalOcean GitHub action, it works only when updating the app. So for the very first run you have to an App manually. So please do that and put your DigitalOcean 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:
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.