# Maven publishing

> Publish JVM libraries with `elide publish`: install into the local Maven repository, deploy to Nexus, Artifactory or GitHub Packages, and release to Maven Central with signatures and checksums, no plugins.

`elide publish` builds a library's JARs, writes a POM for its consumers and publishes them to a Maven repository: the
local one by default, a team repository with `--to`, or Maven Central through the Central Publisher Portal. Maven,
Gradle and other Elide projects then depend on it like any other artifact. The `publishing` block in `elide.pkl`
configures it; there are no plugins to add.

| Target | Command | Needs |
| --- | --- | --- |
| Local Maven repository | `elide publish` | `groupId`, `version`, one `Jar` |
| A team repository | `elide publish --to internal` | Credentials in the environment |
| Maven Central | `elide publish --to central` | Portal token, `gpg`, POM metadata, sources and javadoc JARs |
| Nowhere (check first) | `elide publish --dry-run` | Nothing |

## Install locally

A publication needs a `groupId`, the project's `version` and one `Jvm.Jar` artifact:

```pkl
amends "elide:project.pkl"
import "elide:Jvm.pkl" as Jvm

name = "greeter"
version = "1.0.0"
description = "Greets people."

artifacts {
  ["lib"] = new Jvm.Jar {}
}

publishing {
  maven {
    groupId = "dev.example"
  }
}
```

```bash
elide publish
```

Elide builds the JAR (reusing up-to-date outputs), writes the POM and installs both as `dev.example:greeter:1.0.0`
under `~/.m2/repository`, with `maven-metadata-local.xml`. The `artifactId` defaults to the project name. Reinstalling
the same version replaces it, so a consumer on the same machine picks up each change. From Maven:

```xml
<dependency>
  <groupId>dev.example</groupId>
  <artifactId>greeter</artifactId>
  <version>1.0.0</version>
</dependency>
```

From Gradle:

```kotlin
repositories { mavenLocal() }
dependencies { implementation("dev.example:greeter:1.0.0") }
```

From another Elide project:

```pkl
dependencies {
  maven {
    packages { "dev.example:greeter:1.0.0" }
  }
}
```

A local install needs no network and no credentials. `localRepository` installs somewhere else; a relative path
resolves against the manifest.

## Check before publishing

```bash
elide publish --dry-run
```

`--dry-run` stages the POM and JARs under `.dev/staging/publish/maven` with Maven file names
(`greeter-1.0.0.jar`, `greeter-1.0.0-sources.jar`, `greeter-1.0.0.pom`) and lists them. It signs them when signing is
on, and for Maven Central also writes the bundle, so it shows exactly what a real run would send. Nothing is written
to a repository.

## What goes in the POM

The POM describes the library for its consumers, not the build: coordinates, the `pom` metadata and dependencies,
with no `<build>`, `<repositories>` or local paths.

| `elide.pkl` | Published POM |
| --- | --- |
| `packages`, `modules` | `compile` dependencies |
| `runtimeOnly` | `runtime` dependencies |
| `compileOnly`, `devPackages`, `processors`, `kotlinPlugins`, `testPackages` | Left out |
| Versionless `exclusions` | An `<exclusion>` on every dependency |
| A workspace project | That project's publishing coordinates |
| A `Jar.from` sibling | Its `packages`, `modules` and `runtimeOnly`, unless already listed |
| A local JAR path | An error: consumers can't resolve it |

> [!NOTE] Kotlin libraries
> The POM also lists what Elide adds to a Kotlin project's classpath without a declaration, because consumers need it
> at run time: `kotlin-stdlib` and `kotlin-reflect` at the project's Kotlin version, and the runtimes
> `kotlin.features` turns on (coroutines, serialization). Turn a feature off, set `kotlin.compilerOptions.noStdlib`, or
> declare the dependency yourself to pin another version.

