How im Using Minecraft to Learn CI/CD
September 22, 2026
At least once a year, my friend group suddenly gets the urge to play Minecraft again for about two weeks after which most people get bored/burnt out and quit.
And every year, that means I have to go dig up the server, update server.jar, and then update every single plugin
that goes with it, one by one, praying none of them broke compatibility since last time.
Considering the fact that I am lazy, this year I did something different: instead of taking 30 minutes to do this manually, I decided to spend multiple hours to build a proper CI/CD pipeline to automate it. (I mean… I already host a forgejo instance.)
The plugin problem
I run a Paper server with a wide range of “essential” plugins: homes, warps, economy, claims, chat formatting,
auction house, staff tools, …
Every one of those is a separate jar, a separate GitHub repo to check for
updates, and a separate compatibility risk when Paper bumps its API version.
From time to time I even built the jar locally because there was no prebuilt release on GitHub.
So intead I created an all-in-one plugin, Mars (private for now due to spaghetti code, I’d like to clean it up before anyone else has the displeasure of having to look at it).
One jar, one thing to update, one plugin.yml to keep track of.
This solved the “a dozen plugins” problem. What it didn’t solve is the “manually rebuild and re-upload a jar every time” problem. That’s where I came to use CI/CD workflows.
The pipeline
I already self-host a Forgejo instance, and Forgejo Actions provide you the same YAML-based workflow model as GitHub Actions. Here’s the loop I ended up with:
- Develop locally, run
./gradlew testagainst the MockBukkit test suite as I go. - Push to Forgejo.
test.yamlruns on every push againstmain, so broken commits get caught before getting tagged. - Tag a release (
vX.Y.Z) once I am actually ready to ship the changes. release.yamlstarts on the tag push, builds the plugin against all listed Paper API versions supported (currently only26.2and26.3), and drafts a Forgejo release with themars-<mc_version>-<tag>.jarfiles for each of the minecraft versions.deploy.yamlis manual (workflow_dispatch), I manually trigger it once I actually want the change live. The action SFTPs into the game panel (Pelican), detects which Paper version the server is actually running, rebuilds the jar for that specific version, and drops it into the plugins directory, replacing whatever pervious version was there. Finally, it restarts the server via the panel API and the new jar is live.
Testing (test.yaml)
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: linux-amd64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '25'
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew test --no-daemon
Nothing fancy, just gate every push on the test suite actually passing.
Releasing (release.yaml)
on:
push:
tags:
- 'v*.*.*'
jobs:
build-and-release:
runs-on: linux-amd64
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '25'
- uses: gradle/actions/setup-gradle@v4
- name: Build
run: |
for paperVersion in 26.2 26.3; do
./gradlew shadowJar --no-daemon -PpaperApiVersion=$paperVersion -Pversion=${{ github.ref_name }}
done
- name: Draft release
uses: https://code.forgejo.org/actions/forgejo-release@v2
with:
direction: upload
release-dir: release-dir
tag: ${{ github.ref_name }}
draft: true
token: ${{ secrets.RELEASE_TOKEN }}
A tag push gets me a draft release with jars for every Paper version I care about, with zero manual builds.
Deploying (deploy.yaml)
- name: Detect the server's Paper version
run: |
VERSION=$(sftp -P "$SFTP_PORT" "$SFTP_USER@$SFTP_HOST" <<< "ls versions" \
| grep -Eo '[0-9]+\.[0-9]+(\.[0-9]+)?' | head -n1)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Build matching jar
run: ./gradlew shadowJar --no-daemon -PpaperApiVersion=${{ steps.detect.outputs.version }} -Pversion=${{ github.ref_name }}
- name: Upload jar and clean up old versions
run: |
JAR="build/libs/mars-${{ steps.detect.outputs.version }}-${{ github.ref_name }}.jar"
sftp -P "$SFTP_PORT" "$SFTP_USER@$SFTP_HOST" <<EOF
cd plugins
-rm mars-*.jar
put $JAR
bye
EOF
- name: Restart the server
run: |
curl -sf -X POST "$PANEL_URL/api/client/servers/$SERVER_ID/power" \
-H "Authorization: Bearer ${{ secrets.PELICAN_API_KEY }}" \
-d '{"signal":"restart"}'
Why this was worth it for a Minecraft plugin
None of this is complicated CI/CD by professional standards, it’s a test job, a build-and-release job, and a deploy job. But building it against something low-stakes and self-contained, like a Minecraft server only my friends and I touch, turned out to be a genuinely good way to actually internalize the concepts: tagging as a release trigger, artifact matrices for multiple target versions, secrets management for deploy credentials, and separating “build” from “ship” so a bad build never gets anywhere near production automatically.
Next time we want to start a new Minecraft server, all I need to do is tagging, pushing, and clicking the “build” button in Forgejo.