Show Navigation

GitHub Actions CI/CD with Grails 8

End-to-end CI/CD pipeline for a Grails 8 application: a multi-stage job graph (validation, unit, integration, functional) with PostgreSQL service containers and Geb + Testcontainers, plus a tag-triggered release workflow that builds the OCI image with bootBuildImage and pushes it to GHCR.

Authors: James Fredley

Grails Version: 8

1 Getting Started

In this guide you will set up a complete GitHub Actions CI/CD pipeline for an Apache Grails 8 application. The pipeline replaces the patterns in the seven-year-old Grails on GitHub Actions guide, all of whose actions are now deprecated.

You will end up with three files in .github/:

  • workflows/ci.yml - a four-stage job graph (validation → unit tests → integration tests → functional tests) that runs on every push and pull request to the grails8 branch.

  • workflows/release.yml - tag-triggered release pipeline that builds the OCI image with bootBuildImage (from the Docker bootBuildImage guide), pushes to GitHub Container Registry, and creates a GitHub Release with the bootJar attached.

  • dependabot.yml - weekly Gradle and GitHub Actions updates with grouped Grails / Spring / testing PRs.

This guide targets Apache Grails 8 / Spring Boot 4 / JDK 21.

1.1 What You Will Build

By the end of the guide your project will have:

  • actions/setup-java@v4 pinning the JDK to Liberica 21 (the same distribution Paketo’s bootBuildImage uses, so build-time and runtime bytecode levels agree).

  • gradle/actions/wrapper-validation@v4 running before any other Gradle invocation, so a tampered gradle-wrapper.jar cannot exfiltrate build-time secrets.

  • gradle/actions/setup-gradle@v4 configuring the Gradle build cache + dependency cache so the unitTest, integrationTest, and functionalTest jobs each cold-start in seconds, not minutes.

  • A multi-stage job graph - validationunit-test + integration-testfunctional-testcoverage - so failures are isolated to the layer that broke and functional-test does not run if the unit suite is already broken.

  • PostgreSQL service containers for the integration-test job and Testcontainers-driven Postgres for the functional-test job (Geb specs against the real booted app).

  • A separate release.yml triggered on v*.. tags that builds the OCI image with bootBuildImage, pushes to GHCR, and attaches the bootJar to a GitHub Release.

  • A codecov/codecov-action@v5 upload from the coverage job.

  • A dependabot.yml covering Gradle and GitHub Actions, with grouped PRs so a Grails minor bump arrives as one PR instead of fifteen.

  • A README badge that turns green when the latest build on grails8 passes.

1.2 What You Will Need

To complete this guide you will need:

  • JDK 21

  • A GitHub account that owns or has push access to the repository hosting the application

  • About 30 minutes

1.3 How to Complete the Guide

You can either type the YAML in this guide as you read or skip ahead and clone the finished sample app:

git clone -b grails8 https://github.com/grails-guides/grails-github-actions-cicd.git
cd grails-github-actions-cicd/complete
./gradlew test

The repository contains two top-level directories:

  • initial/ - the starting Grails 8 project, generated from start.grails.org with the postgres and testcontainers features.

  • complete/ - the same project with the three workflow YAML files added under .github/.

2 Creating the Application

Generate a fresh Apache Grails 8 web application from start.grails.org with the two features the rest of the guide expects: postgres and testcontainers. The functional tests use ContainerGebSpec from org.apache.grails:grails-geb, which runs the browser in a Testcontainers Selenium container - no geb-with-webdriver-binaries feature is needed.

2.1 Download a Grails 8 Starter

Open start.grails.org, pick the web profile, name the application cicd, set the package to example, choose JDK 21, tick the postgres and testcontainers features, and download the generated zip. Unzip it and cd into the cicd directory.

The forge unpacks into a cicd/ directory containing a vanilla Grails 8 web layout with two feature-driven additions:

  • runtimeOnly "org.postgresql:postgresql" and a Postgres datasource template

  • testImplementation "org.testcontainers:postgresql" (and org.testcontainers:spock + :testcontainers)

Geb container support comes from testFixtures("org.apache.grails:grails-geb"), which is already on the test classpath in the standard web profile - no extra forge feature is needed.

3 The Multi-Stage Job Graph

The CI workflow is intentionally split into five jobs that form a directed acyclic graph:

                  validation
                  /         \
            unit-test    integration-test
                  \         /
                   coverage         functional-test

validation runs first and is the gate for everything else. It validates the Gradle wrapper, sets up the toolchain, and compiles classes + testClasses so that any compile-time error fails fast in under a minute.

