Automating PaperMC Plugin Releases with GitHub Actions

I set up an environment at home to practice running the PDCA cycle through Minecraft, and as a supportive tool for that, I created a whiteboard plugin.

Honestly, if you’re just running the PDCA cycle for yourself, publishing to a distribution site isn’t strictly necessary. However, with the mindset of “since I went through the trouble of making it, let’s publish it so others can use it too," I set up a distribution pipeline. Specifically, I made it possible to publish directly to Hangar (PaperMC’s official distribution site) using just GitHub Actions, creating a workflow that allows me to focus on improving the README and taking screenshots from now on.

目次

Tools and Versions

Element Version Role
Java (JDK) 21 Mandatory requirement for Paper 1.20.5 and later. Unified to JDK 21 for both local and CI environments.
Gradle (Wrapper) 9.1.0 Java build automation tool. Automates building and publishing. Make sure to commit gradle/wrapper/gradle-wrapper.jar.
Paper 1.21.9 Minecraft server
GitHub Java source repository, Issue management, Release management.
GitHub Actions Automated build & publishing (push → Snapshot, tag → Release). Stores HANGAR_API_TOKEN in Secrets.
Hangar PaperMC’s official plugin distribution site. Used for Project creation, Channel setup (color required for Snapshot), and API Key generation.
Hangar API key Used by GitHub to upload plugin files to Hangar.
Registered as HANGAR_API_TOKEN in GitHub repository Secrets.

What You Will Gain From This Article (Overview)

  • Creating a project on Hangar and publishing the plugin
  • Generating an API Key on Hangar and saving it to GitHub Repository Secrets
  • Placing necessary files in the GitHub repository (correct location for plugin.yml, committing the Gradle Wrapper JAR)
  • Using GitHub Actions for:
    • Pushing to main → Automatic Snapshot publication
    • Pushing a v* tag → Automatic Release publication

Step 1: Initial Setup on Hangar

  1. Create Project
    Determine the name and slug to create it (will be used later in Gradle’s id.set(“")).
  2. Add Channel (Snapshot) *The Release channel exists by default
    Projects → Channels → New
    Name: Snapshot, make sure to select a Color (without a color, it will fail via API with …noColor).
  3. Generate API Key
    Account Settings → API Keys → Create
    Save it later in GitHub Secrets (do not hardcode it in the repository).

Step 2: Minimum GitHub Repository Structure

whiteboard/
├─ build.gradle.kts
├─ settings.gradle.kts
├─ gradle/wrapper/gradle-wrapper.jar   ← ★ Make sure to commit the JAR as well
├─ gradle/wrapper/gradle-wrapper,properties
├─ gradlew / gradlew.bat                             ← gradlew requires execute permission (+x)
├─ src/main/java/net/nando256/whiteboard/WhiteboardPlugin.java
└─ src/main/resources/plugin.yml                     ← ★ Place here (do not put directly under root)

plugin.yml

// build.gradle.kts (Excerpt)
import io.papermc.hangarpublishplugin.model.Platforms

plugins {
    java
    id("io.papermc.hangar-publish-plugin") version "0.1.3"
}

group = "net.nando256"
// Use version.override if supplied from CI, otherwise use local default
version = (findProperty("version.override") as String?) ?: "0.0.0-local"

java { toolchain.languageVersion.set(JavaLanguageVersion.of(21)) }

repositories {
    mavenCentral()
    maven("https://repo.papermc.io/repository/maven-public/")
}

dependencies {
    compileOnly("io.papermc.paper:paper-api:1.21.9-R0.1-SNAPSHOT")
}

// Expand ${version} in plugin.yml using Gradle's version
tasks.processResources {
    filesMatching("plugin.yml") { expand("version" to project.version) }
}

hangarPublish {
    publications.register("plugin") {
        id.set("whiteboard")                         // ← Hangar project slug
        version.set(project.version.toString())
        channel.set(providers.gradleProperty("hangar.channel").orElse("Snapshot"))
        apiKey.set(System.getenv("HANGAR_API_TOKEN"))
        platforms {
            register(Platforms.PAPER) {
                jar.set(tasks.jar.flatMap { it.archiveFile })
                platformVersions.set(listOf("1.21.9")) // Multiple versions can be specified
            }
        }
    }
}

Step 4: GitHub Actions (push = Snapshot / tag = Release)

.github/workflows/publish.yml

name: Publish to Hangar

on:
  push:
    branches: [ "main" ]
    tags: [ "v*" ]

jobs:
  build-and-publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: JDK 21
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 21

      - name: Gradle
        uses: gradle/actions/setup-gradle@v4

      - name: Determine channel & version
        id: meta
        run: |
          if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
            echo "channel=Release" >> $GITHUB_OUTPUT
            echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
          else
            echo "channel=Snapshot" >> $GITHUB_OUTPUT
            echo "version=0.0.0-${GITHUB_SHA::7}-SNAPSHOT" >> $GITHUB_OUTPUT
          fi

      - name: Build
        run: ./gradlew -q clean build

      - name: Publish to Hangar
        env:
          HANGAR_API_TOKEN: ${{ secrets.HANGAR_API_TOKEN }}
        run: |
          ./gradlew \
            -Phangar.channel=${{ steps.meta.outputs.channel }} \
            -Pversion.override=${{ steps.meta.outputs.version }} \
            publishPluginPublicationToHangar

Registering Secrets

GitHub → Settings → Secrets and variables → Actions → New repository secret

  • Name: HANGAR_API_TOKEN
  • Value: (API Key generated on Hangar)

Operational Rules (Just Remember These)

  • During Development: Push to main → Automatically published as a Snapshot (0.0.0--SNAPSHOT)
  • For Release: git tag v0.1.0 && git push --tags → Automatically published as a Release
  • Version Name Conflicts: Hangar does not allow duplicate names even across different channels. If a conflict occurs, bump the tag or delete the older version.

Common Issues and Quick Fixes

  • version.new.error.channel.noColor
    → Create the Snapshot channel via the UI and “assign a color"
  • version.new.error.duplicateNameAndPlatform
    → A version with the same name already exists. Avoid collisions by including the SHA for Snapshots and using tags for Releases.
  • Unable to access … gradle-wrapper.jar
    → Wrapper JAR is not committed. Review .gitignore and run git add -f gradle/wrapper/gradle-wrapper.jar
  • Build/Startup Failure
    → Assumes Java 21 (fix both local and CI to 21), ensure plugin.yml is under src/main/resources/

Conclusion (Leave Publishing to Automation, Focus Entirely on Creation)

Publishing isn’t strictly required to run a PDCA cycle. However, putting what you’ve made out into the world might help others learn and play.
Following the steps in this article, distribution is reduced to just pushing or tagging, leaving you with more time to spend on READMEs, screenshots, and feature improvements. It takes very little effort while delivering huge benefits—that’s what building this distribution pipeline is all about.