Repositories & Publishing Across Tools

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Ant 1.10.18 with the Maven Artifact Resolver Ant Tasks 2.0.0 and Apache Ivy 2.6.0, Apache Maven 3.10.0 (Maven Resolver 2) and Gradle 9.8.1. Every example was run on JDK 26 with --release 17 on 2026-10-16, apart from the ones labelled "not run here". Maven 4 is still a release candidate (4.0.0-rc-7) and is mentioned only where it changes the published files.

Ant, Maven and Gradle disagree about almost everything — how a build is described, when work happens, what a plugin is — but they agree on one thing: the format of a binary repository. A JAR built by Gradle can be consumed by Ant, a POM written by hand for an Ant build can be consumed by Maven, and an Ivy build can publish into the same directory a Maven build deploys to. That shared format is the layout that Apache Maven defined for Maven Central, and it is the reason a library written in 2008 is still resolvable today by a tool that did not exist then. This page describes the format, then publishes the same two tidepool modules (tidepool-model and tidepool-engine, see the section index for the scenario) to a local file repository, build/repo, from Ant with the Maven Resolver Ant Tasks, from Ant with Ivy, from Maven and from Gradle, and consumes each result from the other tools.

For the model behind the coordinates see Dependency Management Fundamentals; for the signatures, checksums and verification that protect consumers see Supply Chain Security; the tool-specific pages that go deeper than this one are Maven: Install, Deploy & Distribution Management, Maven: Publishing to Maven Central, Gradle: Publishing, Ant: Maven Resolver Ant Tasks and Ant: Ivy Publishing & Multi-Project Builds.

The layout all three tools read and write

A Maven repository is a directory tree (served over HTTP, or just a directory on disk) whose paths are computed from the coordinates of an artifact. There is no index to maintain and no server logic needed: a client that knows the coordinates knows the URL. The algorithm in the Maven documentation is:

  • the groupId becomes directories, one per dot-separated part (io.tidepool becomes io/tidepool/)

  • then one directory named after the artifactId

  • then one directory named after the (base) version

  • then the file artifactId-version.extension, or artifactId-version-classifier.extension when there is a classifier

From coordinates to files: groupId, artifactId, version, classifier and extension map to directories and a file name, with the A-level and V-level metadata, checksums and signatures around them

The classifier distinguishes several files of the same module (sources, javadoc, tests); the extension is usually jar or pom. Everything else in a repository is a sibling file whose name adds a suffix to an existing file name:

Suffix or file Meaning

.pom

The module descriptor: coordinates, parent, dependencies with scopes, and the metadata Maven Central requires. This is what a consuming tool reads to learn the transitive dependencies; Gradle, Ant and Ivy all read it too.

.md5, .sha1

Hex-encoded checksums of the file next to them. Maven Central requires both.

.sha256, .sha512

Stronger checksums. Supported by Central but not mandatory; written by Gradle and by the Central Publisher Portal plugin, not by mvn deploy.

.asc

A detached OpenPGP signature of the file next to it. Central requires one for every deployed file. A signature needs no checksum and a checksum needs no signature.

.module

Gradle Module Metadata (a JSON file), written by Gradle next to the POM. Gradle consumers prefer it; every other tool ignores it.

maven-metadata.xml

Repository bookkeeping at three levels, described next.

The metadata files

maven-metadata.xml is the only mutable file in a release repository. It exists at three levels, documented on the Maven repository metadata page:

  • G level (in the groupId directory): maps plugin prefixes to artifact IDs, and exists only for Maven plugins. It is how mvn tidepool:check-stations finds out which artifactId the prefix tidepool means.

  • A level (in the artifactId directory): the list of versions, with latest, release and lastUpdated. It is how a tool resolves a range such as [1.0,2.0), RELEASE or Gradle’s 1.+, and how a repository browser lists versions.

  • V level (in a -SNAPSHOT version directory, snapshot repositories only): maps 1.2.0-SNAPSHOT to the newest timestamped build.

After the three Maven deployments described below (versions 1.0.0 and 1.1.0, then 1.2.0-SNAPSHOT twice), the A-level file of tidepool-model reads:

build/repo/io/tidepool/tidepool-model/maven-metadata.xml
<?xml version="1.0" encoding="UTF-8"?>
<metadata>
  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-model</artifactId>
  <versioning>
    <release>1.1.0</release>
    <versions>
      <version>1.0.0</version>
      <version>1.1.0</version>
      <version>1.2.0-SNAPSHOT</version>
    </versions>
    <lastUpdated>20261016091504</lastUpdated>
  </versioning>
</metadata>

release is the newest non-snapshot version, so 1.1.0 and not 1.2.0-SNAPSHOT. The second snapshot deployment, made a minute after the first, wrote a timestamped build and updated the V-level file in 1.2.0-SNAPSHOT/ (abridged to the jar and pom entries):

build/repo/io/tidepool/tidepool-model/1.2.0-SNAPSHOT/maven-metadata.xml
<?xml version="1.0" encoding="UTF-8"?>
<metadata modelVersion="1.1.0">
  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-model</artifactId>
  <versioning>
    <lastUpdated>20261016091504</lastUpdated>
    <snapshot>
      <timestamp>20261016.091504</timestamp>
      <buildNumber>2</buildNumber>
    </snapshot>
    <snapshotVersions>
      <snapshotVersion>
        <extension>pom</extension>
        <value>1.2.0-20261016.091504-2</value>
        <updated>20261016091504</updated>
      </snapshotVersion>
      <snapshotVersion>
        <extension>jar</extension>
        <value>1.2.0-20261016.091504-2</value>
        <updated>20261016091504</updated>
      </snapshotVersion>
      <!-- … the sources and javadoc JARs and the .asc signatures follow -->
    </snapshotVersions>
  </versioning>
  <version>1.2.0-SNAPSHOT</version>
</metadata>

A remote repository never holds a file called tidepool-model-1.2.0-SNAPSHOT.jar: each deployment adds files such as tidepool-model-1.2.0-20261016.091504-2.jar, and consumers learn which one is newest from the V-level file. The layout documentation adds that only a locally installed snapshot uses the plain -SNAPSHOT base version in its file name; the timestamped names are used in remote repositories, when the artifact is deployed.

Who writes what

The same two modules published by each tool produce slightly different file sets. The listings below come from the real build/repo directories of this page’s builds:

File Maven deploy Gradle maven-publish Ant Resolver tasks Ant with Ivy

.pom of the module

yes (a copy of pom.xml)

yes (generated)

yes (a copy of pom.xml)

yes (from ivy:makepom)

parent POM deployed

yes (tidepool-parent)

no (no parent)

no (no parent)

no (no parent)

-sources.jar, -javadoc.jar

yes (with the source and Javadoc plugins)

yes (withSourcesJar(), withJavadocJar())

yes (declared as classified artifacts)

sources yes, Javadoc not built here

.md5, .sha1

yes

yes

yes

yes

.sha256, .sha512

no

yes

no

no

.asc for every file

with maven-gpg-plugin

with the signing plugin

with gpg run from the build file

not built here

.module (Gradle Module Metadata)

no

yes

no

no

maven-metadata.xml

A level, plus V level for snapshots

A level

A level

none

Two consequences follow. First, a repository published by Ivy has no maven-metadata.xml, so a consumer that has to list versions (a range, RELEASE, Gradle’s 1.+) has nothing to read in that repository (the Ivy ibiblio resolver can list versions from a directory instead of metadata when useMavenMetadata is false); fixed versions resolve fine, as the consumers below show. Second, the deployed POM is not always what Maven built: the raw copy Maven deploys keeps its parent and ${project.version} references and relies on the parent being in the same repository, while Gradle, Ant and Ivy write flat POMs with every value resolved. Maven 4 (release candidate) changes this by publishing a separate consumer POM; see Maven 4: POM Model & Consumer POM.

Checksums and signatures in practice

A checksum detects accidental corruption; a signature proves who published the file. Both are plain files next to the artifact, and both can be checked by hand. On the Maven-published JAR:

cd build/repo/io/tidepool/tidepool-model/1.0.0
cat tidepool-model-1.0.0.jar.sha1
shasum -a 1 tidepool-model-1.0.0.jar
gpg --verify tidepool-model-1.0.0.jar.asc tidepool-model-1.0.0.jar
e8a8ce283dc8e5344cbff7f85c63a1daf2769546
e8a8ce283dc8e5344cbff7f85c63a1daf2769546  tidepool-model-1.0.0.jar
gpg: Signature made Fri Oct 16 11:12:29 2026 CEST
gpg:                using EDDSA key 35B3E2EB8D77E61B4347515F2B79206E5B78A033
gpg: Good signature from "Tidepool Demo (throwaway) <demo@example.invalid>" [ultimate]

The key is a throwaway generated in a scratch directory for these runs; it signs nothing real. To generate one without touching your own keyring, give GnuPG a separate home directory and a passphrase taken from the environment:

export GNUPGHOME="$HOME/tpgpg"
mkdir -m 700 "$GNUPGHOME"
gpg --batch --pinentry-mode loopback --passphrase "$SIGNING_PASSWORD" \
    --quick-gen-key "Tidepool Demo (throwaway) <demo@example.invalid>" ed25519 sign 1d