By default a publication holds every `Jar`, `SourceJar` and `JavadocJar` the project declares; `artifacts` picks a
subset by artifact name. It must contain exactly one `Jar`, and each classifier once. A `JavadocJar` over Kotlin
sources is generated with Dokka (see [JAR assembly](https://elide.help/docs/toolchain/jvm/jar#sources-and-javadoc-jars)).

## Publish to a team repository

Name the repository under `repositories`, then publish to it by name:

```pkl
publishing {
  maven {
    groupId = "dev.example"
    repositories {
      ["internal"] = "https://repo.example.dev/releases"
    }
  }
}
```

```bash
export ELIDE_MAVEN_INTERNAL_USERNAME=ci
export ELIDE_MAVEN_INTERNAL_PASSWORD="$REPO_TOKEN"
elide publish --to internal
```

Credentials come from `ELIDE_MAVEN_<NAME>_USERNAME` and `ELIDE_MAVEN_<NAME>_PASSWORD`, where `<NAME>` is the
repository name in upper case with every other character turned into `_` (`my-repo` → `ELIDE_MAVEN_MY_REPO_…`), so
the manifest holds no secrets. Every file is uploaded with `.md5`, `.sha1`, `.sha256` and `.sha512` checksums, and
`maven-metadata.xml` is updated. This works with Nexus, Artifactory, GitHub Packages and any repository that accepts
Maven uploads. A plain `http://` URL other than localhost is refused without `--allow-http-repositories`.

## Sign with gpg

```pkl
publishing {
  maven {
    signing { gpg { keyName = "0123ABCD" } }   // `signing {}` uses gpg's default key
  }
}
```

With `signing` set, a remote publish signs every file with the `gpg` binary (`<file>.asc`). A local install signs only
with `--sign`; `--no-sign` turns signing off. `gpg-agent` asks for the passphrase; in CI, set `ELIDE_GPG_PASSPHRASE`
and Elide passes it to `gpg` through loopback pinentry. Elide never reads the key itself. If `gpg` is missing or fails,
nothing is published.

## Release to Maven Central

Maven Central needs POM metadata, sources and javadoc JARs, signatures and a release version. A complete library:

```pkl
amends "elide:project.pkl"
import "elide:Jvm.pkl" as Jvm
import "elide:Publishing.pkl" as Publishing

name = "greeter"
version = "1.0.0"
description = "Greets people."

artifacts {
  ["lib"] = new Jvm.Jar {}
  ["sources"] = new Jvm.SourceJar {}
  ["javadoc"] = new Jvm.JavadocJar {}
}

publishing {
  maven {
    groupId = "dev.example"
    pom {
      name = "Greeter"
      url = "https://github.com/example/greeter"
      licenses { new { name = "Apache-2.0"; url = "https://www.apache.org/licenses/LICENSE-2.0" } }
      developers { new { id = "jdoe"; name = "J. Doe" } }
      scm { url = "https://github.com/example/greeter" }
    }
    repositories {
      ["central"] = new Publishing.CentralPortal {}
    }
  }
}
```

Create a user token in the [Central Publisher Portal](https://central.sonatype.com) and publish:

```bash
export ELIDE_MAVEN_CENTRAL_USERNAME=<token user>
export ELIDE_MAVEN_CENTRAL_PASSWORD=<token password>
elide publish --to central
```

Before building anything, Elide checks Central's requirements and reports everything missing at once: the POM's
`name`, `description`, `url`, `licenses`, `developers` and `scm.url`, a `SourceJar`, a `JavadocJar`, and a version that
isn't a `-SNAPSHOT`. The POM's `description` defaults to the project's. Central always signs, with `gpg`'s default key
unless [`signing`](#sign-with-gpg) picks one, so `--no-sign` is refused.

Elide uploads one signed, checksummed bundle and waits for the Portal to validate it. By default
(`publishingType = "USER_MANAGED"`) you then release the deployment in the Portal; Elide prints its page. With
`publishingType = "AUTOMATIC"` the Portal releases it once validated. A failed validation prints the Portal's errors.
`--no-wait` returns right after the upload.

## Workspaces

In a [workspace](https://elide.help/docs/elide-pkl-reference#workspaces), set the shared configuration once in the root `elide.pkl`:

```pkl
import "elide:Publishing.pkl" as Publishing

publishing {
  maven {
    groupId = "dev.example"
    pom { url = "https://github.com/example/greeter"; scm { url = "https://github.com/example/greeter" } }
    repositories { ["central"] = new Publishing.CentralPortal {} }
  }
}
```

Members inherit everything except `artifactId` and `artifacts`, which stay per project; a member's own settings win
field by field, and repositories merge by name.

| Run | Publishes |
| --- | --- |
| `elide publish` at the root | Every project |
| `elide publish` in a member | That member and every workspace project its POM depends on |
| `elide publish core util` | Exactly `core` and `util`; workspace projects they depend on must already be in the target |
| `elide publish --skip-existing` | Every selected project whose version isn't in the target yet |

Every project is built, staged and signed before any is published, so a failure there publishes nothing. To Maven
Central, every project goes into one deployment that the Portal validates and releases together. `--skip-existing`
also reruns a release that failed partway: it leaves out what already made it, and never skips a snapshot.

## In CI

```yaml
- run: elide publish --to central --skip-existing
  env:
    ELIDE_MAVEN_CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }}
    ELIDE_MAVEN_CENTRAL_PASSWORD: ${{ secrets.CENTRAL_PASSWORD }}
    ELIDE_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
```

Import the signing key into the runner's `gpg` keyring in an earlier step. See [Continuous integration](https://elide.help/docs/installation/ci-cd)
to install Elide on the runner.

## Reference

- [`elide publish`](https://elide.help/docs/cli-references#publish): every flag.
- [`publishing`](https://elide.help/docs/elide-pkl-reference#publishing): every manifest property.
- [POM export](https://elide.help/docs/build-tools-export): print a `pom.xml` for the build instead of publishing.
