Clojure: CI with Github Actions and Postgres
I recently had to move a project to java 21 for virtual threads and Generational ZGC. Unfortunately, I couldn't find an easy way to use Circle CI with Clojure and Java 21. So I figured I'd give Github Actions a go.
This guide will only cover using Github Actions to run tests, some of which are run against a Postgres database. It also assumes you are using tools.deps and your deps.edn
looks something like this:
{...
:aliases
{:dev
{:extra-paths ["test" "dev"]
:jvm-opts ["-Duser.timezone=UTC"
"-XX:+UseZGC"
"-XX:+ZGenerational"]
:extra-deps
{io.github.cognitect-labs/test-runner
{:git/tag "v0.5.1" :git/sha "dfb30dd"}
ring/ring-mock {:mvn/version "0.4.0"}}}
:test
{:main-opts ["-m" "cognitect.test-runner"]
:exec-fn cognitect.test-runner.api/test}
:migrate-test-db
{:main-opts ["-m" "server.test-db"]
:exec-fn server.test-db/migate-test-db}}
...}
We need to create a .github/workflows/ci.yml
file (the file itself doesn't have to be called ci.yml
it can be called anything you want e.g: foo.yml
). With the following contents:
name: CI
on: [push]
jobs:
build:
name: Build
runs-on: ubuntu-latest
env:
TEST_DATABASE_URL: "postgres://username:password@localhost:5432/test-database-name"
services:
# Label used to access the service container
postgres:
# Docker Hub image
image: postgres
env:
POSTGRES_DB: test-database-name
POSTGRES_USER: username
POSTGRES_PASSWORD: password
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
# Maps tcp port 5432 on service container to the host
- 5432:5432
steps:
- name: Check out repository code
uses: actions/checkout@v4
- name: Prepare java
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '21'
- name: Install clojure tools
uses: DeLaGuardo/setup-clojure@12.5
with:
cli: 1.11.2.1446
cljfmt: 0.10.2
- name: Cache clojure dependencies
uses: actions/cache@v4
with:
path: |
~/.m2/repository
~/.gitlibs
~/.deps.clj
# List all files containing dependencies:
key: cljdeps-${{ hashFiles('deps.edn') }}
restore-keys: cljdeps-
- name: Check formatting
run: cljfmt check
- name: Run migration on test db
run: clojure -X:dev:migrate-test-db
- name: Run tests
run: clojure -X:dev:test
That's basically it. This example doesn't specify a version of Postgres, so you'll want to specify the same version as your production environment. The list of docker hub tags can be found here.
You'll also want your Clojure environment variables that specify db name, username and password (or in this case a connection url):
...
env:
TEST_DATABASE_URL: "postgres://username:password@localhost:5432/test-database-name"
...
To match the db name, username and password specified in the Postgres environment:
...
image: postgres
env:
POSTGRES_DB: test-database-name
POSTGRES_USER: username
POSTGRES_PASSWORD: password
...
That's it. Your are good to go.
But wait! I know what you're thinking. Yuk YAML! Where are all the parentheses?
Well if you have babashka installed you can live a YAML free existence thanks to clj-yaml (which ironically is a fork of Circle CI's clj-yaml, the same Circle CI we are migrating away from).
clj-yaml is built into babashka, so we can get straight into it.
(require '[clj-yaml.core :as yaml])
(defn ->db-url [db-name username password]
(str "postgres://" username ":" password "@localhost:5432/" db-name))
(defn postgres [db-name username password]
{:postgres
{:image "postgres"
:env
{:POSTGRES_DB db-name
:POSTGRES_USER username
:POSTGRES_PASSWORD password}
:options
"--health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5"
:ports ["5432:5432"]}})
(def checkout-code
{:name "Check out repository code" :uses "actions/checkout@v4"})
(def java
{:name "Prepare java"
:uses "actions/setup-java@v4"
:with {:distribution "zulu" :java-version "21"}})
(def clojure-tools
{:name "Install clojure tools"
:uses "DeLaGuardo/setup-clojure@12.5"
:with {:cli "1.11.2.1446" :cljfmt "0.10.2"}})
(def cache
{:name "Cache clojure dependencies"
:uses "actions/cache@v4"
:with
{:path "~/.m2/repository\n~/.gitlibs\n~/.deps.clj\n"
:key "cljdeps-${{ hashFiles('deps.edn') }}"
:restore-keys "cljdeps-"}})
(def check-formatting
{:name "Check formatting" :run "cljfmt check"})
(def run-migrations
{:name "Run migration on test db"
:run "clojure -X:dev:migrate-test-db"})
(def run-tests
{:name "Run tests" :run "clojure -X:dev:test"})
(def ci
(let [db-name "test-database-name"
username "username"
password "password"]
{:name "CI"
true ["push"]
:jobs
{:build
{:name "Build"
:runs-on "ubuntu-latest"
:env {:TEST_DATABASE_URL
(->db-url db-name username password)}
:services (postgres db-name username password)
:steps
[checkout-code
java
clojure-tools
cache
check-formatting
run-migrations
run-tests]}}}))
;; Check our generated YAML is semantically the same
;; as our original handwritten YAML.
(= (yaml/parse-string (slurp "ci.yml"))
ci)
(spit "generated-ci.yml" (yaml/generate-string ci))
Overkill for most projects I imagine. But if you ever end up doing a lot of things with Github Actions, Circle CI or any other YAML heavy interface clj-yaml
+ babashka
can give you something more composable and easier to manage.