GNUPGHOME has to be a short path. Under the long scratch path used for these runs, gpg-agent could not create its socket and key generation failed with gpg: can't connect to the agent: File name too long. Never commit a passphrase or a secret key; the builds below read the passphrase from SIGNING_PASSWORD (and Gradle the key from SIGNING_KEY) in the environment. How the consumer decides to trust a signature or a checksum is the subject of Supply Chain Security.

Maven Central and the Central Publisher Portal

Maven Central is the default repository of all three tools (https://repo.maven.apache.org/maven2/, also served as repo1.maven.org). Ant with Ivy’s ibiblio resolver, Ant with the Resolver tasks (central is the only default repository), Maven (the central repository of the super POM) and Gradle (mavenCentral()) all read it with the layout above. What changed recently is the write side.

The end of OSSRH

For many years open source projects published to Maven Central by deploying with the Maven Deploy Plugin (or Gradle’s maven-publish) to the Sonatype OSSRH staging service, then releasing the staging repository by hand. OSSRH reached end of life on 2025-06-30 and was shut down; every OSSRH namespace was migrated to the Central Publisher Portal (central.sonatype.com), and the same username and password can be used to log in. The Gradle documentation states the consequence directly: Maven Central no longer supports the legacy deployment protocol used by the Maven Deploy Plugin, so publishing to Central requires a dedicated plugin or a bundle upload. Books and blog posts that show <repository> entries pointing at oss.sonatype.org or s01.oss.sonatype.org describe a service that no longer exists.

The Portal accepts a release in three ways:

  • The Maven plugin. org.sonatype.central:central-publishing-maven-plugin (0.12.0 at the time of writing) is added with <extensions>true</extensions>; mvn deploy then builds a bundle and uploads it.

  • A bundle upload. A ZIP (or tar.gz, up to 1 GB, possibly holding several components) laid out exactly like the repository above, uploaded by hand in the Portal or with the Portal Publisher API.

  • Community Gradle plugins. There is no official Gradle plugin for the Portal. Sonatype lists JReleaser and a number of third-party plugins, and also offers an OSSRH Staging API compatibility service that lets an unchanged maven-publish configuration keep working after the tokens and the URL are replaced.

Ant has no plugin, so an Ant build produces the bundle itself, which the next section does.

Namespace, metadata and files Central requires

Before the first upload you register a namespace in the Portal, which is a groupId prefix you must prove you control. For a domain you own the proof is a DNS TXT record on the reversed domain (control example.com and you may publish com.example.*); for GitHub-hosted projects the Portal can verify io.github.<user> automatically. A namespace is registered once and then covers every module published under it; io.tidepool is a made-up namespace here, so nothing on this page can be published.

Central validates every deployment against these rules:

  • Coordinates: a release version (never ending in -SNAPSHOT), and a groupId inside a verified namespace.

  • POM metadata: name, description, url, at least one license, at least one developer, and an scm section with connection, developerConnection and url (the URL need not be public). The POM of the tidepool modules carries all of them.

  • Sources and Javadoc: a -sources.jar and a -javadoc.jar for every JAR (placeholder JARs are accepted for projects with nothing to document).

  • Checksums: .md5 and .sha1 for every file; .sha256 and .sha512 are accepted.

  • Signatures: an .asc for every file, including the POM.

The Maven plugin generates the checksums, but it does not create the Javadoc or sources JARs, the signatures, or the POM metadata. Snapshots are separate: they are accepted only for namespaces where snapshots were explicitly enabled, without validation, and are cleaned up after a period (currently 90 days).

Building a bundle from any tool’s repository

The repository that Ant’s Resolver tasks wrote above already has the structure Central expects: for every file an .asc, .md5 and .sha1. A bundle is therefore a ZIP of the io/ tree; the maven-metadata.xml files are left out, because the example tree in the Central documentation has none and the Maven plugin deletes its own staging metadata before it zips:

cd build/repo
zip -qr ../../tidepool-1.0.0-bundle.zip io -x '*/maven-metadata.xml*'
unzip -Z1 ../../tidepool-1.0.0-bundle.zip | grep -v '/$' | grep tidepool-model
io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0-javadoc.jar
io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0-sources.jar
io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0-javadoc.jar.asc
io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0-javadoc.jar.md5
io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom.sha1
io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0-javadoc.jar.sha1
io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar.md5
io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0-sources.jar.sha1
io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom
io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0-sources.jar.md5
io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0-sources.jar.asc
io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar.asc
io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom.md5
io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar
io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar.sha1
io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom.asc

The upload itself is a single authenticated request to the Portal Publisher API. It is not run here: it needs a verified namespace and a user token, and the namespace used on this page does not exist. The call has this shape, with the token read from the environment (generate one on the Portal’s account page; never commit it):

curl --request POST \
     --header "Authorization: Bearer ${CENTRAL_TOKEN}" \
     --form bundle=@tidepool-1.0.0-bundle.zip \
     "https://central.sonatype.com/api/v1/publisher/upload?name=tidepool-1.0.0&publishingType=USER_MANAGED"

CENTRAL_TOKEN stands for the base64 encoding of the user token’s username:password pair, which is the value the API expects after Bearer. publishingType is AUTOMATIC (validate, then publish if valid) or USER_MANAGED, the default (validate, then wait for a manual release in the Portal). The Maven plugin does exactly this for you from mvn deploy; its configuration and its output are covered, again as "not run here", in Maven: Publishing to Maven Central.

Where each tool keeps what it downloads

Publishing is the rare half of repository work; the common half is consuming, and every tool keeps a local copy of what it resolves. The three caches are different on purpose, and none of them is a repository you should publish by copying files.

flowchart LR maven["Maven"] --> m2["Maven local repository: ~/.m2/repository"] resolver["Ant with the Resolver tasks"] --> m2 gradletool["Gradle"] --> gc["Gradle cache: ~/.gradle/caches/modules-2"] ivytool["Ant with Ivy"] --> ic["Ivy cache: ~/.ivy2/cache"] m2 -- "cache miss" --> manager["Repository manager: proxy, hosted, group"] gc -- "cache miss" --> manager ic -- "cache miss" --> manager manager --> central["Maven Central"] manager --> hosted["Hosted releases and snapshots"]
Tool Cache and default location Layout and origin tracking

Maven and the Resolver Ant Tasks

The local repository, ~/.m2/repository (moved with -Dmaven.repo.local, <localRepository> in settings.xml, or the Ant localrepo element).

The same layout as a remote repository. Resolver adds _remote.repositories files that record which repository each file came from.

Gradle

The dependency cache, modules-2 under $GRADLE_USER_HOME/caches (default ~/.gradle).

Not a repository: files live under files-2.1/<group>/<module>/<version>/<sha1>/ and the parsed metadata under metadata-2.<n>/, keyed by repository so that the same coordinates from two repositories are different cache entries.

Ivy

The cache, ~/.ivy2/cache by default (defaultCacheDir in the settings file).

One directory per organisation/module holding ivy-<revision>.xml (a converted copy of any POM), ivydata-<revision>.properties and a jars/ directory; plus resolution reports in the cache root.

The listings below show the real contents for the tidepool consumers of this page. Maven’s local repository after resolving tidepool-engine from a repository named tidepool:

io/tidepool/tidepool-engine/1.0.0/_remote.repositories
io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.jar
io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.jar.sha1
io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.pom
io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.pom.sha1
#NOTE: This is a Maven Resolver internal implementation file, its format can be changed without prior notice.
#Fri Oct 16 11:20:12 CEST 2026
tidepool-engine-1.0.0.jar>tidepool-d6bc14ce29278accc5e8de3329d6391aca5966a2=
tidepool-engine-1.0.0.pom>tidepool-d6bc14ce29278accc5e8de3329d6391aca5966a2=

The Gradle cache after resolving the same module over HTTP (the <sha1> directories are shown as a placeholder):

files-2.1/io.tidepool/tidepool-engine/1.0.0/<sha1>/tidepool-engine-1.0.0.jar
files-2.1/io.tidepool/tidepool-engine/1.0.0/<sha1>/tidepool-engine-1.0.0.pom
files-2.1/io.tidepool/tidepool-model/1.0.0/<sha1>/tidepool-model-1.0.0.jar
files-2.1/io.tidepool/tidepool-model/1.0.0/<sha1>/tidepool-model-1.0.0.pom
files-2.1/io.tidepool/tidepool-parent/1.0.0/<sha1>/tidepool-parent-1.0.0.pom

And the Ivy cache of the Ivy build (the tidepool-model entry was resolved from the file repository, the others from Central):

commons-io/commons-io/ivy-2.20.0.xml
commons-io/commons-io/ivy-2.20.0.xml.original
commons-io/commons-io/ivydata-2.20.0.properties
commons-io/commons-io/jars/commons-io-2.20.0.jar
io.tidepool/tidepool-model/ivy-1.0.0.xml
io.tidepool/tidepool-model/ivy-1.0.0.xml.original
io.tidepool/tidepool-model/ivydata-1.0.0.properties
io.tidepool/tidepool-model/jars/tidepool-model-1.0.0.jar
org.apache.commons/commons-csv/ivy-1.14.1.xml
org.apache.commons/commons-csv/ivydata-1.14.1.properties
org.apache.commons/commons-csv/jars/commons-csv-1.14.1.jar

Why Gradle does not share ~/.m2

Gradle’s cache is not a Maven repository, and Gradle does not read ~/.m2 unless you ask it to with mavenLocal(). According to the Gradle documentation every entry in the metadata cache records the repository that provided it and a timestamp for expiry, there is a separate metadata cache per repository, the files are stored under their checksum without a separate file store per repository, and file-based locking lets several Gradle processes use the cache concurrently. Those guarantees depend on Gradle being the only writer, which a directory shared with Maven, Ant and arbitrary scripts cannot promise. The Gradle documentation also says that for a local Maven or Ivy repository Gradle bypasses its dependency cache entirely, because nothing guarantees the content stays unchanged between runs, and recommends such repositories only for prototyping.

You can see this on this page’s runs. The Gradle consumer resolved tidepool-engine from build/repo as a plain file: URL and the cache directory files-2.1/io.tidepool was never created; the same consumer pointed at the same files served by a local HTTP server created the five entries listed above.

mavenLocal() pitfalls

mavenLocal() adds the Maven local repository as a Gradle repository. It is tempting because mvn install and publishToMavenLocal put things there, and it is the classic cause of builds that pass on one machine and fail on another:

  • It is a file repository, so it bypasses the Gradle cache and its verification (see above).

  • It is whatever happens to be installed on this machine. A teammate or a CI agent that never ran mvn install has nothing there; and a stale snapshot or a half-finished experiment can silently win over the published release.

  • It is searched in declaration order. Placed before mavenCentral(), it answers for every group, so a corrupted or hand-installed copy of an unrelated library wins, with no signature or checksum comparison.

  • It makes builds hard to reproduce: the result depends on files that are not part of the build.

The following run shows both sides with the tidepool consumer. Without mavenLocal() the artifact installed by Maven is invisible to Gradle (Maven Central does not contain io.tidepool):

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':compileJava'.
> Could not resolve all files for configuration ':compileClasspath'.
   > Could not find io.tidepool:tidepool-engine:1.0.0.
     Searched in the following locations:
       - https://repo.maven.apache.org/maven2/io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.pom
     Required by:
         root project 'tidepool-consumer'

With mavenLocal() declared and the Maven local repository pointed at the directory that holds the install (-Dmaven.repo.local=…, which Gradle honours, as it does the localRepository setting of settings.xml), the same build prints the three station lines. If you must use it, restrict it to the groups you install locally with a content filter, as the next page section shows, and never depend on it in CI. Publishing into ~/.m2 needs no mavenLocal() in the publishing.repositories block: the Maven Publish Plugin creates a publishToMavenLocal task for that.

Repository managers

A repository manager is a server that stores and serves binary components. Maven’s own best-practice page calls using one an essential practice for any significant use of Maven, and the arguments apply unchanged to Ant and Gradle builds. Three repository kinds exist in every product:

  • A proxy repository caches a remote repository (usually Maven Central). The first request for a file downloads it from Central; later requests, from any developer or CI agent, are served from the manager. Builds get faster, keep working when Central is unreachable, and every consumed component is recorded in one place.

  • A hosted repository holds what your organisation publishes: releases (immutable) and snapshots (overwritable), usually in two separate repositories.

  • A group repository merges several proxy and hosted repositories behind one URL, so that every build needs a single repository entry.

The products the Maven documentation lists include open source and commercial servers; the best known are Sonatype Nexus Repository, JFrog Artifactory (an open source edition exists, free but not open source) and Reposilite (open source). Apache Archiva, which older tutorials and the Maven 2 book use, was retired: the project moved to the Apache Attic in 2024 (retired February 2024, move completed May 2024) and receives no more releases, so choose one of the others. The concepts the old books teach — proxy, hosted and group repositories, mirrors, deployment accounts — are exactly the ones above.

All three build tools point at the manager the same way, as the only repository for everything:

  • Maven: a <mirror> in settings.xml with <mirrorOf>*</mirrorOf> redirects every repository, including Central and the repositories declared in POMs, to the group URL.

  • Gradle: one maven { url = … } repository in dependencyResolutionManagement in settings.gradle(.kts), with RepositoriesMode.FAIL_ON_PROJECT_REPOS so that no build script can add another, or an init script that does the same for every project on the machine.

  • Ant: a remoterepo (plus an optional mirror) in the Resolver tasks, or an ibiblio resolver with root in the Ivy settings.

The Maven mirror is the smallest example. It is not run here: it needs a repository manager, and the group URL below is a placeholder for a Nexus, Artifactory or Reposilite instance that answers on localhost.

~/.m2/settings.xml (not run here)
<settings>
  <mirrors>
    <mirror>
      <id>company-group</id>
      <mirrorOf>*</mirrorOf>
      <url>http://localhost:8081/repository/maven-public/</url>
    </mirror>
  </mirrors>
  <servers>
    <server>
      <id>company-group</id>
      <username>${env.REPO_USER}</username>
      <password>${env.REPO_PASSWORD}</password>
    </server>
  </servers>
</settings>

The <server> entry is matched to the mirror by id; credentials come from environment variables or from the encrypted passwords described in Maven: settings.xml & Password Encryption, and settings.xml itself must never be committed. Maven 3.10.0 additionally scopes credentials to the origin of a repository, so a password configured for one host is not offered to another. The full mirror, repository-order and proxy rules are in Maven: Repositories, Mirrors & Repository Managers; for Gradle see Gradle: Repositories & Content Filtering.

What a client enforces about the transport

Two checks happen before any byte of an artifact is read, and both produce error messages that readers meet in practice. Both runs below use a tiny stand-in for a repository server: python3 -m http.server 8089 --bind 127.0.0.1 started inside build/repo, which serves the files read-only over plain HTTP.

Maven blocks plain HTTP for repositories other than localhost. Since Maven 3.8.1 a built-in mirror named maven-default-http-blocker refuses http:// repositories (a defence against man-in-the-middle attacks on dependency downloads). Asking for the tidepool modules at an HTTP host that is not local fails before any request is made:

[ERROR] Failed to execute goal on project tidepool-consumer: Could not collect dependencies for project io.tidepool.demo:tidepool-consumer:jar:1.0.0
[ERROR] Failed to read artifact descriptor for io.tidepool:tidepool-engine:jar:1.0.0
[ERROR] 	Caused by: The following artifacts could not be resolved: io.tidepool:tidepool-engine:pom:1.0.0 (absent): Could not transfer artifact io.tidepool:tidepool-engine:pom:1.0.0 from/to maven-default-http-blocker (http://0.0.0.0/): Blocked mirror for repositories: [tidepool (http://repo.example.org/releases, default, releases)]

The same repository at http://localhost:8089 works, and the server log shows what Maven asks for: each file and its .sha1.

127.0.0.1 - - [16/Oct/2026 11:20:12] "GET /io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.pom HTTP/1.1" 200 -
127.0.0.1 - - [16/Oct/2026 11:20:12] "GET /io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.pom.sha1 HTTP/1.1" 200 -
127.0.0.1 - - [16/Oct/2026 11:20:12] "GET /io/tidepool/tidepool-parent/1.0.0/tidepool-parent-1.0.0.pom HTTP/1.1" 200 -

Gradle refuses plain HTTP unless you opt in. The same HTTP URL in a Gradle repository fails with:

> Could not resolve all dependencies for configuration ':compileClasspath'.
   > Using insecure protocols with repositories, without explicit opt-in, is unsupported.

* Try:
> Switch Maven repository 'tidepool(http://localhost:8089)' to redirect to a secure protocol (like HTTPS) or allow insecure protocols.

The consumer builds below turn isAllowInsecureProtocol (allowInsecureProtocol in Groovy) on only when the URL scheme is http, which is acceptable for localhost and for nothing else. Ant’s Resolver tasks support only file:, http: and https: remote repositories.

Maven Central filters by prefix. Central publishes a list of the namespaces it contains, and Maven Resolver’s remote repository filtering uses it so that a request for a path that cannot exist there is never sent. The failure message of the checksum test below includes the line prefixes: Path io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar NOT allowed from central, which is Resolver deciding that io/tidepool is not a Central namespace and therefore not asking Central about it. This keeps internal coordinates from leaking to a public server. Filtering other repositories is configuration (a prefix file or a groupId filter in ${localRepo}/.remoteRepositoryFilters) and Maven’s own documentation cautions against limiting what Central may serve.

Publishing tidepool to build/repo

The two modules are published to build/repo four ways. All four publish io.tidepool:tidepool-model:1.0.0 and io.tidepool:tidepool-engine:1.0.0, where the engine depends on the model and on Apache Commons CSV 1.14.1, so each published POM has to say so. No example publishes anywhere else.

Maven: mvn deploy

Maven’s coordinates, dependencies and repository come from one file, so publishing is a lifecycle phase. The parent POM holds the metadata Central needs, the distributionManagement repository (a file: URL under the project directory) and a sign profile that attaches the maven-gpg-plugin; the Source and Javadoc plugins run in every build. This runs on Maven 3.10.0 with the plugin versions pinned (see Maven: Plugins & Their Configuration for why).

pom.xml (the parent)
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-parent</artifactId>
  <version>1.0.0</version>
  <packaging>pom</packaging>

  <name>tidepool</name>
  <description>Predicts tide heights for coastal stations from harmonic constituents.</description>
  <url>https://tidepool.example.org</url>

  <licenses>
    <license>
      <name>Apache License, Version 2.0</name>
      <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
      <distribution>repo</distribution>
    </license>
  </licenses>
  <developers>
    <developer>
      <name>Tidepool Maintainers</name>
      <email>maintainers@tidepool.example.org</email>
      <organization>Tidepool</organization>
      <organizationUrl>https://tidepool.example.org</organizationUrl>
    </developer>
  </developers>
  <scm>
    <connection>scm:git:https://git.example.org/tidepool.git</connection>
    <developerConnection>scm:git:ssh://git.example.org/tidepool.git</developerConnection>
    <url>https://git.example.org/tidepool</url>
  </scm>

  <modules>
    <module>tidepool-model</module>
    <module>tidepool-engine</module>
  </modules>

  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.build.outputTimestamp>2026-10-16T00:00:00Z</project.build.outputTimestamp>
  </properties>

  <distributionManagement>
    <repository>
      <id>local-file</id>
      <url>file://${maven.multiModuleProjectDirectory}/build/repo</url>
    </repository>
    <snapshotRepository>
      <id>local-file</id>
      <url>file://${maven.multiModuleProjectDirectory}/build/repo</url>
    </snapshotRepository>
  </distributionManagement>

  <build>
    <pluginManagement>
      <plugins>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-compiler-plugin</artifactId>
          <version>3.16.0</version>
        </plugin>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-jar-plugin</artifactId>
          <version>3.5.1</version>
        </plugin>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-resources-plugin</artifactId>
          <version>3.5.0</version>
        </plugin>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-install-plugin</artifactId>
          <version>3.2.0</version>
        </plugin>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-deploy-plugin</artifactId>
          <version>3.2.0</version>
        </plugin>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-surefire-plugin</artifactId>
          <version>3.6.0</version>
        </plugin>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-source-plugin</artifactId>
          <version>3.4.0</version>
        </plugin>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-javadoc-plugin</artifactId>
          <version>3.12.0</version>
        </plugin>
        <plugin>
          <groupId>org.apache.maven.plugins</groupId>
          <artifactId>maven-gpg-plugin</artifactId>
          <version>3.2.8</version>
        </plugin>
      </plugins>
    </pluginManagement>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-source-plugin</artifactId>
        <executions>
          <execution>
            <id>attach-sources</id>
            <goals>
              <goal>jar-no-fork</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-javadoc-plugin</artifactId>
        <executions>
          <execution>
            <id>attach-javadocs</id>
            <goals>
              <goal>jar</goal>
            </goals>
          </execution>
        </executions>
      </plugin>
    </plugins>
  </build>

  <profiles>
    <profile>
      <id>sign</id>
      <build>
        <plugins>
          <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-gpg-plugin</artifactId>
            <executions>
              <execution>
                <id>sign-artifacts</id>
                <phase>verify</phase>
                <goals>
                  <goal>sign</goal>
                </goals>
              </execution>
            </executions>
          </plugin>
        </plugins>
      </build>
    </profile>
  </profiles>
</project>

Each module’s POM names the parent and its own dependencies. The engine’s is:

tidepool-engine/pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <parent>
    <groupId>io.tidepool</groupId>
    <artifactId>tidepool-parent</artifactId>
    <version>1.0.0</version>
  </parent>
  <artifactId>tidepool-engine</artifactId>
  <name>tidepool-engine</name>
  <description>Reads stations from CSV and predicts tide heights.</description>
  <dependencies>
    <dependency>
      <groupId>io.tidepool</groupId>
      <artifactId>tidepool-model</artifactId>
      <version>${project.version}</version>
    </dependency>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-csv</artifactId>
      <version>1.14.1</version>
    </dependency>
  </dependencies>
</project>

The file URL uses ${maven.multiModuleProjectDirectory}, which is the directory holding the .mvn folder, so the project needs an (empty) .mvn directory. The commands below pass -Dmaven.repo.local=local-m2 only to keep this demonstration out of the real ~/.m2; they also install into it, which is why a deploy leaves the artifacts in the local repository too. The signing key and passphrase come from GNUPGHOME, SIGNING_PASSWORD and the gpg plugin’s MAVEN_GPG_PASSPHRASE variable:

export GNUPGHOME="$HOME/tpgpg" MAVEN_GPG_PASSPHRASE="$SIGNING_PASSWORD"
mvn -Dmaven.repo.local=local-m2 --batch-mode -Psign -Dgpg.keyname=<key id> deploy
[INFO] Reactor Build Order:
[INFO] tidepool                                                           [pom]
[INFO] tidepool-model                                                     [jar]
[INFO] tidepool-engine                                                    [jar]
…
[INFO] --- source:3.4.0:jar-no-fork (attach-sources) @ tidepool-model ---
[INFO] Building jar: …/tidepool-model/target/tidepool-model-1.0.0-sources.jar
[INFO] --- javadoc:3.12.0:jar (attach-javadocs) @ tidepool-model ---
[INFO] Building jar: …/tidepool-model/target/tidepool-model-1.0.0-javadoc.jar
[INFO] --- gpg:3.2.8:sign (sign-artifacts) @ tidepool-model ---
[INFO] --- install:3.2.0:install (default-install) @ tidepool-model ---
[INFO] --- deploy:3.2.0:deploy (default-deploy) @ tidepool-model ---
[INFO] Uploaded to local-file: file://…/build/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom (589 B at 589 kB/s)
[INFO] Uploaded to local-file: file://…/build/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar (5.3 kB at 2.6 MB/s)
…
[INFO] Downloading from local-file: file://…/build/repo/io/tidepool/tidepool-model/maven-metadata.xml
[INFO] Uploaded to local-file: file://…/build/repo/io/tidepool/tidepool-model/maven-metadata.xml (305 B)
…
[INFO] Reactor Summary for tidepool 1.0.0:
[INFO] tidepool ........................................... SUCCESS [  6.039 s]
[INFO] tidepool-model ..................................... SUCCESS [  4.503 s]
[INFO] tidepool-engine .................................... SUCCESS [  1.300 s]
[INFO] BUILD SUCCESS

The order of the lifecycle explains the order of the work: the signing plugin is bound to verify, so it signs after the JARs exist and before install and deploy copy them. Deploy then reads the existing A-level metadata from the target repository (the "Downloading … maven-metadata.xml" line), merges the new version into it, and writes it back with fresh checksums. The parent POM is deployed too, because the engine’s POM refers to it. Versions 1.1.0 and 1.2.0-SNAPSHOT were produced by changing the version in all three POMs with the Versions Maven Plugin and deploying again:

mvn -Dmaven.repo.local=local-m2 --batch-mode org.codehaus.mojo:versions-maven-plugin:2.23.0:set \
    -DnewVersion=1.1.0 -DgenerateBackupPoms=false
mvn -Dmaven.repo.local=local-m2 --batch-mode -Psign -Dgpg.keyname=<key id> deploy

Snapshots go to the same directory because snapshotRepository points at it; production setups use two repositories, so that releases can be immutable while snapshots are overwritten freely. Hosted release repositories are normally configured to reject redeployment of an existing version (a setting of the repository manager), and Maven Central never modifies or removes a published component, which is why a published version is never fixed in place — a corrected build gets the next version.

Gradle: maven-publish

In Gradle publishing is an extension (publishing) configured in each module, plus the signing plugin. withSourcesJar() and withJavadocJar() add the extra JARs to the java component; from(components["java"]) publishes the main JAR together with them, and the pom block fills in the metadata Central requires. Gradle generates the POM and the .module file and fills in the checksums. Both modules are configured the same way; the model is shown first, then the lines that differ in the engine.

tidepool-model/build.gradle.kts
plugins {
    `java-library`
    `maven-publish`
    signing
}

group = "io.tidepool"
version = "1.0.0"
description = "Station, constituent and height value types."

java {
    withSourcesJar()
    withJavadocJar()
}

tasks.withType<JavaCompile>().configureEach {
    options.release = 17
}

publishing {
    publications {
        create<MavenPublication>("maven") {
            from(components["java"])
            pom {
                name = "tidepool-model"
                description = project.description
                url = "https://tidepool.example.org"
                licenses {
                    license {
                        name = "Apache License, Version 2.0"
                        url = "https://www.apache.org/licenses/LICENSE-2.0.txt"
                    }
                }
                developers {
                    developer {
                        name = "Tidepool Maintainers"
                        email = "maintainers@tidepool.example.org"
                        organization = "Tidepool"
                        organizationUrl = "https://tidepool.example.org"
                    }
                }
                scm {
                    connection = "scm:git:https://git.example.org/tidepool.git"
                    developerConnection = "scm:git:ssh://git.example.org/tidepool.git"
                    url = "https://git.example.org/tidepool"
                }
            }
        }
    }
    repositories {
        maven {
            name = "localFile"
            url = uri(rootProject.layout.buildDirectory.dir("repo"))
        }
    }
}

signing {
    val signingKey: String? = providers.environmentVariable("SIGNING_KEY").orNull
    val signingPassword: String? = providers.environmentVariable("SIGNING_PASSWORD").orNull
    isRequired = signingKey != null
    useInMemoryPgpKeys(signingKey, signingPassword)
    sign(publishing.publications["maven"])
}
tidepool-model/build.gradle
plugins {
    id 'java-library'
    id 'maven-publish'
    id 'signing'
}

group = 'io.tidepool'
version = '1.0.0'
description = 'Station, constituent and height value types.'

java {
    withSourcesJar()
    withJavadocJar()
}

tasks.withType(JavaCompile).configureEach {
    options.release = 17
}

publishing {
    publications {
        maven(MavenPublication) {
            from components.java
            pom {
                name = 'tidepool-model'
                description = project.description
                url = 'https://tidepool.example.org'
                licenses {
                    license {
                        name = 'Apache License, Version 2.0'
                        url = 'https://www.apache.org/licenses/LICENSE-2.0.txt'
                    }
                }
                developers {
                    developer {
                        name = 'Tidepool Maintainers'
                        email = 'maintainers@tidepool.example.org'
                        organization = 'Tidepool'
                        organizationUrl = 'https://tidepool.example.org'
                    }
                }
                scm {
                    connection = 'scm:git:https://git.example.org/tidepool.git'
                    developerConnection = 'scm:git:ssh://git.example.org/tidepool.git'
                    url = 'https://git.example.org/tidepool'
                }
            }
        }
    }
    repositories {
        maven {
            name = 'localFile'
            url = rootProject.layout.buildDirectory.dir('repo')
        }
    }
}

signing {
    def signingKey = providers.environmentVariable('SIGNING_KEY').orNull
    def signingPassword = providers.environmentVariable('SIGNING_PASSWORD').orNull
    required = signingKey != null
    useInMemoryPgpKeys(signingKey, signingPassword)
    sign publishing.publications.maven
}

The engine’s build file is the same with tidepool-engine as the name and description, and a dependencies block before group:

tidepool-engine/build.gradle.kts (added block)
dependencies {
    api(project(":tidepool-model"))
    implementation("org.apache.commons:commons-csv:1.14.1")
}
tidepool-engine/build.gradle (added block)
dependencies {
    api project(':tidepool-model')
    implementation 'org.apache.commons:commons-csv:1.14.1'
}
settings.gradle.kts
rootProject.name = "tidepool"

dependencyResolutionManagement {
    repositories {
        mavenCentral()
    }
}

include("tidepool-model", "tidepool-engine")

The key and passphrase come from the environment: SIGNING_KEY holds the ASCII-armoured secret key as a single value (a CI secret) and SIGNING_PASSWORD its passphrase; with no key the build still publishes, unsigned, because isRequired is false.

export SIGNING_KEY="$(cat throwaway-key.asc)" SIGNING_PASSWORD=…
gradle --console=plain publish
> Task :tidepool-engine:compileJava
> Task :tidepool-engine:jar
> Task :tidepool-engine:javadocJar
> Task :tidepool-engine:sourcesJar
> Task :tidepool-engine:generateMetadataFileForMavenPublication
> Task :tidepool-engine:generatePomFileForMavenPublication
> Task :tidepool-engine:signMavenPublication
> Task :tidepool-engine:publishMavenPublicationToLocalFileRepository
> Task :tidepool-engine:publish
> Task :tidepool-model:signMavenPublication
> Task :tidepool-model:publishMavenPublicationToLocalFileRepository
> Task :tidepool-model:publish

BUILD SUCCESSFUL in 8s

The task names follow publish<Publication>PublicationTo<Repository>Repository, so an extra repository named staging would add publishMavenPublicationToStagingRepository. Running the Groovy DSL version of the same build produced an identical file tree and a byte-identical engine POM. The generated engine POM shows how Gradle maps configurations to Maven scopes:

build/repo/io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.pom (dependencies)
  <dependencies>
    <dependency>
      <groupId>io.tidepool</groupId>
      <artifactId>tidepool-model</artifactId>
      <version>1.0.0</version>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-csv</artifactId>
      <version>1.14.1</version>
      <scope>runtime</scope>
    </dependency>
  </dependencies>

api became compile scope and implementation became runtime: a consumer compiles against the model but not against Commons CSV, which is exactly the encapsulation the Java Library plugin promises. That encapsulation is lost for any consumer that reads only the POM and treats a scope it has not seen as compile, and it is why the Ant consumer below can compile against the Gradle-published engine without Commons CSV on its compile classpath but not against Maven’s, where the same dependency was declared without a scope and so is compile. The generated POM also starts with a comment containing the marker do_not_remove: published-with-gradle-metadata, which tells Gradle consumers to read the .module file next to it instead; that file carries the full variant information (the apiElements and runtimeElements variants with their attributes and file checksums).

Ant with the Maven Resolver Ant Tasks

Ant has no coordinates, no dependency model and no publication lifecycle: you write the steps. The Maven Artifact Resolver Ant Tasks (2.0.0) borrow Maven’s: a resolver:pom element reads a POM, resolver:deploy uploads the artifacts you list under the coordinates in it, and resolver:resolve builds classpaths from dependencies. The uber JAR (about 4.9 MB, downloaded from Maven Central and checked against its published .sha1) goes in the project’s lib directory and is loaded with a taskdef on the antlib URI antlib:org.apache.maven.resolver.ant.

Each module needs a plain pom.xml because Ant has nowhere else to hold the metadata. They duplicate the licenses, developers and scm blocks of the Maven parent, since Ant has no inheritance; the engine’s declares the model dependency and, as the Gradle build does, puts Commons CSV in runtime scope:

tidepool-engine/pom.xml (Ant build)
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-engine</artifactId>
  <version>1.0.0</version>

  <name>tidepool-engine</name>
  <description>Reads stations from CSV and predicts tide heights.</description>
  <url>https://tidepool.example.org</url>

  <licenses>
    <license>
      <name>Apache License, Version 2.0</name>
      <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
    </license>
  </licenses>
  <developers>
    <developer>
      <name>Tidepool Maintainers</name>
      <email>maintainers@tidepool.example.org</email>
      <organization>Tidepool</organization>
      <organizationUrl>https://tidepool.example.org</organizationUrl>
    </developer>
  </developers>
  <scm>
    <connection>scm:git:https://git.example.org/tidepool.git</connection>
    <developerConnection>scm:git:ssh://git.example.org/tidepool.git</developerConnection>
    <url>https://git.example.org/tidepool</url>
  </scm>

  <dependencies>
    <dependency>
      <groupId>io.tidepool</groupId>
      <artifactId>tidepool-model</artifactId>
      <version>1.0.0</version>
    </dependency>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-csv</artifactId>
      <version>1.14.1</version>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
</project>

The build file is one macro that compiles, packages, signs and deploys a module, called twice. The signing step runs the gpg command-line tool (Ant has no signing task); the passphrase is fed on standard input from the SIGNING_PASSWORD environment variable. resolver:deploy takes a POM (which fixes the coordinates and which is also uploaded), a list of artifacts to attach (classifier for the sources and Javadoc JARs, type="jar.asc" and type="pom.asc" for the signatures, which makes them files with those extensions) and a remote repository:

build.xml
<?xml version="1.0" encoding="UTF-8"?>
<project name="tidepool" default="deploy" basedir="."
         xmlns:resolver="antlib:org.apache.maven.resolver.ant">

  <property name="version" value="1.0.0"/>
  <property name="local.repo" location="${basedir}/.m2-local"/>
  <property name="deploy.repo" location="${basedir}/build/repo"/>
  <property name="gpg.keyname" value=""/>
  <property environment="env"/>

  <taskdef uri="antlib:org.apache.maven.resolver.ant"
           resource="org/apache/maven/resolver/ant/antlib.xml"
           classpath="lib/maven-resolver-ant-tasks-2.0.0-uber.jar"/>

  <!-- Where Maven Resolver keeps its cache, and which repositories it may read and write. -->
  <resolver:localrepo dir="${local.repo}"/>
  <resolver:remoterepo id="tidepool-file" url="file:${deploy.repo}"/>

  <path id="empty.cp"/>

  <!-- Builds, packages, signs and deploys one module; the module's pom.xml supplies the coordinates. -->
  <macrodef name="publish-module">
    <attribute name="name"/>
    <attribute name="classpathref" default="empty.cp"/>
    <sequential>
      <local name="out"/>
      <property name="out" location="build/@{name}"/>
      <mkdir dir="${out}/classes"/>
      <javac srcdir="@{name}/src/main/java" destdir="${out}/classes" release="17"
             includeantruntime="false" classpathref="@{classpathref}"/>
      <jar destfile="${out}/@{name}-${version}.jar" basedir="${out}/classes"/>
      <jar destfile="${out}/@{name}-${version}-sources.jar" basedir="@{name}/src/main/java"/>
      <javadoc sourcepath="@{name}/src/main/java" destdir="${out}/apidocs" classpathref="@{classpathref}"
               failonerror="true" additionalparam="-Xdoclint:none" access="protected">
        <package name="io.tidepool.*"/>
      </javadoc>
      <jar destfile="${out}/@{name}-${version}-javadoc.jar" basedir="${out}/apidocs"/>

      <!-- Signatures: one detached .asc per file, produced by the gpg command line. -->
      <apply executable="gpg" failonerror="true" parallel="false" dir="${out}"
             inputstring="${env.SIGNING_PASSWORD}">
        <arg line="--batch --yes --pinentry-mode loopback --passphrase-fd 0 --local-user ${gpg.keyname} --armor --detach-sign"/>
        <srcfile/>
        <fileset dir="${out}" includes="*.jar"/>
      </apply>
      <copy file="@{name}/pom.xml" tofile="${out}/@{name}-${version}.pom"/>
      <exec executable="gpg" failonerror="true" inputstring="${env.SIGNING_PASSWORD}">
        <arg line="--batch --yes --pinentry-mode loopback --passphrase-fd 0 --local-user ${gpg.keyname} --armor --detach-sign ${out}/@{name}-${version}.pom"/>
      </exec>

      <resolver:pom file="@{name}/pom.xml" id="pom.@{name}"/>
      <resolver:deploy>
        <pom refid="pom.@{name}"/>
        <artifacts>
          <artifact file="${out}/@{name}-${version}.jar"/>
          <artifact file="${out}/@{name}-${version}-sources.jar" classifier="sources"/>
          <artifact file="${out}/@{name}-${version}-javadoc.jar" classifier="javadoc"/>
          <artifact file="${out}/@{name}-${version}.jar.asc" type="jar.asc"/>
          <artifact file="${out}/@{name}-${version}-sources.jar.asc" type="jar.asc" classifier="sources"/>
          <artifact file="${out}/@{name}-${version}-javadoc.jar.asc" type="jar.asc" classifier="javadoc"/>
          <artifact file="${out}/@{name}-${version}.pom.asc" type="pom.asc"/>
        </artifacts>
        <remoterepo refid="tidepool-file"/>
      </resolver:deploy>
    </sequential>
  </macrodef>

  <target name="deploy" description="Build and deploy both modules to build/repo">
    <publish-module name="tidepool-model"/>
    <resolver:resolve>
      <dependencies>
        <dependency coords="org.apache.commons:commons-csv:1.14.1"/>
      </dependencies>
      <path refid="csv.cp" classpath="compile"/>
    </resolver:resolve>
    <path id="engine.cp">
      <pathelement location="build/tidepool-model/classes"/>
      <path refid="csv.cp"/>
    </path>
    <publish-module name="tidepool-engine" classpathref="engine.cp"/>
  </target>
</project>
export GNUPGHOME="$HOME/tpgpg" SIGNING_PASSWORD=…
ant -Dgpg.keyname=<key id>
Buildfile: …/build.xml

deploy:
    [javac] Compiling 4 source files to …/build/tidepool-model/classes
      [jar] Building jar: …/build/tidepool-model/tidepool-model-1.0.0.jar
      [jar] Building jar: …/build/tidepool-model/tidepool-model-1.0.0-sources.jar
      [jar] Building jar: …/build/tidepool-model/tidepool-model-1.0.0-javadoc.jar
[resolver:deploy] Uploaded to tidepool-file file:…/build/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom (2 KB at 400.7 KB/sec)
[resolver:deploy] Uploaded to tidepool-file file:…/build/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar (4 KB at 1816.4 KB/sec)
…
[resolver:deploy] Downloading from tidepool-file file:…/build/repo/io/tidepool/tidepool-model/maven-metadata.xml
[resolver:deploy] Uploaded to tidepool-file file:…/build/repo/io/tidepool/tidepool-model/maven-metadata.xml (305 B at 297.9 KB/sec)
    [javac] Compiling 2 source files to …/build/tidepool-engine/classes
…
BUILD SUCCESSFUL
Total time: 3 seconds

The resulting tree equals the one Maven wrote, file for file, except that there is no parent POM: Resolver does exactly what deploy:deploy does (the same library underneath), including reading and updating maven-metadata.xml and writing .md5 and .sha1 for each file. The resolver:resolve call also demonstrates the consuming side: it resolved Commons CSV and its two dependencies from Maven Central into the localrepo directory, so .m2-local ended up with the same layout as ~/.m2/repository. There is also a resolver:install task that installs the produced artifacts into the local repository instead of deploying them to a remote one (not run here).

Ant with Ivy

Ivy comes from the other direction: its model is the module descriptor (ivy.xml) with configurations, and the Maven layout is merely one of the repository shapes it can write. Publishing to a Maven layout therefore needs three decisions the Resolver tasks make for you: a resolver that writes in the Maven layout, a POM generated from the descriptor, and a publication list that includes the POM.

ivysettings.xml
<ivysettings>
  <property name="repo.dir" value="${ivy.settings.dir}/build/repo" override="false"/>
  <settings defaultResolver="all"/>
  <caches defaultCacheDir="${ivy.settings.dir}/.ivy2/cache"/>
  <resolvers>
    <!-- Write side: the Maven 2 layout, built from the Ivy module coordinates. -->
    <filesystem name="tidepool-publish" m2compatible="true">
      <artifact pattern="${repo.dir}/[organisation]/[module]/[revision]/[artifact]-[revision](-[classifier]).[ext]"/>
    </filesystem>
    <!-- Read side: the same directory read through POMs, then Maven Central. -->
    <chain name="all">
      <ibiblio name="tidepool-file" root="file://${repo.dir}" m2compatible="true"/>
      <ibiblio name="central" m2compatible="true"/>
    </chain>
  </resolvers>
</ivysettings>

The filesystem resolver supports publishing; the ibiblio resolver does not ("Handle publish: no" in the resolver reference), which is why the settings define two. m2compatible="true" converts the dots of the organisation into slashes, so [organisation] expands to io/tidepool, and the (-[classifier]) part of the pattern is how the sources classifier ends up in a file name. The module descriptor lists what to publish. The m:classifier attribute comes from the http://ant.apache.org/ivy/maven namespace:

tidepool-engine/ivy.xml
<ivy-module version="2.0" xmlns:m="http://ant.apache.org/ivy/maven">
  <info organisation="io.tidepool" module="tidepool-engine" revision="1.0.0" status="release">
    <license name="Apache License, Version 2.0" url="https://www.apache.org/licenses/LICENSE-2.0.txt"/>
    <description homepage="https://tidepool.example.org">Reads stations from CSV and predicts tide heights.</description>
  </info>
  <configurations>
    <conf name="compile"/>
    <conf name="runtime" extends="compile"/>
  </configurations>
  <publications>
    <artifact name="tidepool-engine" type="jar" ext="jar" conf="compile"/>
    <artifact name="tidepool-engine" type="source" ext="jar" m:classifier="sources" conf="compile"/>
    <artifact name="tidepool-engine" type="pom" ext="pom" conf="compile"/>
  </publications>
  <dependencies>
    <dependency org="io.tidepool" name="tidepool-model" rev="1.0.0" conf="compile->default"/>
    <dependency org="org.apache.commons" name="commons-csv" rev="1.14.1" conf="compile->default"/>
  </dependencies>
</ivy-module>

The model’s descriptor is the same without the dependencies block. ivy:makepom turns the descriptor into a POM, but its default template has no licenses, developers or SCM section, so a template file supplies those (Ivy fills in the ${ivy.pom.*} properties):

pom-template.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>${ivy.pom.groupId}</groupId>
  <artifactId>${ivy.pom.artifactId}</artifactId>
  <packaging>${ivy.pom.packaging}</packaging>
  <version>${ivy.pom.version}</version>
  <name>${ivy.pom.artifactId}</name>
  <description>${ivy.pom.description}</description>
  <url>${ivy.pom.url}</url>
  <licenses>
    <license>
      <name>Apache License, Version 2.0</name>
      <url>https://www.apache.org/licenses/LICENSE-2.0.txt</url>
    </license>
  </licenses>
  <developers>
    <developer>
      <name>Tidepool Maintainers</name>
      <email>maintainers@tidepool.example.org</email>
      <organization>Tidepool</organization>
      <organizationUrl>https://tidepool.example.org</organizationUrl>
    </developer>
  </developers>
  <scm>
    <connection>scm:git:https://git.example.org/tidepool.git</connection>
    <developerConnection>scm:git:ssh://git.example.org/tidepool.git</developerConnection>
    <url>https://git.example.org/tidepool</url>
  </scm>
</project>
build.xml (Ivy build)
<?xml version="1.0" encoding="UTF-8"?>
<project name="tidepool-ivy" default="publish" basedir="."
         xmlns:ivy="antlib:org.apache.ivy.ant">

  <taskdef resource="org/apache/ivy/ant/antlib.xml" uri="antlib:org.apache.ivy.ant"
           classpath="lib/ivy-2.6.0.jar"/>
  <ivy:settings file="ivysettings.xml"/>

  <macrodef name="publish-module">
    <attribute name="name"/>
    <sequential>
      <local name="out"/>
      <property name="out" location="build/@{name}"/>
      <ivy:resolve file="@{name}/ivy.xml" conf="*"/>
      <ivy:cachepath pathid="@{name}.cp" conf="compile" file="@{name}/ivy.xml"/>
      <mkdir dir="${out}/classes"/>
      <javac srcdir="@{name}/src/main/java" destdir="${out}/classes" release="17"
             includeantruntime="false" classpathref="@{name}.cp"/>
      <jar destfile="${out}/@{name}-1.0.0.jar" basedir="${out}/classes"/>
      <jar destfile="${out}/@{name}-1.0.0-sources.jar" basedir="@{name}/src/main/java"/>
      <ivy:makepom ivyfile="@{name}/ivy.xml" pomfile="${out}/@{name}-1.0.0.pom"
                  templatefile="pom-template.xml" printIvyInfo="false">
        <mapping conf="compile" scope="compile"/>
        <mapping conf="runtime" scope="runtime"/>
      </ivy:makepom>
      <ivy:publish resolver="tidepool-publish" pubrevision="1.0.0" status="release"
                   overwrite="true" publishivy="false" conf="*"
                   artifactspattern="${out}/[artifact]-[revision](-[classifier]).[ext]"/>
    </sequential>
  </macrodef>

  <target name="publish" description="Build both modules and publish them to build/repo">
    <publish-module name="tidepool-model"/>
    <publish-module name="tidepool-engine"/>
  </target>
</project>

publishivy="false" stops Ivy from also writing ivy-1.0.0.xml next to the POM, since a Maven-layout repository holds POMs, not Ivy files. The engine is built against the just-published model: ivy:resolve finds io.tidepool#tidepool-model;1.0.0 through the ibiblio resolver reading build/repo, which proves the published POM is readable. The output of ant:

Buildfile: …/build.xml

publish:
[ivy:resolve] :: Apache Ivy 2.6.0 - 20260712075753 :: https://ant.apache.org/ivy/ ::
[ivy:resolve] :: loading settings :: file = …/ivysettings.xml
[ivy:resolve] :: resolving dependencies :: io.tidepool#tidepool-model;1.0.0
…
[ivy:publish] :: publishing :: io.tidepool#tidepool-model
[ivy:publish] 	published tidepool-model to …/build/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar
[ivy:publish] 	published tidepool-model to …/build/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0-sources.jar
[ivy:publish] 	published tidepool-model to …/build/repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.pom
[ivy:resolve] :: resolving dependencies :: io.tidepool#tidepool-engine;1.0.0
[ivy:resolve] 	found io.tidepool#tidepool-model;1.0.0 in tidepool-file
[ivy:resolve] 	found org.apache.commons#commons-csv;1.14.1 in central
[ivy:resolve] 	found commons-io#commons-io;2.20.0 in central
[ivy:resolve] 	found commons-codec#commons-codec;1.19.0 in central
…
BUILD SUCCESSFUL
Total time: 2 seconds

The repository Ivy wrote has the three artifacts per module, plus .md5 and .sha1, but, as the table above shows, no maven-metadata.xml and no .asc files: signing and metadata are your job. Two details of the ivy:resolve step cost time while developing this page. First, the dependency mapping compile->compile found the model module but no artifacts in it, because a module read from a POM exposes its JAR in the default configuration, not in compile; compile->default is the mapping that works. Second, the Commons CSV dependency had to be in the compile configuration for javac to see it, which is why the POM ivy:makepom wrote lists it with compile scope, unlike the Gradle and Resolver publications.

Consuming each repository from the other tools

All three tools can be pointed at build/repo. The consumer below is a small project named tidepool-consumer with one class, io.tidepool.consumer.Report, that reads data/stations.csv through tidepool-engine and prints a predicted tide height per station. It exercises the engine (so its own dependencies on the model and on Commons CSV must resolve), and it is identical in all three consumers:

src/main/java/io/tidepool/consumer/Report.java
package io.tidepool.consumer;

import io.tidepool.engine.StationReader;
import io.tidepool.engine.TideEngine;
import io.tidepool.model.Station;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Locale;

public final class Report {
    public static void main(String[] args) throws IOException {
        try (var reader = Files.newBufferedReader(Path.of(args[0]))) {
            for (Station s : StationReader.read(reader)) {
                System.out.printf(Locale.ROOT, "%s: %.3f m%n", s.name(), TideEngine.predict(s, Instant.parse("2026-01-01T00:00:00Z")).metres());
            }
        }
    }
}

Report is this page’s own lab code, written for these runs rather than taken from any tool’s documentation.

Maven consumer

Maven reads a repository declared in <repositories> of the POM (or in a profile of settings.xml). The URL is a property so the same POM can be pointed at each tool’s output from the command line. checksumPolicy is warn by default; here it is set to fail, so a corrupt file stops the build instead of printing a warning, and snapshots are disabled for the repository because it holds releases only. The Exec Maven Plugin runs the program.

pom.xml (consumer)
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>io.tidepool.demo</groupId>
  <artifactId>tidepool-consumer</artifactId>
  <version>1.0.0</version>

  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <!-- Overridden on the command line: -Dtidepool.repo=file:///path/to/build/repo -->
    <tidepool.repo>file://${project.basedir}/../maven/build/repo</tidepool.repo>
  </properties>

  <repositories>
    <repository>
      <id>tidepool</id>
      <url>${tidepool.repo}</url>
      <releases>
        <checksumPolicy>fail</checksumPolicy>
      </releases>
      <snapshots>
        <enabled>false</enabled>
      </snapshots>
    </repository>
  </repositories>

  <dependencies>
    <dependency>
      <groupId>io.tidepool</groupId>
      <artifactId>tidepool-engine</artifactId>
      <version>1.0.0</version>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.16.0</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-resources-plugin</artifactId>
        <version>3.5.0</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.5.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-dependency-plugin</artifactId>
        <version>3.11.0</version>
      </plugin>
      <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>exec-maven-plugin</artifactId>
        <version>3.6.4</version>
        <configuration>
          <mainClass>io.tidepool.consumer.Report</mainClass>
          <arguments>
            <argument>data/stations.csv</argument>
          </arguments>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>
mvn -Dmaven.repo.local=consumer-m2 --batch-mode -Dtidepool.repo=file:///…/gradle/build/repo compile exec:java dependency:tree
San Francisco: -0.869 m
Boston: -0.662 m
Honolulu: -0.112 m
[INFO] io.tidepool.demo:tidepool-consumer:jar:1.0.0
[INFO] \- io.tidepool:tidepool-engine:jar:1.0.0:compile
[INFO]    +- io.tidepool:tidepool-model:jar:1.0.0:compile
[INFO]    \- org.apache.commons:commons-csv:jar:1.14.1:runtime
[INFO]       +- commons-io:commons-io:jar:2.20.0:runtime
[INFO]       \- commons-codec:commons-codec:jar:1.19.0:runtime

Pointing the same command at the Maven, Ant-with-Resolver and Ant-with-Ivy repositories printed the same three station lines. Only the dependency tree differs, and it is the scope the publisher chose that differs: from the Maven and Ivy repositories Commons CSV is compile, from the Gradle and Resolver repositories it is runtime (and so is everything it brings in). While the third-party files were requested, the log also showed Maven asking the file repository tidepool for artifacts it does not hold (commons-csv-1.14.1.pom, commons-codec-1.19.0.jar) before falling back to Central: a repository declared in a POM is consulted for every artifact, in declaration order, unless the build filters it. The coordinates of the consumed module also appear in the consumer’s local repository with the origin recorded in _remote.repositories, as shown above.

Two failures worth knowing, produced on purpose. A corrupt checksum with checksumPolicy fail (the .sha1 file of the model JAR in a copy of the repository was replaced with zeros):

[WARNING] Checksum validation failed, expected '0000000000000000000000000000000000000000' (REMOTE_EXTERNAL) but is actually 'e8a8ce283dc8e5344cbff7f85c63a1daf2769546' from tidepool for file://…/bad-repo/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar
[ERROR] BUILD FAILURE
[ERROR] Failed to execute goal on project tidepool-consumer: Could not resolve dependencies for project io.tidepool.demo:tidepool-consumer:jar:1.0.0
[ERROR] dependency: io.tidepool:tidepool-model:jar:1.0.0 (compile)
[ERROR] 	prefixes: Path io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar NOT allowed from central
[ERROR] 	Could not transfer artifact io.tidepool:tidepool-model:jar:1.0.0 from/to tidepool (file://…/bad-repo): Checksum validation failed, expected '0000000000000000000000000000000000000000' (REMOTE_EXTERNAL) but is actually 'e8a8ce283dc8e5344cbff7f85c63a1daf2769546'

and a repository that does not exist:

[ERROR] Failed to execute goal on project tidepool-consumer: Could not resolve dependencies for project io.tidepool.demo:tidepool-consumer:jar:1.0.0
[ERROR] dependency: io.tidepool:tidepool-engine:jar:1.0.0 (compile)
[ERROR] 	Could not find artifact io.tidepool:tidepool-engine:jar:1.0.0 in tidepool (file://…/nope)
[ERROR] 	prefixes: Path io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.jar NOT allowed from central

The second failure leaves tidepool-engine-1.0.0.jar.lastUpdated and tidepool-engine-1.0.0.pom.lastUpdated files in the local repository: Maven remembers the failed lookup and does not retry until the update interval of the repository has passed (daily by default) or you run with -U. Delete the directory, or use -U, after fixing a repository URL.

Gradle consumer

A Gradle build declares repositories in the settings file (the recommended place), so that every project in the build shares them and RepositoriesMode.FAIL_ON_PROJECT_REPOS can forbid a build script from adding more. exclusiveContent declares that the io.tidepool group is found in the tidepool repository and in no other, so Central is never asked about it (using exclusive content filters is a Gradle best practice, and it keeps internal coordinates from leaking to public servers).

settings.gradle.kts
rootProject.name = "tidepool-consumer"

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        exclusiveContent {
            forRepository {
                maven {
                    name = "tidepool"
                    // Overridden with -PtidepoolRepo=/path/to/build/repo
                    url = uri(providers.gradleProperty("tidepoolRepo").getOrElse("../maven/build/repo"))
                    // A plain-HTTP repository is refused unless you opt in; only do this for localhost.
                    isAllowInsecureProtocol = url.scheme == "http"
                }
            }
            filter {
                includeGroup("io.tidepool")
            }
        }
        mavenCentral()
    }
}
settings.gradle
rootProject.name = 'tidepool-consumer'

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        exclusiveContent {
            forRepository {
                maven {
                    name = 'tidepool'
                    // Overridden with -PtidepoolRepo=/path/to/build/repo
                    url = providers.gradleProperty('tidepoolRepo').getOrElse('../maven/build/repo')
                    // A plain-HTTP repository is refused unless you opt in; only do this for localhost.
                    allowInsecureProtocol = url.scheme == 'http'
                }
            }
            filter {
                includeGroup 'io.tidepool'
            }
        }
        mavenCentral()
    }
}
build.gradle.kts
plugins {
    application
}

