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:

  1. It checks that sbt’s version (sbt 'print ThisBuild / version', which sbt-dynver derives from the tag) is the tag’s version without its v, and publishes the library to the runner’s local Maven repository (sbt publishM2), so the Kotlin build compiles against the very llm4s-java-api_3 the release published rather than waiting for Central.
  2. 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 .asc signatures and checksums - to modules/kotlin-api/build/staging-deploy. llm4sVersion sets both the artifact’s version and the llm4s-java-api_3 version it depends on; without it the build is 0.1.0-SNAPSHOT.
  3. scripts/check-kotlin-publication.sh (with REQUIRE_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 on llm4s-java-api_3 at the same version and on no snapshot; and a throwaway Gradle project that imports llm4s-bom and declares llm4s-kotlin-api with no version compiles Kotlin code against it.
  4. scripts/publish-kotlin-api.sh zips the version directory into a Central Portal bundle, uploads it with publishingType=AUTOMATIC (as sbt ci-release releases), 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/published and its deployments of llm4s-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 reaches curl in a 0600 header 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 accepted
    

    It 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-api also a POM that depends on llm4s-java-api_3 at 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 against 0.4.0 with the stub list it reports all five stubs as missing, which is how 0.4.0 shipped (#1150); against 0.4.1 it 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 the https://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 sbom job 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/llm4s should 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 that modules/samples/scala-cli/hello.scala pins the release now on Maven Central (the prepare commit bumps it with docs/_data/project.yml) and runs scala-cli compile on 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 that llm4sVersion in modules/samples/mill/build.mill is the release now on Maven Central (bump it with docs/_data/project.yml in 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 the org.llm4s::llm4s-* versions in modules/samples/flink/project.scala with docs/_data/project.yml in the prepare commit (ci.yml’s scripts/check-flink-guide-snippets.sh fails 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.xml and scaladoc-sitemap.xml, should read without errors, and the new release’s pages should not show new crawl errors). The deploy itself fails if sitemap.xml lists 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 as https://repo1.maven.org/maven2/org/llm4s/llm4s-core_3/ and the POM’s <scm>. A claims.json entry 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 ([![llm4s-core Scala version support](https://index.scala-lang.org/llm4s/llm4s/llm4s-core/latest-by-scala-version.svg)](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 mimaPreviousArtifacts names the published artifact at the module’s own baseline, that MiMa resolved llm4s-<name>_3 and compared it (it prints the coordinates and the problem counts: a silent empty baseline would pass), and that mimaReportBinaryIssues finds nothing;
  • handles per-module baselines: a module that mimaBaselineOverrides gives Some("X.Y.Z") is compared with a second throwaway version, at which the build is published a second time; one it gives None must have no baseline at all while the others are checked. --list-frozen prints each module’s baseline;
  • runs a negative control: a baseline with two extra classes, one @Stable and one @Experimental, that the code then lacks. MiMa must fail on the @Stable one and must not report the @Experimental one.

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.2 not 0.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.2
    

    Note 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-release job will skip creation.

  • publish-kotlin failed — the sbt artifacts are live, so do not re-tag. github-release, docs and sbom wait for publish-kotlin, so none of them has run (docker has: it needs only publish).
    • 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-kotlin is 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, then docs and sbom, 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 on main; llm4s-kotlin-api first ships with the next patch version. Version X then stays without it, and its llm4s-bom still lists org.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 X reports 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.sh shows the sbt artifacts are on Central.

  • Failed after publish (github-release, docs or docker) — 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-running docs dispatches a fresh docs deploy. If docs ended “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.