A collection of sbt plugins for shared build settings across Gemini lucuma projects.
The plugins are split across several published artifacts. Most projects only need
one of sbt-lucuma-lib (for published libraries) or sbt-lucuma-app (for
applications) — both depend on the core sbt-lucuma artifact and pull in its plugins
transitively. The CSS, Docker, and jsdom artifacts are added as needed.
| Artifact | Add with | Provides |
|---|---|---|
sbt-lucuma |
(transitive — pulled by -lib/-app) |
LucumaPlugin, LucumaScalaJSPlugin, LucumaScalafmtPlugin, LucumaScalafixPlugin, LucumaBundleMonPlugin |
sbt-lucuma-lib |
addSbtPlugin("edu.gemini" % "sbt-lucuma-lib" % V) |
LucumaLibPlugin (+ core) |
sbt-lucuma-app |
addSbtPlugin("edu.gemini" % "sbt-lucuma-app" % V) |
LucumaAppPlugin (+ core) |
sbt-lucuma-css |
addSbtPlugin("edu.gemini" % "sbt-lucuma-css" % V) |
LucumaCssPlugin |
sbt-lucuma-docker |
addSbtPlugin("edu.gemini" % "sbt-lucuma-docker" % V) |
LucumaDockerPlugin (+ core) |
lucuma-jsdom |
libraryDependencies += "edu.gemini" %% "lucuma-jsdom" % V (in project/) |
LucumaJSDOMNodeJSEnv |
In the tables below, Activation is either:
- Automatic — the plugin enables itself on every (qualifying) project once it is on
the build classpath (
trigger = allRequirements); or - Opt-in — you must enable it explicitly with
.enablePlugins(...)on a project.
Activation: Automatic (on all projects, once -lib or -app is added).
The umbrella plugin tying everything together. It requires the relevant sbt-typelevel plugins plus the lucuma scalafmt/scalafix plugins, and configures sensible defaults across the build:
- Scala / JDK: Scala
3.9.0,tlJdkRelease := 25. - Publishing:
edu.geminiorganization, BSD-3-Clause license, developer list. - CI: fatal warnings in CI,
evictionErrorLevelfatal in CI / relaxed locally, header + scalafmt + scalafix checks wired into the workflow, Mergify config, doc/dependency jobs disabled. - Dependency downloads in CI: three defenses against spurious download failures. The sbt
cache key includes
ci.yml, so another workflow in the repo usingcache: sbt(an npm publish, a nightly) cannot save a partial cache under CI's key and leave CI downloading mid-test; other workflows read the cache as shown below. Thesbt updatestep, where the downloads happen on a cache miss, is retried up to 3 times 30s apart and also fetches the Scala 3 compiler bridge, which sbt otherwise resolves at the first compile. Coursier's own retries are raised where they apply: 10 resolution attempts 5s apart on "Connection timed out" and HTTP 5xx (csrConfiguration), and 10 download attempts on SSL errors (SBT_OPTSin the workflow). Coursier does not retry a plain connection reset, only failures to establish the connection, which is why the step-level retry exists. - Headers: BSD-3-Clause C++-style line-comment header, applied automatically
(
AutomateHeaderPlugin). - Git versioning and a
prePR/tlPrePrBotHookcommand alias that regenerates the workflow, headers, and scalafmt/scalafix configs.
Selected autoImport:
| Key | Description |
|---|---|
lucumaGlobalSettings, lucumaScalaVersionSettings, lucumaScalacSettings, lucumaScalacProjectSettings, lucumaPublishSettings, lucumaCiSettings, lucumaHeaderSettings, lucumaGitSettings, lucumaDocSettings, lucumaDockerComposeSettings, lucumaStewardSettings |
Reusable setting sequences, exposed so individual projects can opt in/out of pieces. |
ci.yml is the only workflow that should write the sbt dependency cache. A hand-written
workflow (an npm publish, a nightly) that also uses setup-java's cache: sbt races CI to
save the key, and a short job that resolves one project saves a partial cache. setup-java has
no read-only mode, so drop cache: sbt there and restore CI's cache with
actions/cache/restore, which never saves:
- uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 25
# no `cache: sbt` here
- name: Restore sbt cache (read-only, written by ci.yml)
uses: actions/cache/restore@v6
with:
# Must be byte-for-byte what setup-java v5 passes for `cache: sbt` on Linux, in this
# order: the cache "version" is a hash of this list, and a different list never matches.
path: |
/home/runner/.ivy2/cache
/home/runner/.sbt
/home/runner/.cache/coursier
!/home/runner/.sbt/*.lock
!/home/runner/**/ivydata-*.properties
key: setup-java-${{ runner.os }}-x64-sbt-${{ hashFiles('**/*.sbt', '**/project/build.properties', '**/project/**.scala', '**/project/**.sbt', '.github/workflows/ci.yml') }}
restore-keys: setup-java-${{ runner.os }}-x64-sbt-key reproduces CI's exact key (same globs as the generated cache-dependency-path; x64 is
spelled the way setup-java writes it, not runner.arch's X64). Check it once against the
Cache saved with the key: line in a CI log. restore-keys covers the window where CI is still
saving for the same commit, by taking the newest setup-java sbt cache visible to the branch.
Until every hand-written workflow in the repo has been converted, that fallback can also pick a
partial cache one of them saved, so convert them all. Anything the job needs beyond the
restored cache downloads normally and is not saved.
Activation: Automatic — requires ScalaJSPlugin, so it activates only on Scala.js
projects (i.e. the JS side of a crossProject).
- Sets
evictionErrorLevel := Level.Warnfor Scala.js projects. - Flaky test handling on Scala.js. MUnit reads
MUNIT_FLAKY_OKviaSystem.getenvat test runtime, butSystem.getenvalways returnsnullon Scala.js, so flaky tests cannot be honored at runtime there. WhenMUNIT_FLAKY_OK=trueis set, this plugin instead excludes flaky-tagged MUnit tests on Scala.js at the build level (--exclude-tags=Flaky, evaluated in the sbt JVM viasys.env). The JVM side is untouched and keeps MUnit's normal runtime behavior.
Activation: Automatic.
Manages a shared scalafmt config (.scalafmt-common.conf) generated from a resource bundled
in the plugin.
| Task | Description |
|---|---|
lucumaScalafmtGenerate |
Write the common scalafmt config to the build root. |
lucumaScalafmtCheck |
Fail if the on-disk config differs from the bundled one. |
Activation: Automatic.
The scalafix counterpart to the above, managing .scalafix-common.conf.
| Task | Description |
|---|---|
lucumaScalafixGenerate |
Write the common scalafix config to the build root. |
lucumaScalafixCheck |
Fail if the on-disk config differs from the bundled one. |
Activation: Automatic where BundleMonPlugin is present (requires LucumaPlugin &&
BundleMonPlugin).
Adds a "Monitor bundle size" CI step (runs bundleMon for the rootJS matrix project) and
sets bundleMonCompression := Brotli.
Activation: Automatic (requires LucumaPlugin). Disable with
ThisBuild / lucumaAffectedTests := false.
In CI, runs only the tests a pull request can break.
It finds the changed projects by matching changed files against each project's source and
resource directories, then adds everything that depends on them. The dependency graph comes
from sbt itself, so a new dependsOn is picked up with no extra config.
When it can't tell what changed, it runs everything. That happens when:
- a changed file matches
lucumaAffectedAlwaysPaths: the build (*.sbt,project/**,flake.*,.jvmopts), CI (.github/**), node (package*.json, lockfiles,.npmrc, since Scala.js tests run on it) ordocker-compose.yml - a changed file belongs to no project
- there is no base ref to diff against
Files matching lucumaAffectedIgnorePaths are dropped before any of that, so a PR touching only
those runs no tests at all. The defaults are deliberately narrow — docs (**.md, docs/**,
notes/**, LICENSE) and editor or environment files (.editorconfig, .envrc,
.gitattributes, .gitignore, .git-blame-ignore-revs, .githooks/**, .vscode/**,
.idea/**) — because an ignore can't be overridden. Anything else is a judgement call about
your repository, so add it there:
ThisBuild / lucumaAffectedIgnorePaths ++= Seq("**vite.config.*", "**hasura/**")The workflow file itself doesn't change: the Test step calls lucumaTestAffected instead of
test. No project names appear in it, so adding or renaming projects needs no regeneration.
| Setting | Default | Description |
|---|---|---|
lucumaAffectedTests |
true |
Set to false to always run the full suite. |
lucumaAffectedAlwaysPaths |
see above | Globs that trigger a full run. |
lucumaAffectedIgnorePaths |
see above | Globs that trigger nothing. Checked before the always list, so an entry here can't be overridden by one there. |
lucumaAffectedBaseRef |
$LUCUMA_AFFECTED_BASE, else origin/$GITHUB_BASE_REF on a PR, else origin/<default branch> on a push to any other branch, else the previous commit. Never set on a tag. |
What to diff against. None runs everything. |
Globs use java.nio syntax: * stops at /, ** doesn't. So *.sbt matches build.sbt but
not core/src/sbt-test/foo/build.sbt.
| Task | Description |
|---|---|
lucumaAffectedChangedFiles |
Changed files, including uncommitted and untracked ones. None if no diff was possible. |
lucumaAffectedProjects |
The projects to test. |
lucumaTestAffected |
Runs Test/test on them. Limited to the current project's aggregates, so rootJVM / rootJS still works. |
Tests aren't the only thing a PR can make pointless. Building and linking an app, publishing an image, checking a bundle size — none of that needs to happen if the diff can't reach the projects it's built from.
Name the projects a job consumes and it's skipped when none of them are affected:
ThisBuild / githubWorkflowAddedJobs += lucumaAffectedJob(
WorkflowJob("explore-deploy", "Build and deploy Explore", steps, cond = someCond),
explore_app
)It takes the projects themselves, not their ids, so a rename is a refactor and a typo is a compile
error. For a crossProject, name the platform you mean: schemas_lib.js.
lucumaAffectedJob adds the dependency and ANDs the condition onto whatever the job already had.
For a single step, or to build the expression yourself, use lucumaAffectedCond(explore_app) and
add lucumaAffectedJobId to the job's needs.
Naming one project covers everything upstream of it, since the condition reads the reverse
dependency closure: gating on explore_app also fires for ui_lib and schemas_lib changes.
Both read needs.affected.outputs.projects, published by a generated affected job. That job
costs an sbt boot, so it's only generated once something depends on it — adding
lucumaAffectedJobId to a needs list is what brings it into being.
GitHub fires CI twice for a commit on a branch with an open PR, once for each event, so what a push run diffs against matters as much as a PR run does:
- Push to any non-default branch. Both the
affectedjob andlucumaTestAffecteddiff againstorigin/<default branch>, so the push run narrows the same way itspull_requestcounterpart does instead of running everything. A stacked PR, one targeting another branch rather than the default, is still measured against the default branch, so its push run sees the parent branch's changes too and over-tests. - Merge to
main. Theaffectedjob diffs against the previousmain(github.event.before), so a merge that can't reach an app doesn't redeploy it. The test run does not:lucumaTestAffectedhas no base ref there and runs the full suite, which is the backstop for everything the dependency graph can't see. A first push or a force-push gives no usable commit, and then both fall back to running everything. - Tags. Never narrowed. A tag points into a branch, so diffing against it would find nothing changed and publish an untested release.
| Task | Description |
|---|---|
lucumaAffectedReport |
Log the affected projects, and write projects and all to GITHUB_OUTPUT under Actions. |
Try it locally with LUCUMA_AFFECTED_BASE=origin/main sbt lucumaAffectedProjects.
Warning
sbt doesn't know about GraphQL schemas used by codegen, database migrations, or npm
dependencies. If those live outside the project that uses them, add them to
lucumaAffectedAlwaysPaths.
Activation: Automatic (requires LucumaPlugin). Disable with
ThisBuild / lucumaSlackNotify := false.
Posts to Slack when a workflow fails on the default branch.
GitHub only notifies whoever triggered a run, and merges are pushed by a bot, so a red main
reaches nobody. This watches whole workflows rather than instrumenting jobs, so a job added later
is covered without touching anything.
lucumaSlackNotifyGenerate writes .github/workflows/ci-failure-slack.yml —
githubWorkflowGenerate only ever writes ci.yml and clean.yml, so this has its own task,
checked in CI the way the shared scalafmt and scalafix configs are. prePR and Steward regenerate
it. Setting lucumaSlackNotify := false and regenerating deletes the file.
You need the webhook as a repository or organization secret. Get one from a Slack app under Incoming Webhooks; it is bound to a single channel. Without it the workflow logs a warning and exits cleanly, so an unconfigured repo doesn't get a second failure on top of the one it was reporting.
| Setting | Default | Description |
|---|---|---|
lucumaSlackNotify |
true |
Set to false to not generate the workflow. |
lucumaSlackNotifyWorkflows |
Seq("Continuous Integration") |
Workflows to watch. Add a nightly one here: a scheduled run otherwise notifies only whoever last edited its cron. |
lucumaSlackNotifyBranch |
"main" |
Branch whose failures are reported. |
lucumaSlackWebhookSecret |
"GPP_SLACK_WEBHOOK_URL" |
Name of the Actions secret holding the webhook URL. |
| Task | Description |
|---|---|
lucumaSlackNotifyGenerate |
Write the workflow, or delete it when the feature is off. |
lucumaSlackNotifyCheck |
Fail if it is missing, edited by hand, or out of date. |
The message names the failed jobs, which needs one API call. That call is continue-on-error, so a
flaky lookup costs you the job names rather than the whole notification.
Activation: Automatic (requires LucumaPlugin, and LucumaAffectedPlugin purely so it runs
after it: both append jobs to the workflow, and this one validates against the final list).
Disable with ThisBuild / lucumaRequiredChecks := false.
Generates one CI job to require in branch protection, instead of requiring one entry per test shard, JDK and Scala version.
GitHub matches a required check by its literal name, built from the job name plus its matrix values:
Test (ubuntu-22.04, 3, temurin@25, 0). Listing those is fragile twice over. Change a JDK, a Scala
version, a shard count or a job name and the entry silently stops matching. And a job skipped by a job-level if: never expands its matrix, so the requirement never matches.
The generated job depends on jobs by id through needs, which carry none of those values.
needs: [build] waits for every combination of build and fails if any of them failed:
ThisBuild / lucumaRequiredCheckJobs := Seq("build", "checks")Each id is checked against the generated workflow, so a typo or a renamed job fails the build instead of quietly dropping a requirement.
The check to require is the job name plus the one matrix value GitHub appends:
REQUIRED CHECKS FOR BRANCH PROTECTION - AGGREGATED (ubuntu-latest)
| Setting | Default | Description |
|---|---|---|
lucumaRequiredChecks |
true |
Set to false to not generate the job. |
lucumaRequiredCheckJobs |
Seq("build") |
Job ids to require. Each must exist in the generated workflow. |
lucumaRequiredChecksJobName |
"REQUIRED CHECKS FOR BRANCH PROTECTION - AGGREGATED" |
Display name of the job. Keep it the same across repos so there is one string to configure everywhere. |
The job runs if: always(), so a skipped dependency can't skip it and leave the check unreported.
It fails on failure or cancelled and accepts skipped: a job the diff made pointless is fine.
It runs on ubuntu-latest because sbt-typelevel puts the OS in the check name, and that label never
changes while dated images get retired.
It's generated by default so the name shows up in GitHub's picker from the first push; it does
nothing until branch protection points at it. To adopt: bump, githubWorkflowGenerate, push once,
then replace the required-checks list with the single name above.
Activation: Automatic (requires TypelevelCiReleasePlugin && LucumaPlugin &&
LucumaScalafmtPlugin).
For published libraries. Pulls in CI release support and extends the prePR command
alias with mimaReportBinaryIssues so binary-compatibility checks run as part of pre-PR
verification.
Activation: Automatic (requires LucumaPlugin && LucumaScalafmtPlugin).
For applications (as opposed to published libraries). Defines a date + git-hash version
scheme (e.g. 20250101-abcdef12, suffixed -UNCOMMITTED when the tree is dirty) and disables
MiMa binary-issue checks (tlCiMimaBinaryIssueCheck := false).
Activation: Opt-in — .enablePlugins(LucumaCssPlugin) on a Scala.js project.
Collects CSS assets (from both the classpath and dependency jars) into the target directory as part of the linking step, so stylesheets shipped inside lucuma libraries end up alongside the linked JS.
| Key | Description |
|---|---|
lucumaCss (task) |
Copy CSS into target/lucuma-css; hooked into fastLinkJS / fullLinkJS. |
lucumaCssExts (setting) |
File extensions treated as CSS (default css, scss, saas). |
Activation: Automatic where DockerPlugin && JavaServerAppPackaging are enabled.
Opinionated Docker packaging (via sbt-native-packager) for lucuma server applications:
eclipse-temurin:25-jrebase image,noirlabDocker username, non-rootsoftwareuser.- Heroku-compatible image manifest (
--provenance false --output type=docker,linux/amd64). - The image's JVM distrusts the CAs compromised in
CVE-2026-91403 by adding
them to
blocked.certs, so deployments are protected even on unpatched base images. - OOM safety JVM options, locale settings, no Windows launchers, no javadocs/sources.
- cgroups-aware heap sizing (via a bundled
docker-set-memory.sh) and optional Heroku Java metrics agent (downloaded at build time).
| Key | Default | Description |
|---|---|---|
lucumaDockerDefaultMaxHeap |
512 |
Max heap (MB) when cgroups don't report a limit. |
lucumaDockerMinHeap |
256 |
Minimum heap (MB). |
lucumaDockerHeapPercentMax |
80 |
% of memory for heap when cgroups report max. |
lucumaDockerHeapSubtract |
0 |
MB to subtract from the memory limit when sizing heap. |
lucumaDockerOpenDebugPorts |
false |
Open JMX / JDWP debug ports in the start script. |
lucumaDockerUseHerokuAgent |
true |
Bundle and attach the Heroku Java metrics agent. |
Not an sbt plugin — a custom Scala.js JSEnv (extending JSDOMNodeJSEnv) added as a build
dependency. It injects browser globals (document, window, navigator, Event,
IS_REACT_ACT_ENVIRONMENT, …) into the Node realm so React component tests can run under
jsdom. Use it by setting:
jsEnv := new lucuma.LucumaJSDOMNodeJSEnv()