dependencies {
    implementation("io.tidepool:tidepool-engine:1.0.0")
}

tasks.withType<JavaCompile>().configureEach {
    options.release = 17
}

application {
    mainClass = "io.tidepool.consumer.Report"
}

tasks.named<JavaExec>("run") {
    args("data/stations.csv")
}
build.gradle
plugins {
    id 'application'
}

dependencies {
    implementation 'io.tidepool:tidepool-engine:1.0.0'
}

tasks.withType(JavaCompile).configureEach {
    options.release = 17
}

application {
    mainClass = 'io.tidepool.consumer.Report'
}

tasks.named('run') {
    args 'data/stations.csv'
}
gradle --console=plain -q -PtidepoolRepo=/…/maven/build/repo run dependencies --configuration runtimeClasspath
San Francisco: -0.869 m
Boston: -0.662 m
Honolulu: -0.112 m

------------------------------------------------------------
Root project 'tidepool-consumer'
------------------------------------------------------------
runtimeClasspath - Runtime classpath of source set 'main'.
\--- io.tidepool:tidepool-engine:1.0.0
     +--- io.tidepool:tidepool-model:1.0.0
     \--- org.apache.commons:commons-csv:1.14.1
          +--- commons-io:commons-io:2.20.0
          \--- commons-codec:commons-codec:1.19.0

