# 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](https://blog.vyarus.ru/using-github-packages-in-gradle-and-maven-projects), 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](https://central.sonatype.org/pages/ossrh-eol/) so I have to update my maven central publications and reviewed my snapshots approach.

Maven central [snapshots publication](https://central.sonatype.org/publish/publish-portal-snapshots/) 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](https://central.sonatype.org/publish/publish-portal-snapshots/):

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1752741781265/20262f20-6776-4761-8e10-b4dc8acc452c.png align="center")

It is also important to [generate a new token](https://central.sonatype.org/publish/generate-portal-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](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](https://central.sonatype.org/publish/publish-portal-ossrh-staging-api/). With it you can just change target maven repositories and don’t need to search for new plugins.

I use gradle [maven-publish](https://github.com/gradle-nexus/publish-plugin) plugin. In my case, configuration looks like:

```java
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](https://central.sonatype.org/publish/publish-portal-snapshots/#consuming-via-gradle) to access snapshots:

```java
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:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1752743582930/8e17a68a-295e-4fa4-9b82-0ae9062cfd9d.png align="center")

### Actions

For simplicity, my script split into 3 files:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1752744720619/78839fcd-7219-49f4-83f0-37918f770a97.png align="center")

(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:

```yaml
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](https://stackoverflow.com/a/48616954/5186390):

```yaml
      - 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](https://stackoverflow.com/a/67931765/5186390) 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](https://github.com/orgs/community/discussions/17245#discussioncomment-11222880)).

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

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

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

```yaml
  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`). It was also possible to just [inherit secrets](https://docs.github.com/en/enterprise-cloud@latest/actions/how-tos/sharing-automations/reuse-workflows#using-inputs-and-secrets-in-a-reusable-workflow).

### Snapshot publication

`publish-snapshot.yml`

```yaml
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](https://docs.github.com/en/enterprise-cloud@latest/actions/how-tos/sharing-automations/reuse-workflows#using-inputs-and-secrets-in-a-reusable-workflow):

```yaml
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):

```yaml
      - 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:

```yaml
      - 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`

```yaml
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:

```yaml
    defaults:
      run:
        working-directory: examples
```

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

### Dynamic library version

Scripts, descibed above, assume that actual library snapshot version is specified in the examples project. But we already extracted an actual project version, so it is possible to use for the examples project run (to avoid manual version changes after each release).

Declare custom [input](https://docs.github.com/en/enterprise-cloud@latest/actions/how-tos/sharing-automations/reuse-workflows#using-inputs-and-secrets-in-a-reusable-workflow) for examples workflow (`examples-CI.yml`):

```yaml
on:
  workflow_call:
    inputs:
      version:
        required: false
        type: string
```

And assign provided input to the environment variable:

```yaml
- name: Build and Check
  env:
    ORG_GRADLE_PROJECT_guiceyBom: ${{ inputs.version }}
  run: |
    chmod +x gradlew
    ./gradlew build --no-daemon
```

The main CI workflow (`CI.yml`) must specify library version for examples workflow:

```yaml
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
  with:
    version: ${{ needs.build.outputs.version }}
```

In my case, required library version was declared explicitly in `build.gradle`:

```java
ext {
    guiceyBom = '6.3.2'
    dwVersion = '3.0.14'
}
```

But this **would override** the environment variable (`ORG_GRADLE_PROJECT_guiceyBom`) value. To workaround it, assigning default only if value is not already set:

```java
ext {
    guiceyBom = findProperty('guiceyBom') ?: '6.3.2'
    dwVersion = '3.0.14'
}
```

This way, it will use environment variable on CI and the default value for local runs.

### Summary

Action execution would look like this:

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1752746052794/a9fc27ee-f7ef-4b40-a1d1-43de1dd598cd.png align="center")

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 (with dynamic library version for examples run) [could be found here](https://github.com/xvik/dropwizard-guicey/tree/dw-3/.github/workflows)

More details could be found in [this blog post](https://simonscholz.dev/tutorials/publish-maven-central-gradle), which helped me a lot.
