Publish snapshot into maven central from github action

Note: post covers only Gradle, but it could be easilly adopted for Maven (github action would be nearly the same - all important moments described; some maven-related links included)

Suppose you developed a library and related examples in a single repository (e.g. examples stored as a separate project in the examples directory). It would be great to not only run library tests on CI, but also check examples compatibility with the latest snapshot. In theory it’s simple: run library tests, publish snapshot and run examples with a just published snapshot.

Previously, I have tried to use github packages for snapshots publication, but this was far from perfect:

  1. Github packages are not accessible without authorization - users have to create their own github tokens in order to access snapshots.

  2. Each published snapshot is counted as a separate package and so, after some time, you have to clean up outdated versions manually (becuase storage space is limited)

Recently, sonatype sunset OSSRH publication so I have to update my maven central publications and reviewed my snapshots approach.

Maven central snapshots publication specifics:

  1. Snapshot is removed after 90 days (more than enough)

  2. No signing is required (and overall publication validation is disabled)

  3. Published snapshots are available immediately (in contrast to releases, which are available after ~1h)

  4. Users would have to use a custom repository to access snapshots (but without required authorization!)

Sonatype configuration

Important: It is assumed that your namespace was already migrated (migration ended on 30th of June 2025). If you did not perform migration manually, it would be performed automatically (in some time).

First of all, snapshots must be enabled for your namespace:

It is also important to generate a new token. Even if you already have token, generated before June 30th, it’s better to re-generate it (there are many messages on the sonatype forum about 401 problems with the old tokens).

Open https://central.sonatype.com/account and hit “generate token”. Copy generated tokens (user and password tokens) because window will close in 1 minute.

Project configuration

In spite of the fact that OSSRH is shut down, sonatype provides a OSSRH-compatible publication API. With it you can just change target maven repositories and don’t need to search for new plugins.

I use gradle maven-publish plugin. In my case, configuration looks like:

nexusPublishing {
    repositories {
        sonatype {
            nexusUrl = uri("https://ossrh-staging-api.central.sonatype.com/service/local/")
            snapshotRepositoryUrl = uri("https://central.sonatype.com/repository/maven-snapshots/")
            username = findProperty('sonatypeUser')
            password = findProperty('sonatypePassword')
        }
    }
}

Only urls are important here. The plugin configures sonatype maven publication, which is only important for snapshot publication (you can configure maven publication manually - only release would require additinoal actions, handled by this plugin).

Locally, sonatypeUser and sonatypePassword properties could be specified in the global gradle properties file (~/.gradle/gradle.properties). On CI, environment variables would be used (ORG_GRADLE_PROJECT_sonatypeUser and ORG_GRADLE_PROJECT_sonatypePassword).

For the examples project, it is required to configure a custom repository to access snapshots:

repositories {
    mavenLocal()
    mavenCentral()
    maven {
        name = 'Central Portal Snapshots'
        url = 'https://central.sonatype.com/repository/maven-snapshots/'
        mavenContent {
            snapshotsOnly()
            includeGroupAndSubgroups('ru.vyarus')
        }
    }
}

For security reasons, repository should be limited to snapshots and one group (in my case sub groups are aslo included because they are used by library modules).

Github actions

Credentials

Generated sonatype tokens must be declared as a github repositry secrets:

Actions

For simplicity, my script split into 3 files:

(ignore dependencies.yml - it updates project dependencies for github dependency graph)

CI script runs build matrix for multiple java versions: check build, run tests (publish coverage data). Also, CI script runs snapshot publication and examples workflows, if required:

name: CI

on:
  push:
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest
    name: Java ${{ matrix.java }}
    strategy:
      fail-fast: false
      matrix:
        java: [11, 17, 21]
    outputs:
      version: ${{ steps.project.outputs.version }}
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK ${{ matrix.java }}
        uses: actions/setup-java@v4
        with:
          distribution: 'zulu'
          java-version: ${{ matrix.java }}

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v3

      - name: Build
        run: |
          chmod +x gradlew
          ./gradlew assemble --no-daemon

      - name: Test
        run: ./gradlew check --no-daemon

      - name: Extract Project version
        id: 'project'
        run: |
          ver=$(./gradlew :properties --property version --no-daemon --console=plain -q | grep "^version:" | awk '{printf $2}')
          echo "Project version: $ver"
          echo "version=$ver" >> $GITHUB_OUTPUT

  publish:
    if: ${{ github.ref == 'refs/heads/dw-3' && github.event_name != 'pull_request' && endsWith(needs.build.outputs.version, '-SNAPSHOT') }}
    needs: build
    uses: ./.github/workflows/publish-snapshot.yml
    # workflow can't see secrets directly
    secrets:
      sonatype_user: ${{ secrets.SONATYPE_USERNAME }}
      sonatype_password: ${{ secrets.SONATYPE_PASSWORD }}

  examples:
    if: ${{ github.ref == 'refs/heads/dw-3' && github.event_name != 'pull_request' && endsWith(needs.build.outputs.version, '-SNAPSHOT') }}
    needs: [build, publish]
    uses: ./.github/workflows/examples-CI.yml