The Gradle and Groovy builds printed the same lines, and the consumer also worked against the Gradle-, Resolver- and Ivy-published repositories. Reading a Maven-published module there is no .module file, so Gradle builds the module’s variants from the POM. In this run Gradle did not complain about the corrupt .sha1 of the bad-repository copy when it read it as a file: repository, which is consistent with the cache bypass described above; checksums of files fetched over HTTP are compared with the server’s, and the stricter checks are dependency verification (the subject of Gradle: Dependency Locking, Verification & Caching).

Ant consumer

The Resolver tasks make Ant a consumer of any Maven-layout repository: list the repositories under the id resolver.repositories (the default is Central only, so declaring it replaces the default), declare the dependency by coordinates, and get a path per scope. A classpath asked for with classpath="compile" holds the compile-time dependencies and one asked for with classpath="runtime" the runtime ones. The <echo> shows the compile classpath, so the effect of the publisher’s scopes is visible:

build.xml (Ant consumer)
<?xml version="1.0" encoding="UTF-8"?>
<project name="tidepool-consumer" default="run" basedir="."
         xmlns:resolver="antlib:org.apache.maven.resolver.ant">

  <!-- Overridden with -Dtidepool.repo=/path/to/build/repo -->
  <property name="tidepool.repo" location="${basedir}/../maven/build/repo"/>
  <property name="local.repo" location="${basedir}/.m2-local"/>

  <taskdef uri="antlib:org.apache.maven.resolver.ant"
           resource="org/apache/maven/resolver/ant/antlib.xml"
           classpath="lib/maven-resolver-ant-tasks-2.0.0-uber.jar"/>

  <resolver:localrepo dir="${local.repo}"/>

  <!-- Declared repositories are searched in this order: the file repository first, then Central. -->
  <resolver:remoterepos id="resolver.repositories">
    <resolver:remoterepo id="tidepool" url="file:${tidepool.repo}" snapshots="false"/>
    <resolver:remoterepo id="central" url="https://repo.maven.apache.org/maven2" snapshots="false"/>
  </resolver:remoterepos>

  <target name="resolve">
    <resolver:resolve>
      <dependencies>
        <dependency coords="io.tidepool:tidepool-engine:1.0.0"/>
      </dependencies>
      <path refid="compile.cp" classpath="compile"/>
      <path refid="runtime.cp" classpath="runtime"/>
    </resolver:resolve>
    <echo>compile classpath: ${toString:compile.cp}</echo>
  </target>

  <target name="run" depends="resolve">
    <mkdir dir="build/classes"/>
    <javac srcdir="src" destdir="build/classes" release="17" includeantruntime="false" classpathref="compile.cp"/>
    <java classname="io.tidepool.consumer.Report" fork="true">
      <classpath>
        <pathelement location="build/classes"/>
        <path refid="runtime.cp"/>
      </classpath>
      <arg value="data/stations.csv"/>
    </java>
  </target>
