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 thegrails8branch. -
workflows/release.yml- tag-triggered release pipeline that builds the OCI image withbootBuildImage(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@v4pinning the JDK to Liberica 21 (the same distribution Paketo’s bootBuildImage uses, so build-time and runtime bytecode levels agree). -
gradle/actions/wrapper-validation@v4running before any other Gradle invocation, so a tamperedgradle-wrapper.jarcannot exfiltrate build-time secrets. -
gradle/actions/setup-gradle@v4configuring the Gradle build cache + dependency cache so theunitTest,integrationTest, andfunctionalTestjobs each cold-start in seconds, not minutes. -
A multi-stage job graph -
validation→unit-test+integration-test→functional-test→coverage- so failures are isolated to the layer that broke andfunctional-testdoes not run if the unit suite is already broken. -
PostgreSQL service containers for the
integration-testjob and Testcontainers-driven Postgres for thefunctional-testjob (Geb specs against the real booted app). -
A separate
release.ymltriggered onv*..tags that builds the OCI image withbootBuildImage, pushes to GHCR, and attaches thebootJarto a GitHub Release. -
A
codecov/codecov-action@v5upload from thecoveragejob. -
A
dependabot.ymlcovering 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
grails8passes.
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 thepostgresandtestcontainersfeatures. -
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"(andorg.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 Actions → Summary 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:
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 thegrails8branch. 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: readfollows the principle of least privilege: the CI workflow can only read the repo, not write to it. The release workflow asks forcontents: writeandpackages: writeseparately. -
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 aroundindex.htmlfiles; on failure they are the first thing you click on. -
--no-daemonensures 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:
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 matchingv1.2.3is pushed. The matchingworkflow_dispatchblock 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 versionstep extracts the version from the tag (v1.2.3→1.2.3) when triggered by tag push, or uses the manual input onworkflow_dispatch. Every later step references${{ steps.version.outputs.version }}so the version flows through togradlew, 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. -
--publishImageplus the registry credentials publish the image directly to the registry without a separatedocker pushstep.
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 |
9 Dependabot for Gradle and Actions
A .github/dependabot.yml keeps dependency drift in check without you remembering to look:
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(andmaster, 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). TheCoveragejob 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:
[](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.
-
Slack - real-time conversation with the Apache Grails community.
-
Developer mailing list - design discussions and contributor coordination.
-
Users mailing list - end-user questions and answers.
-
Issue tracker on GitHub - file a bug or feature request against the framework.
For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.