
We obsess over clean architecture, reactive UIs, and robust testing, but often treat our release process like a messy, secretive script we’re afraid to touch. If you’ve ever experienced that pit in your stomach right after clicking “Rollout” on the Play Console — wondering, “Did I remember to bump the versionCode?” or “Wait, did I build from the main branch?”—you are not alone.
As my open-source project, Upnext: TV Series Manager, grew from a personal experiment into a comprehensive app featured in the Google Developers Dev Library, the “manual release checklist” became my biggest bottleneck.
It was time to adopt what I call the Factory Line Mental Model. If a human hand has to touch the product on its way out the door — whether that’s manually editing a build.gradle file, typing a Git tag, or dragging an AAB file into a web browser—it’s a safety violation. The CI pipeline should be the only entity authorized to ship.
Recently, I embarked on a journey to automate Upnext’s release engineering completely. In this article, I will share the architectural blueprint for moving from anxious, manual uploads to a confident, automated pipeline using Calendar Versioning (CalVer), Fastlane, and GitHub Actions.
Part 1: The Problem with versionCode++
For years, Android developers have relied on a simple integer (versionCode) and an arbitrary string (versionName like 1.0.42).
The problem? After a few years, 1.0.42 loses all semantic meaning. Does that version include the new Navigation 3 migration? Was it built before or after Google I/O last year? You’d have to dig through Git logs to find out.
The Solution: Calendar Versioning (CalVer)
Instead of arbitrary numbers, I transitioned Upnext to a date-based format: YYYY.M.Patch. If a user reports a bug on a version 2026.1.4I know instantly that the build was cut in January 2026. This immediate context is invaluable for debugging and communicating with users.
But CalVer only works if you don’t have to calculate it manually.
Part 2: Laying the Foundation (The Source of Truth)
The first step to automation is to extract your configuration from the complex Gradle DSL and into a simple, parsable file that external scripts can easily read and write.
I created a version.properties file at the root of the project:
VERSION_NAME=2026.1.0
VERSION_CODE=214
Now, we update our app’s build.gradle.kts to read from this file dynamically:
BAD ❌
// Hardcoded assumption requires human intervention every release
defaultConfig {
versionCode = 214
versionName = "2026.1.0"
}
GOOD ✅
// The file is the single source of truth
val versionPropsFile = rootProject.file("version.properties")
val versionProps = Properties()
if (versionPropsFile.exists()) {
versionProps.load(FileInputStream(versionPropsFile))
}
defaultConfig {
versionCode = versionProps["VERSION_CODE"].toString().toInt()
versionName = versionProps["VERSION_NAME"].toString()
}
Part 3: The Automation Engine (Fastlane)
While Fastlane is incredibly popular in the iOS ecosystem, it is an absolute powerhouse for Android. It acts as the robotic arm on our factory floor, running the precise steps needed to package the app.
I configured a Fastfile to handle the heavy lifting of calculating the new CalVer string and bumping the integers.
Here is a simplified look at the internal track lane that calculates our versioning:
default_platform(:android)
platform :android do
lane :internal do
# 1. Bump the standard integer code
increment_version_code(
gradle_file_path: "app/build.gradle",
ext_constant_name: "versionCode"
)
# 2. Calculate the CalVer string (YYYY.M.X)
current_year = Time.now.year.to_s
current_month = Time.now.month.to_s
# Custom logic to determine the patch number based on existing tags is here
# ...
# new_version_name = "#{current_year}.#{current_month}.#{patch}"
# 3. Build the release AAB
gradle(task: "bundleRelease")
# 4. Upload to Play Store Internal Track
upload_to_play_store(track: 'internal')
end
end
With this script, Fastlane ensures the version is always incremented correctly, the AAB is always built consistently, and the artifact is always routed to the correct Play Store track.
Part 4: The CI Guardrails (GitHub Actions)
Local scripts are great, but a unified pipeline is better. We need to enforce that Fastlane only runs in a clean, reproducible environment: the cloud.
I created a .github/workflows/deploy.yml workflow. This workflow triggers only when a specific event occurs (like a push to the main branch or a manual dispatch).
A critical part of this pipeline is Secure Secret Management. Your Keystore password and alias should never exist in your repository. Instead, we store them as GitHub Secrets and inject them at runtime as environment variables:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# ... Java Setup and build steps ...
- name: Deploy to Internal Track
env:
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
# Decode the base64 keystore file stored in secrets
KEYSTORE_FILE: ${{ secrets.KEYSTORE_FILE_BASE64 }}
run: |
echo $KEYSTORE_FILE | base64 --decode > app/keystore.jks
bundle exec fastlane internal
The Secret Sauce: Adding Your Credentials to GitHub
You might be wondering: How does GitHub get my keystore if it’s not in the repo? The answer is GitHub Actions Secrets.
To set this up, you need to navigate to your repository on GitHub and go to Settings > Secrets and variables > Actions. Here, you will click New repository secret and add the exact keys referenced in your deploy.yml.
For standard text secrets like KEYSTORE_PASSWORD or KEY_ALIAS, you simply paste the text value.
However, the tricky part is the .jks keystore file itself, because GitHub Secrets only accept text strings, not binary files. To solve this, we convert the keystore into a Base64 string locally first.
Open your terminal and run this command:
# On macOS/Linux
base64 -i my_keystore.jks -o keystore_base64.txt
Copy the entire contents of keystore_base64.txt and paste it into a new GitHub Secret named KEYSTORE_FILE_BASE64. As you can see in the YML snippet above, our CI script will grab that string and decode it back into a usable .jks file right before Fastlane runs.
The Final Polish: Automated GitHub Releases
A professional release process isn’t just about throwing code at Google Play; it’s about documenting history. I added steps to the deploy.yml that use the GitHub CLI (gh release create) to automatically generate a GitHub Release based on our new CalVer tag, attaching the compiled APKs directly to the repository history.
The “Bus Factor”
Why go through all this effort? It reduces the “bus factor” — the risk of knowledge being siloed with just one developer.
Even if you are a solo developer right now, your future self is a different person. When you return to a project after six months, you shouldn’t have to remember which script to run or where the keystore file is hidden.
Key Takeaways
If you take anything away from my journey refactoring Upnext’s deployment strategy, let it be this:
- ✅ CalVer Provides Context: Knowing when a bug was shipped is often more important than knowing what arbitrary number it was assigned.
- ✅ Your build.gradle is not a database: Extract changing values like versions to .properties files.
- ✅ Script the Boring Stuff: Fastlane enforces absolute consistency for Android builds.
- ✅ The Factory Floor is in the Cloud: Use GitHub Actions to ensure your deployments are zero-trust, reproducible, and fully automated.
Stop guessing. Start shipping with confidence.
Happy shipping!
From Hobbyist to Pro: Automating Release Confidence with CalVer & Fastlane was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.




This Post Has 0 Comments