</project>
ant -Dtidepool.repo=/…/gradle/build/repo
Buildfile: …/build.xml

resolve:
[resolver:resolve] Resolving artifacts
     [echo] compile classpath: …/.m2-local/io/tidepool/tidepool-engine/1.0.0/tidepool-engine-1.0.0.jar:…/.m2-local/io/tidepool/tidepool-model/1.0.0/tidepool-model-1.0.0.jar

run:
    [mkdir] Created dir: …/build/classes
    [javac] Compiling 1 source file to …/build/classes
     [java] San Francisco: -0.869 m
     [java] Boston: -0.662 m
     [java] Honolulu: -0.112 m
BUILD SUCCESSFUL

With the Ant-published repository the compile classpath is the same two JARs, because that POM also puts Commons CSV in runtime scope; from the Maven-published repository it has five JARs (the engine, the model, Commons CSV, Commons IO and Commons Codec), because there Commons CSV is a compile dependency. Which is "right" is a decision the publisher made when writing the POM; the consumer cannot override it except with an explicit dependency or an exclusion. The Ant consumer also resolved the repository published by Ivy.

Who can read whom

Every cell of this matrix was run and printed the three station lines:

Consumer \ repository published by Maven Gradle Ant (Resolver) Ant (Ivy)

Maven (<repositories>)