Snapshot publication must not be performed for pull requests, forks and on release (including release tag).

In order to prevent snapshot publication for release we need to know project version:

      - name: Extract Project version
        id: 'project'
        run: |
          ver=$(./gradlew :properties --property version --no-daemon --console=plain -q | grep "^version:" | awk '{printf $2}')
          echo "Project version: $ver"
          echo "version=$ver" >> $GITHUB_OUTPUT

Note: —property version works starting from Gradle 7.5. For older gradle versions, this part could be removed and still the correct version value would be selected.

(for maven, help:evaluate could be used for version extraction: mvn help:evaluate -Dexpression=project.version -q -DforceStdout)

echo "version=$ver" >> $GITHUB_OUTPUT publish extracted version as a build step output. Note that version is published for each matrix step (it’s almost immediate, so not an issue; just in case, if you want to store separate data from matrix steps, see this post).

In order to use stored version in the separate job, we should declare it as a build job’s output:

    outputs:
      version: ${{ steps.project.outputs.version }}

After successful build, snapshot publication run from the separate file:

  publish:
    if: ${{ github.ref == 'refs/heads/dw-3' && github.event_name != 'pull_request' && endsWith(needs.build.outputs.version, '-SNAPSHOT') }}
    needs: build
    uses: ./.github/workflows/publish-snapshot.yml
    # workflow can't see secrets directly
    secrets:
      sonatype_user: ${{ secrets.SONATYPE_USERNAME }}
      sonatype_password: ${{ secrets.SONATYPE_PASSWORD }}

needs: build would hold this job until build job completion

if would allow snapshot publication only for direct branch commit and only for snapshot versions. Note that it reference build job outputs, referenced from needs (needs.build.outputs.version)

It is important to bypass required secrets here because they would not be availble in a separate worklow (executed under workflow_call)

Snapshot publication

publish-snapshot.yml

name: Publish snapshot

on:
  workflow_call:
    secrets:
      sonatype_user:
        required: true
      sonatype_password:
        required: true
jobs:
  publish:
    name: Publish snapshot
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK
        uses: actions/setup-java@v4
        with:
          distribution: 'zulu'
          java-version: 17

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v3

      - name: Build without tests
        run: |
          chmod +x gradlew
          ./gradlew build -x check --no-daemon

      - name: Publish
        env:
          ORG_GRADLE_PROJECT_sonatypeUser: ${{ secrets.sonatype_user }}
          ORG_GRADLE_PROJECT_sonatypePassword: ${{ secrets.sonatype_password }}
        run: ./gradlew publishToSonatype

This workflow is triggered by the main script and can’t access secrets (github actions restriction) and so required secrets must be declared:

on:
  workflow_call:
    secrets:
      sonatype_user:
        required: true
      sonatype_password:
        required: true

As it is a seprate workflow, we have to checkout and build project again (but without tests now):

      - name: Build without tests
        run: |
          chmod +x gradlew
          ./gradlew build -x check --no-daemon

And, finally, we need to call publishToSonatype task to publish snapshot:

      - name: Publish
        env:
          ORG_GRADLE_PROJECT_sonatypeUser: ${{ secrets.sonatype_user }}
          ORG_GRADLE_PROJECT_sonatypePassword: ${{ secrets.sonatype_password }}
        run: ./gradlew publishToSonatype

Note that required credentials passed as environment variables (ORG_GRADLE_PROJECT_sonatypeUser), which gradle would apply as a project properties value.

After successful snapshot publication, examples could be run

Examples run

examples-CI.yml

name: Examples CI

on:
  workflow_call:

jobs:
  build:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: examples
    name: Java ${{ matrix.java }}
    strategy:
      matrix:
        java: [11, 17]

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK ${{ matrix.java }}
        uses: actions/setup-java@v4
        with:
          distribution: 'zulu'
          java-version: ${{ matrix.java }}

      - name: Setup Gradle
        uses: gradle/actions/setup-gradle@v3

      - name: Build and Check
        run: |
          chmod +x gradlew
          ./gradlew build --no-daemon

Again, as we have a separate workflow, project must be cheked out again.

Default directory changed to examples where separate gradle project is stored:

    defaults:
      run:
        working-directory: examples

Correct snapshot version and required maven repository is configured in the examples project.

Summary

Action execution would look like this:

Publication waits build copletion (in case of build error - no need to publish snapshot). And examples wait for snapshot publication (and so will not run if publication fails).

Full scripts could be found here

More details could be found in this blog post, which helped me a lot.

0
Subscribe to my newsletter

Read articles from Vyacheslav Rusakov directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Vyacheslav Rusakov
Vyacheslav Rusakov