unit-test and integration-test run in parallel, both depending on validation. They are the bulk of the runtime - 80% of the wall-clock budget. Splitting them lets you see in ActionsSummary whether the regression is in business logic (unit tests) or in database/transaction wiring (integration tests).

functional-test depends on integration-test (a green integration suite is a precondition for booting the app and driving it with Geb). It uses Testcontainers for Postgres rather than a service container so it can be the same Spock specs you run locally.

coverage depends on unit-test and integration-test and uploads the merged JaCoCo report to Codecov. It does not block PR review on coverage drops; that is a soft gate that lives in Codecov’s PR comment.

4 The Canonical ci.yml

Create the workflow file at .github/workflows/ci.yml:

.github/workflows/ci.yml
name: CI

on:
  push:
    branches: [grails8]
  pull_request:
    branches: [grails8]

permissions:
  contents: read

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:

  validation:
    name: Wrapper validation + compile
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: liberica
          java-version: 21
      - uses: gradle/actions/wrapper-validation@v4
      - uses: gradle/actions/setup-gradle@v4
        with:
          cache-read-only: ${{ github.ref != 'refs/heads/grails8' }}
      - run: ./gradlew --no-daemon classes testClasses

  unit-test:
    name: Unit tests
    needs: validation
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: liberica
          java-version: 21
      - uses: gradle/actions/setup-gradle@v4
        with:
          cache-read-only: true
      - run: ./gradlew --no-daemon test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: unit-test-reports
          path: build/reports/tests/test/

  integration-test:
    name: Integration tests
    needs: validation
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: testDb
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
        ports: ['5432:5432']
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: liberica
          java-version: 21
      - uses: gradle/actions/setup-gradle@v4
        with:
          cache-read-only: true
      - run: ./gradlew --no-daemon integrationTest
        env:
          SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/testDb
          SPRING_DATASOURCE_USERNAME: postgres
          SPRING_DATASOURCE_PASSWORD: postgres
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: integration-test-reports
          path: build/reports/tests/integrationTest/

  functional-test:
    name: Functional tests (Geb + Testcontainers)
    needs: integration-test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: liberica
          java-version: 21
      - uses: gradle/actions/setup-gradle@v4
        with:
          cache-read-only: true
      - run: ./gradlew --no-daemon functionalTest
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: geb-reports
          path: |
            build/reports/tests/functionalTest/
            build/geb-reports/

  coverage:
    name: Upload coverage
    needs: [unit-test, integration-test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: liberica
          java-version: 21
      - uses: gradle/actions/setup-gradle@v4
        with:
          cache-read-only: true
      - run: ./gradlew --no-daemon jacocoTestReport
      - uses: codecov/codecov-action@v5
        with:
          files: build/reports/jacoco/test/jacocoTestReport.xml
          fail_ci_if_error: false
        env:
          CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

Three header blocks set the contract for every job:

  • on: triggers on every push and pull request to the grails8 branch. We narrow to one branch on push (no point running CI for a feature branch’s intermediate commits if a PR is going to do it anyway) but accept PRs from any branch.

  • permissions: contents: read follows the principle of least privilege: the CI workflow can only read the repo, not write to it. The release workflow asks for contents: write and packages: write separately.

  • concurrency: cancels older runs when a new push lands on the same ref. This stops a long functional-test run from blocking a fix-up commit; the older run is killed and the newer one starts immediately.

Each job uses runs-on: ubuntu-latest, actions/checkout@v4, actions/setup-java@v4 (with distribution: liberica and java-version: 21), and gradle/actions/setup-gradle@v4. The setup-gradle action manages the build cache and dependency cache; setting cache-read-only: true on consumer jobs (anything except the writer on grails8) means PR builds get a free read-through cache without polluting the cache with PR-specific artefacts.

5 The Unit-Test Job

The unit-test job runs just ./gradlew test:

  unit-test:
    name: Unit tests
    needs: validation
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: liberica
          java-version: 21
      - uses: gradle/actions/setup-gradle@v4
        with:
          cache-read-only: true
      - run: ./gradlew --no-daemon test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: unit-test-reports
          path: build/reports/tests/test/

Two patterns worth pointing at:

  • if: failure() on the artefact upload uploads test reports only when the job fails. On success there is no point in carrying around index.html files; on failure they are the first thing you click on.

  • --no-daemon ensures the JVM starts cold and reports honest first-run times. The Gradle dependency cache is still warm; the daemon caching disagreement was about JVM warm-up, not classpath resolution.

6 The Integration-Test Job (Postgres Service Container)

The integration-test job needs a database. GitHub Actions service containers are the cheapest path: declare a services.postgres block, GitHub starts the container before the steps run, and the integration test connects to it via localhost:5432:

    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: testDb
          POSTGRES_USER: postgres
          POSTGRES_PASSWORD: postgres
        ports: ['5432:5432']
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s
          --health-timeout 5s
          --health-retries 10

The three datasource environment variables on the ./gradlew integrationTest step (SPRING_DATASOURCE_URL, _USERNAME, _PASSWORD) match the ${SPRING_DATASOURCE_URL:fallback} placeholders in application.yml from the Docker bootBuildImage envProfiles chapter, so the same wiring serves both CI and Compose.

For the functional tests in the next chapter we switch from a service container to Testcontainers' Postgres. The trade-off: service containers are faster to spin up (the postgres image is already cached on the runner) but force every test in the suite to share the same database. Testcontainers gives each spec its own fresh container at the cost of an extra container start per spec class. For functional tests where database state isolation matters more than wall-clock speed, Testcontainers wins.

7 The Functional-Test Job (Geb + Testcontainers)

The functional-test job boots the application against a Testcontainers-managed Postgres and drives it with Geb 8 specs that extend ContainerGebSpec (from testFixtures("org.apache.grails:grails-geb")). The browser itself runs in a Selenium-Chrome container started by Testcontainers, so the runner never needs Chrome/Firefox installed on the host. Because the job does not declare a services.postgres block, the runner is a plain Ubuntu VM; Testcontainers spins up both Postgres and Selenium on demand from the test JVM via the local Docker socket (which GitHub-hosted runners expose by default).

  functional-test:
    name: Functional tests (Geb + Testcontainers)
    needs: integration-test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: liberica
          java-version: 21
      - uses: gradle/actions/setup-gradle@v4
        with:
          cache-read-only: true
      - run: ./gradlew --no-daemon functionalTest
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: geb-reports
          path: |
            build/reports/tests/functionalTest/
            build/geb-reports/

if: always() on the geb-reports upload is intentional: even on a green run you may want the screenshots from build/geb-reports/ to confirm the page rendered as expected. On a red run, those screenshots are how you debug "works on my machine" failures - the runner’s screenshot of the failed page is usually enough to spot the missing element.

No apt-get install of Chrome/Firefox is needed - the browser process lives inside the Selenium container Testcontainers pulls (selenium/standalone-chrome by default). The first cold run pulls the image (~200 MB) and is slower; subsequent runs reuse the cached layer. The legacy geb-with-webdriver-binaries forge feature is obsolete in Grails 8 and is not used here.

8 The Release Workflow

A separate workflow at .github/workflows/release.yml triggers on tag pushes and produces (a) an OCI image on GitHub Container Registry, (b) a GitHub Release with the bootJar attached:

.github/workflows/release.yml
name: Release

on:
  push:
    tags: ['v*.*.*']
  workflow_dispatch:
    inputs:
      version:
        description: 'Release version (e.g. 1.0.0)'
        required: true
        type: string

permissions:
  contents: write
  packages: write

jobs:

  build-and-publish:
    name: Build OCI image and create GitHub Release
    runs-on: ubuntu-latest
    steps:

      - uses: actions/checkout@v4

      - id: version
        name: Resolve release version
        run: |
          if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
            echo "version=${{ inputs.version }}" >> "$GITHUB_OUTPUT"
          else
            echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
          fi

      - uses: actions/setup-java@v4
        with:
          distribution: liberica
          java-version: 21

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

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build bootJar
        run: ./gradlew --no-daemon -Pversion=${{ steps.version.outputs.version }} bootJar

      - name: Build and publish OCI image
        run: |
          ./gradlew --no-daemon -Pversion=${{ steps.version.outputs.version }} \
            bootBuildImage \
            --imageName=ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }} \
            --publishImage \
          --publicationRegistry.username=${{ github.actor }} \
          --publicationRegistry.password=${{ secrets.GITHUB_TOKEN }}

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        if: startsWith(github.ref, 'refs/tags/')
        with:
          name: ${{ steps.version.outputs.version }}
          generate_release_notes: true
          files: build/libs/*.jar

Three pieces are doing the work:

  • on.push.tags: ['v*..'] triggers the workflow when a tag matching v1.2.3 is pushed. The matching workflow_dispatch block lets you run the same pipeline manually from the Actions tab against an arbitrary branch when you need an out-of-band release.

  • permissions: contents: write (for the GitHub Release) + packages: write (for the GHCR push) is the strict minimum the workflow needs. The two are declared at workflow level rather than job level only because every job in this workflow needs both.

  • The Resolve release version step extracts the version from the tag (v1.2.31.2.3) when triggered by tag push, or uses the manual input on workflow_dispatch. Every later step references ${{ steps.version.outputs.version }} so the version flows through to gradlew, the image tag, and the release name.

8.1 Pushing the OCI Image to GHCR

The Build and publish OCI image step in release.yml calls the same bootBuildImage task introduced in the Docker bootBuildImage guide, with two CI-specific flags:

  • --imageName=ghcr.io/${{ github.repository }}:${{ steps.version.outputs.version }} overrides the build.gradle default at runtime, ensuring every release lands at the canonical GHCR path.

  • --publishImage plus the registry credentials publish the image directly to the registry without a separate docker push step.

The credentials come from docker/login-action@v3, which uses ${{ secrets.GITHUB_TOKEN }} - a repository-scoped token that GitHub auto-provisions for every workflow run. No personal access token to mint, no secret to rotate, no audit trail to maintain. The token is automatically scoped to packages: write because we declared it at workflow level.

The first time the release workflow pushes to ghcr.io/your-org/your-repo, the package is created as private. Make it public via the GitHub UI (Package settings → Change visibility → Public) so anonymous pulls work. After that, every subsequent release inherits the visibility.

9 Dependabot for Gradle and Actions

A .github/dependabot.yml keeps dependency drift in check without you remembering to look:

.github/dependabot.yml
version: 2
updates:

  # Gradle dependencies (build.gradle, settings.gradle, buildSrc)
  - package-ecosystem: gradle
    directory: /
    schedule:
      interval: weekly
      day: monday
      time: '09:00'
      timezone: 'Etc/UTC'
    labels: ['dependencies', 'gradle']
    open-pull-requests-limit: 5
    groups:
      grails:
        patterns: ['org.apache.grails*']
      spring:
        patterns: ['org.springframework*']
      testing:
        patterns: ['org.spockframework*', 'org.testcontainers*', 'org.mockito*']

  # GitHub Actions versions in the workflow files themselves
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
      day: monday
      time: '09:00'
      timezone: 'Etc/UTC'
    labels: ['dependencies', 'github-actions']
    open-pull-requests-limit: 5

Two ecosystems get watched: gradle (your runtime + test dependencies declared in build.gradle) and github-actions (the actions referenced in the workflow YAML files themselves). Both run weekly on Monday morning to batch up the noise, and open-pull-requests-limit: 5 caps the in-flight PR count so a quiet week does not become a 30-PR avalanche.

The groups: block under gradle collapses what would otherwise be a flood of single-dependency bumps into three semantic PRs:

  • All org.apache.grails* updates land as one PR (so a Grails minor bump arrives intact, not as fifteen separate dependency bumps).

  • All org.springframework* updates land as one PR (Spring releases its modules together).

  • All testing-framework updates (Spock, Testcontainers, Mockito) land as one PR.

Anything that does not match a group falls back to one PR per dependency. The result is typically 5 to 8 PRs per week instead of 30+.

10 Required Status Checks

The CI workflow is only useful if a PR cannot be merged when it is red. Configure that in Settings → Branches → Branch protection rules → Add rule:

  • Branch name pattern: grails8 (and master, if you keep one).

  • Require a pull request before merging: on, with at least one approval.

  • Require status checks to pass before merging: on, Require branches to be up to date before merging: on. In the search box add the four required jobs: Wrapper validation + compile, Unit tests, Integration tests, Functional tests (Geb + Testcontainers). The Coverage job stays optional - a coverage drop is a hint, not a hard block.

  • Require linear history: on, if you prefer rebase merges over merge commits.

  • Do not allow bypassing the above settings: on, including for repository administrators (the OWASP A04 guidance applies to your own repo too).

Once branch protection is on, the Merge button on a PR turns grey until all four required checks return green. The PR author either fixes the failing job or asks the reviewer to override - which, with admin-bypass off, requires a second approval and an explicit force-merge.

11 README Build Status Badge

A green badge in your README is the cheapest way to signal "this project’s main branch is healthy". GitHub generates a per-workflow badge for free; copy the URL from Actions → CI → …​ → Create status badge and paste it as the first thing in the README:

[![CI](https://github.com/your-org/your-repo/actions/workflows/ci.yml/badge.svg?branch=grails8)](https://github.com/your-org/your-repo/actions/workflows/ci.yml)

The ?branch=grails8 query string is non-optional. Without it, the badge reflects the most recent run on any branch, including stale feature-branch runs from months ago. With it, the badge tracks the branch you actually ship from.

For projects with multiple workflows, add one badge per workflow. The badges line up nicely as a single bullet list at the top of the README.

12 Do you need help with Grails?

Help with Apache Grails

Apache Grails is supported by an active community of contributors and the Apache Software Foundation. If you need help working through a guide, want to discuss the framework, or have run into something that looks like a bug, the channels below are the right place to start.

For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.