Release Process
Creating a New Release
1. Tag Format
All release tags MUST use the v prefix format: v0.3.2, v1.0.0, etc. Use three numeric parts, optionally followed by a suffix such as -RC1
(vMAJOR.MINOR.PATCH[-suffix]). The release workflow runs for any v[0-9]* tag, but the docs deploy accepts only that form: a tag such as
v1.0 would publish to Maven Central and then fail the docs job, with the artifacts already out.
2. Release Steps
Before tagging, update the support table (see Update the support table).
1
2
3
4
5
6
7
8
9
# 1. Ensure you're on main branch with latest changes
git checkout main
git pull origin main
# 2. Create and push the version tag (ALWAYS use 'v' prefix)
git tag v0.3.2
git push origin v0.3.2
# 3. The GitHub Actions release workflow does the rest -- see below.
Update the support table
Add the new version to docs/_data/support.yml (version, release date, status latest) and set the status of the line
it replaces, in the same pull request that prepares the release. The table is rendered on the
Support Policy. scripts/check-support-table.sh checks it: Quick Checks fails when the table lacks
the newest CHANGELOG release, and the release workflow’s support-table job fails a tag that is not the table’s
latest line, before publish runs. A pre-release tag (v1.0.0-RC1) is not a support line and is not checked.
Write the News post
Every minor release, and every patch worth announcing, gets a post on llm4s.org/news, which also feeds
feed.xml (Atom) and the home page’s “Latest” strip. In the same pull request that prepares the release, after the
CHANGELOG section ## [<version>] - <date> is final:
1
scripts/new-release-post.sh 0.6.0 # drafts docs/_posts/<date>-llm4s-0.6.0.md from the CHANGELOG section
The draft carries the front matter (title, description, the share image, tags: [release], version), the
section’s highlights or entries, its breaking changes, the dependency line and the links to the CHANGELOG section and
the GitHub release. Rewrite the summary into a sentence or two on what is new and why it matters, cut the lists to what
a user acts on, link upgrade notes to docs/migrations/ or the migration guide, and set date: to the release’s time
if you want two posts of one day in order. A post carries no Scala code: link to the compile-checked guide pages
instead. scripts/check-docs-site-links.sh checks its links.
The release workflow’s docs job warns (scripts/new-release-post.sh --check) when the tag has no post; it does not
fail the release. A post merged to main after the tag is deployed with the next docs deploy.
Other posts use the same feed with their own tag: community (Dev Hour recaps, GSoC results, talks with their
recordings) or guides (a summary of an external article, or a cross-post with canonical_url set to the
original). Each tag gets its own feed at /feed/by_tag/<tag>.xml once a post uses it.
Pushing the tag is the whole procedure. Everything after it is automated, in this order:
1
2
3
ci → publish → publish-kotlin → github-release → docs
│ └→ sbom
└──────────────────────────────────→ docker
| Job | Does |
|---|---|
ci |
Full CI on the tagged commit |
publish |
Checks the Scaladoc it will package (sbt doc, then scripts/check-scaladoc-warnings.sh: no warning in an llm4s source, none in a third-party source that scripts/scaladoc-third-party-warnings.txt does not list), then signs and publishes the sbt artifacts to Maven Central (sbt ci-release) |
publish-kotlin |
Builds, signs, checks and uploads llm4s-kotlin-api, the Gradle-built Kotlin artifact, at the same version (see The Kotlin artifact) |
github-release |
Creates the GitHub Release for the tag |
docs |
Warns when the tag has no News post, then deploys llm4s.org with the new version in the install snippets: dispatches the docs workflow on main, built from the tag, and waits for it |
docker |
Builds and pushes the container image |
The docs deploy runs from main, built from the tag
The github-pages environment only accepts deployments from the main branch. A job that ran on the tag
would be rejected before it started (that is what failed v0.4.1, the first release to run it: #1152),
so the docs job does not deploy from the tag: it dispatches pages.yml on main with the tag as its ref
input (scripts/dispatch-docs-deploy.sh). The dispatched run is on main, so the environment accepts it, and its
build checks out the tag, so the version and the install snippets are the release’s. The docs job waits for that run and
fails if the deploy fails; it still runs only after github-release, so the docs never advertise a version that is not on
Maven Central.
An environment rule for v* tags (Settings, Environments, github-pages, Deployment branches and tags) would also let a
tag-triggered deploy through, and is an equally valid alternative if a maintainer prefers it; the workflow does not need it.
To deploy the docs for a tag by hand, for example to re-run a deploy that failed, or to try the path before a release,
dispatch the workflow on main:
1
gh workflow run pages.yml --ref main -f ref=v0.5.0 # the ref must be a release tag, vX.Y.Z or vX.Y.Z-RC1
That redeploys the site from that tag’s content, so use the latest release’s tag unless you mean to roll the site
back. The tag must exist, and latest_release in the install snippets always comes from the newest published GitHub
Release, whichever tag is built.
To check the path without deploying, add dry_run=true. The run does every build step (tag validation, checkout of
the tag, version extraction, the aggregate Scaladoc and its package check, Jekyll, the Pages artifact) and skips the
deploy job, so llm4s.org is untouched. It runs in a concurrency group of its own, so it never displaces a real deploy
waiting in the pages group, and it works on any branch, so a change to pages.yml can be tried before it merges:
1
2
gh workflow run pages.yml --ref main -f ref=v0.5.0 -f dry_run=true # the release path, build only
gh workflow run pages.yml --ref my-branch -f dry_run=true # a pages.yml change, build only
A run that is not a dry run and is not on main fails in its first step, before it builds, because the environment
would reject its deploy anyway.
Only a tag whose commit has the aggregate docs sbt project can be built by today’s pages.yml: it runs
sbt docs/doc. That project arrived after v0.4.1 (in #1155), so -f ref=v0.4.1 fails at “Generate Scaladoc”; the first
tag the release path can build is the next release. Use a dry run on main without ref to check everything except the
tag checkout until then.
The Kotlin artifact: llm4s-kotlin-api
org.llm4s:llm4s-kotlin-api is built by Gradle (modules/kotlin-api), not sbt, so sbt ci-release does not publish it.
The publish-kotlin job does, after publish, with the same signing key and Central Portal token:
- It checks that sbt’s version (
sbt 'print ThisBuild / version', which sbt-dynver derives from the tag) is the tag’s version without itsv, and publishes the library to the runner’s local Maven repository (sbt publishM2), so the Kotlin build compiles against the veryllm4s-java-api_3the release published rather than waiting for Central. gradle publishMavenPublicationToStagingRepository -Pllm4sVersion=<version>writes the signed publication - the POM, the jar, the sources jar, a javadoc jar holding the Dokka HTML of the KDoc, the Gradle module file, their.ascsignatures and checksums - tomodules/kotlin-api/build/staging-deploy.llm4sVersionsets both the artifact’s version and thellm4s-java-api_3version it depends on; without it the build is0.1.0-SNAPSHOT.scripts/check-kotlin-publication.sh(withREQUIRE_SIGNATURES=1) checks it: every file signed; the POM’s url, licence, organization, scm, developers and version scheme equal to the sbt-published POMs’; a compile-scope dependency onllm4s-java-api_3at the same version and on no snapshot; and a throwaway Gradle project that importsllm4s-bomand declaresllm4s-kotlin-apiwith no version compiles Kotlin code against it.scripts/publish-kotlin-api.shzips the version directory into a Central Portal bundle, uploads it withpublishingType=AUTOMATIC(assbt ci-releasereleases), and waits until the Portal has validated it and is publishing. It refuses a snapshot, an unsigned file or a missing sources or javadoc jar before uploading anything. It is safe to re-run: before uploading it asks Maven Central for the POM, then the Portal (GET /api/v1/publisher/publishedand its deployments ofllm4s-kotlin-api-<version>), and exits 0 without uploading when the version is already published or publishing; a deployment an earlier run left validating is followed rather than uploaded again. If the Portal cannot answer those questions the script fails without uploading (fail closed): a duplicate deployment cannot be withdrawn, while a failed check only costs a re-run. The Portal token reachescurlin a0600header file, never on its command line.
github-release waits for publish-kotlin, not only for publish. That is deliberate: llm4s-bom at the release
version names llm4s-kotlin-api, so a Release announced before the Kotlin artifact is on its way would advertise a
BOM entry, and an install line, that do not resolve. The price is that a publish-kotlin failure also holds back the
GitHub Release, the docs deploy and the SBOMs; Re-triggering a failed release says
how to release them by hand.
The artifact has no _3 suffix, since it is Kotlin, and llm4s-bom manages it with every other artifact, so a Gradle
build that imports the BOM declares implementation("org.llm4s:llm4s-kotlin-api"). sbt listPublishedArtifacts lists
it as a gradle line (project/PublishedArtifacts.scala, Gradle), so scripts/verify-release.sh checks it is on
Central, and sbt publishedArtifactsCheck requires it to be documented like any other artifact.
Every pull request runs steps 2 and 3 unsigned in the Kotlin API CI job, at 0.1.0-SNAPSHOT, so a change that breaks
the publication fails there and not on release day; scripts/test-publish-kotlin-api.sh runs step 4 against a fake
Portal. To try it locally (it writes nothing outside the repository but the local Maven repository you name):
1
2
3
sbt -Dmaven.repo.local=/tmp/m2 'set ThisBuild / version := "0.1.0-SNAPSHOT"' publishM2
(cd modules/kotlin-api && gradle -Dmaven.repo.local=/tmp/m2 publishMavenPublicationToStagingRepository)
scripts/check-kotlin-publication.sh 0.1.0-SNAPSHOT modules/kotlin-api/build/staging-deploy /tmp/m2
The Kotlin build needs JDK 21 to run its tests, and a Kotlin 2.0 compiler does not run on JDK 25: run Gradle on JDK 21.
3. What gets published is whatever the tagged commit aggregates
sbt ci-release publishes the root aggregate as it exists on the tagged commit. Work that
is on main but not on that commit is simply not in the release, and because Maven Central is
immutable there is no way to add it to that version afterwards – it waits for the next one.
This has bitten us once already. The Maven relocation stubs that redirect the pre-0.4.0
coordinates (#1146) merged shortly after v0.4.0
was tagged, so 0.4.0 shipped without them and org.llm4s:core % 0.4.0 still fails to resolve
rather than redirecting (#1150).
So before tagging, check that anything the release is for is on the commit you are about to
tag, not merely on main:
1
git merge-base --is-ancestor <commit> <tag-or-HEAD> && echo "in the release" || echo "NOT in the release"
A new published module needs the same check twice over: it must be on the tagged commit and
aggregated by the root project in build.sbt. A module outside the aggregate publishes nothing
and does so silently.
To see exactly what the tagged commit will publish, ask the build:
1
sbt -error listPublishedArtifacts # one `artifact <id>`, `gradle <id>`, `bom <id>` or `stub <id>` per line
A gradle line is an artifact the release publishes from a Gradle build rather than from the aggregate:
llm4s-kotlin-api, published by the publish-kotlin job. It is on the tagged commit when modules/kotlin-api is.
sbt publishedArtifactsCheck (a CI quick check) fails when a published llm4s-* artifact has no tier in
1.0 Scope or no install line in the installation guide.
The bom line is llm4s-bom, a POM-only bill of materials that pins every real artifact to the release
version. It is generated from the same list as the artifact lines, and sbt bomCheck (a CI quick check)
fails when the generated POM disagrees with that list.
4. Do NOT create the GitHub Release by hand
The github-release job creates it for you, and it runs after publish succeeds. That
ordering is the point: a GitHub Release is the signal that a version is available, it is
what notifies everyone watching “Releases only”, and it is what docs reads to decide which
version the install snippets should name.
Creating the Release manually defeats all of that. The job skips creation when a Release already exists, so a hand-made one is accepted regardless of whether the publish went on to succeed – leaving a Release, a notification, and a documented coordinate for a version that never reached Maven Central.
Editing the generated notes afterwards is fine and encouraged. The job only ever creates; it never overwrites. Write whatever the release deserves once it exists.
5. Verify Release
- Check GitHub Actions: https://github.com/llm4s/llm4s/actions/workflows/release.yml
- Check Docker images: https://github.com/llm4s/llm4s/pkgs/container/workspace-runner
-
Verify Maven Central, with the script rather than by eye:
1
scripts/verify-release.sh 0.5.0 # a leading v is acceptedIt asks the build which artifacts it publishes (the list above) and checks that each resolves at that version: the POM and the jar for a real artifact, and for
llm4s-kotlin-apialso a POM that depends onllm4s-java-api_3at that same version, a POM that carries a<dependencyManagement>for the BOM (it has no jar), and for each relocation stub (the pre-0.4.0 coordinates) a POM that carries a<relocation>. It exits non-zero and names each miss. Run against0.4.0with the stub list it reports all five stubs as missing, which is how 0.4.0 shipped (#1150); against0.4.1it passes.Maven Central’s index can lag a successful publish, so a 404 straight after the job finishes is not proof of a miss: re-run before concluding one. A miss that persists cannot be fixed in that version (Re-triggering a failed release); it waits for the next one.
-
Check the published POMs’ metadata.
sbt pomMetadataCheck(a CI quick check) already fails a build whose generated POMs lack a description, an https project URL, a licence, a developer or thehttps://github.com/llm4s/llm4s<scm>that Scaladex matches on. To check what actually reached Central:1 2
curl -sfO https://repo1.maven.org/maven2/org/llm4s/llm4s-core_3/0.5.0/llm4s-core_3-0.5.0.pom sbt "pomMetadataCheckFiles llm4s-core_3-0.5.0.pom"
Run against 0.4.1’s POM it reports three problems: the description is the artifact name, the
<scm><url>has a trailing slash and the connection is the SSH form. All three were fixed after 0.4.1 (#1588). -
Check the Release’s assets: the
sbomjob attaches one<module>-<version>.bom.json(the SBOM) and one<module>-<version>.licences.csv(the licence report) per published module that ships a jar, all named for the tag’s version:1 2
gh release view v0.5.0 --json assets --jq '.assets[].name' | grep -c '\.bom\.json$' # one per module gh release view v0.5.0 --json assets --jq '.assets[].name' | grep -c '\.licences\.csv$' # the same number
- Check Scaladex:
https://index.scala-lang.org/llm4s/llm4sshould list the new version once Scaladex has picked it up. See Discoverability when it does not. - Compile the Scala CLI quick start against the published artifacts:
gh workflow run scala-cli-quickstart.yml. It checks thatmodules/samples/scala-cli/hello.scalapins the release now on Maven Central (the prepare commit bumps it withdocs/_data/project.yml) and runsscala-cli compileon it; it also runs weekly. Locally:scripts/check-scala-cli-quickstart.sh --central --compile. - Build and test the Mill quick start the same way:
gh workflow run mill-quickstart.yml. It checks thatllm4sVersioninmodules/samples/mill/build.millis the release now on Maven Central (bump it withdocs/_data/project.ymlin the prepare commit) and runs./mill app.compile + app.test; it also runs weekly. Locally:scripts/check-mill-quickstart.sh --central --build. -
Test the Flink sample:
gh workflow run flink-sample.yml. Bump theorg.llm4s::llm4s-*versions inmodules/samples/flink/project.scalawithdocs/_data/project.ymlin the prepare commit (ci.yml’sscripts/check-flink-guide-snippets.shfails otherwise); the workflow runs the sample’s MiniCluster test, and also runs weekly. Locally:cd modules/samples/flink && scala-cli test .. - After the docs deploy, look at the page-indexing and sitemap reports in Google Search Console and Bing Webmaster
Tools (both sitemaps,
sitemap.xmlandscaladoc-sitemap.xml, should read without errors, and the new release’s pages should not show new crawl errors). The deploy itself fails ifsitemap.xmllists Scaladoc or PDF URLs (scripts/check-site-sitemaps.sh).
Discoverability: Scaladex and GitHub
Scala developers look for libraries on Scaladex. It indexes an artifact by the
POM’s <scm> (it “associates a new artifact to a project by looking at the scm attribute”, and supports only
public GitHub repositories), and it ranks search results by the GitHub repository’s name (boost 6), organization (5),
description (4) and topics (4), then the artifact names (2) and the README (0.5)
(how it works,
improving visibility). The POM
side is in the build and guarded by pomMetadataCheck. The rest are repository settings or requests upstream, so they
need a maintainer:
- GitHub topics (Settings, General, the About gear, or
gh repo edit llm4s/llm4s --add-topic scala,scala3,llm,llm-agents,ai-agents,rag,mcp,openai,anthropic,ollama,genai,tool-calling). These are the words a Scala developer types when looking for this; there are none today. - Repository description: today “Agentic and LLM Programming in Scala” (with a double space). One sentence that names what it is helps both Scaladex and GitHub search, for example “Scala 3 toolkit for LLM applications: one client for OpenAI, Anthropic, Gemini, Ollama and more, agents, tool calling, RAG and MCP”.
- Repository homepage: empty today; set it to
https://llm4s.org. Scaladex shows it as the project’s website. - If the project is still missing after a release: several projects published through the Sonatype Central Portal
were reported missing in mid-2026, and in each case a Scaladex maintainer added the project by hand on
request (scaladex#1715,
#1716,
#1717; an automatic fallback is proposed in
scaladex#1718). Open an issue there titled “llm4s (org.llm4s) is
not indexed”, giving the repository, the group id
org.llm4s, an artifact such ashttps://repo1.maven.org/maven2/org/llm4s/llm4s-core_3/and the POM’s<scm>. Aclaims.jsonentry in scaladex-contrib is only for artifacts whose POM has no usable<scm>, which ours has. Scaladex reads no repository file such as.scaladex.json, so there is nothing to add here. - Once the listing exists: add the Scaladex badge beside the Maven Central one in
README.md([](https://index.scala-lang.org/llm4s/llm4s/llm4s-core)) and a Scaladex link in the installation guide. Until then that badge renders “project not found”, so it is not added.
Release readiness: a MiMa dry run
mimaBaselineVersion in build.sbt is None and mimaBaselineOverrides is empty, so sbt mimaReportBinaryIssues compiles the build and checks
nothing (The Baseline). It is set at a pre-1.0 release, version to be decided, once
the designs are final (#1281); 0.5.0 sets none. scripts/mima-dry-run.sh does those steps early, against a throwaway version, to show that they will work:
1
2
scripts/mima-dry-run.sh # on JDK 21, from a clean checkout of the commit you want to check
SBT=/path/to/sbt scripts/mima-dry-run.sh --no-negative-control
It works in a scratch clone of HEAD (uncommitted changes are not included; your working tree is not
touched), sets mimaBaselineVersion there, publishes every module to a temporary Maven repository (never
~/.ivy2, ~/.m2 or Central), and then, for each frozen module (read from the mimaFrozen(...) call sites in
build.sbt):
- checks that
mimaPreviousArtifactsnames the published artifact at the module’s own baseline, that MiMa resolvedllm4s-<name>_3and compared it (it prints the coordinates and the problem counts: a silent empty baseline would pass), and thatmimaReportBinaryIssuesfinds nothing; - handles per-module baselines: a module that
mimaBaselineOverridesgivesSome("X.Y.Z")is compared with a second throwaway version, at which the build is published a second time; one it givesNonemust have no baseline at all while the others are checked.--list-frozenprints each module’s baseline; - runs a negative control: a baseline with two extra classes, one
@Stableand one@Experimental, that the code then lacks. MiMa must fail on the@Stableone and must not report the@Experimentalone.
It took about three minutes on a laptop with warm caches (between 156 s and 184 s over three runs; an independent run took 175 s). It proves the wiring: the coordinates, the
_3 suffix, resolution, that the check can fail, and that @Experimental types are outside the freeze. It does
not prove that any API is compatible with anything: there is no baseline yet. It is a manual script,
not a CI job, because it publishes the whole build. scripts/test-mima-dry-run.sh tests the script itself
without sbt.
At the baseline release: publish it, confirm the frozen artifacts are on Central (Verify Release), then follow Setting and bumping the baseline.
Troubleshooting
Release workflow didn’t trigger
- Ensure tag starts with
v(e.g.,v0.3.2not0.3.2) - Check that tag was pushed:
git push origin v0.3.2 - Verify workflow status at GitHub Actions page
Re-triggering a failed release
Check what actually failed first. Maven Central is immutable: once publish has
succeeded, those coordinates exist forever and cannot be replaced. Re-running a release whose
publish step already completed will fail on the existing version, and re-tagging will not
help.
-
Failed before or during
publish— nothing was published. Delete and recreate the tag:1 2 3 4
git tag -d v0.3.2 git push origin :v0.3.2 git tag v0.3.2 git push origin v0.3.2Note that deleting a tag that already has a GitHub Release attached leaves the Release behind as a draft. Delete it too before retrying, or the recreated tag’s
github-releasejob will skip creation. publish-kotlinfailed — the sbt artifacts are live, so do not re-tag.github-release,docsandsbomwait forpublish-kotlin, so none of them has run (dockerhas: it needs onlypublish).- If the cause is outside the tagged commit (a missing or expired secret, a Portal outage, a FAILED
validation whose reasons the log shows and a Portal-side fix clears), fix it and use Re-run failed jobs on
the run.
publish-kotlinis safe to re-run at any step: the upload exits 0 without uploading when the version is already published or publishing, and follows a deployment an earlier attempt left validating. When it goes green,github-release, thendocsandsbom, run as usual. -
If the fix needs a change to the build (
modules/kotlin-api, the workflow, the scripts), it cannot be in version X: the workflow builds the tagged commit, and Maven Central is immutable. Land the fix onmain;llm4s-kotlin-apifirst ships with the next patch version. Version X then stays without it, and itsllm4s-bomstill listsorg.llm4s:llm4s-kotlin-api:X, which will never resolve: a Gradle build that imports the X BOM and declares the Kotlin artifact fails, so the X Release notes must say to use the next version for Kotlin.scripts/verify-release.sh Xreports that one miss and nothing else. Release the rest of X by hand, in the order the workflow would:1 2 3 4 5 6 7 8 9 10 11 12
scripts/verify-release.sh 0.5.0 # every sbt artifact resolves; only llm4s-kotlin-api is missing # The GitHub Release (as the github-release job creates it; add --prerelease for a vX.Y.Z-RC1 tag). gh release create v0.5.0 --verify-tag --title v0.5.0 --generate-notes gh release edit v0.5.0 --notes-file notes.md # say llm4s-kotlin-api is not in 0.5.0 # The docs deploy (what the docs job dispatches), once the Release exists. gh workflow run pages.yml --ref main -f ref=v0.5.0 # The SBOMs and licence reports (what the sbom job does), from a clean checkout of the tag, on JDK 21. git checkout v0.5.0 sbt -batch publishedSboms licenseReport sbt -batch -error listPublishedArtifacts | grep -E '^(artifact|stub) ' > target/published-artifacts.txt scripts/check-sbom.sh target/sboms target/published-artifacts.txt gh release upload v0.5.0 target/sboms/*.bom.json target/licences/*.licences.csv --clobber
This is the one case where the Release is created by hand (section 4): only after
verify-release.shshows the sbt artifacts are on Central.
- If the cause is outside the tagged commit (a missing or expired secret, a Portal outage, a FAILED
validation whose reasons the log shows and a Portal-side fix clears), fix it and use Re-run failed jobs on
the run.
- Failed after
publish(github-release,docsordocker) — the artifacts are live and the release is real. Do not re-tag. Re-run the failed job from the Actions UI, or cut the next patch version if the failure needs a code change. Re-runningdocsdispatches a fresh docs deploy. Ifdocsended “cancelled”, a newer deploy usually replaced it (the workflow keeps one pending run): check that llm4s.org shows the new version before re-running.
Version Numbering
We follow semantic versioning (MAJOR.MINOR.PATCH):
- MAJOR: Breaking API changes
- MINOR: New features, backwards compatible
- PATCH: Bug fixes, backwards compatible
Current documented version series: 0.5.x (pre-1.0 development). Until the MiMa baseline is set, any release, patch included, may carry a breaking change, listed in the CHANGELOG with a migration note; see Compatibility Policy.