yes

yes

yes

yes

Gradle (maven { url })

yes

yes

yes

yes

Ant (Resolver tasks)

yes

yes

yes

yes

Ivy as a consumer was exercised only against its own repository: the engine build above resolved the just-published model through an ibiblio resolver reading build/repo. Resolving from the other three repositories with Ivy was not run here.

Common mistakes

  • Overwriting a release. A release version is a promise: Maven Central does not modify or remove published components, and a hosted release repository is normally set to refuse redeployment. If a published build was wrong, publish the next version. Snapshots exist for the opposite case.

  • Releasing a snapshot dependency. A release whose POM depends on a -SNAPSHOT can no longer be reproduced, and Central rejects it for the same reason it rejects a snapshot version.

  • Forgetting the files Central wants. A build that passes locally can fail validation because sources, Javadoc, one signature, a scm section or a licence is missing; check the bundle’s file list (above) against the Central requirements before the upload.

  • Unpinned plugin versions. Maven warns 'build.plugins.plugin.version' for … is missing; for publishing it matters because a plugin that changes how it writes metadata or checksums changes what you ship.

  • Assuming ~/.m2 is shared. It is shared by Maven, Ant with the Resolver tasks (when localrepo is not set) and, with mavenLocal(), by Gradle — but it is never Gradle’s cache and never Ivy’s.

  • Deploying with the Maven Deploy Plugin to Central. That is the OSSRH protocol, shut down on 2025-06-30. Use the central-publishing-maven-plugin, a bundle upload, or a Gradle plugin that targets the Portal.

  • Putting secrets in the build file. Passphrases, tokens and keys belong to the environment, a CI secret store or settings.xml / ~/.gradle/gradle.properties outside the project — all three tools can read them from there, and none of the files on this page contains one.

Where the books are outdated

Of the three reference books behind this section, none helps with modern publishing. The Ant book (written for Ant 1.5) has no dependency management and no repositories at all: for Ant the answer today is Ivy or the Resolver Ant Tasks above. The Maven 2 book describes Apache Archiva as its repository manager, which was retired in 2024, deploys to the Maven Central of its time by way of OSSRH (shut down on 2025-06-30), and shows plain HTTP repositories, which Maven has blocked by default since 3.8.1. The Gradle book (written for Gradle 1.7) publishes with the maven plugin and the uploadArchives task and resolves from JCenter and Bintray: the plugin and the task were removed in Gradle 7.0, maven-publish is stable and standard, and JCenter and Bintray no longer exist. Every example on this page uses the current form.