[2.x] fix: Substitute [organisation] literally by defaulting Patterns.isMavenCompatible to false (#9344)

**Problem**

In an Ivy resolver pattern, the [organisation]/[organization] token has its dots rewritten to slashes (org.example → org/example), behaving like Ivy's [orgPath] token rather than being literal. Per the [Apache Ivy spec](http://ant.apache.org/ivy/history/latest-milestone/concept.html), [organisation] should be substituted literally and [orgPath] is the slash-separated form.

Root cause: Patterns.isMavenCompatible defaulted to true, which sbt forwards to Apache Ivy as setM2compatible(true); with m2-compatibility on, Ivy rewrites the [organisation] token to slash form. A user supplying a custom Ivy pattern (e.g. for an SFTP/SSH resolver) inherited that default and got the wrong paths, with no obvious indication why. The only workaround was the non-obvious withIsMavenCompatible(false).

**Solution**

Flip the default of Patterns.isMavenCompatible from true to false, so a hand-written Patterns keeps [organisation] literal by default — matching the Ivy spec.
This commit is contained in:
Jozef Koval 2026-06-16 05:54:28 +02:00 committed by GitHub
parent c33b795c78
commit 73d164fa84
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 225 additions and 7 deletions

View File

@ -4,6 +4,10 @@
// DO NOT EDIT MANUALLY
package sbt.librarymanagement
/** @param isMavenCompatible When false (the default), the [organisation]/[organization] token is substituted literally,
so an organization such as `org.example` stays `org.example` in resolved paths, matching the
Apache Ivy specification. When true, the Ivy engine rewrites the [organisation] token to the
slash-separated form (`org/example`), as in a Maven m2-compatible repository layout. */
final class Patterns private (
val ivyPatterns: Vector[String],
val artifactPatterns: Vector[String],
@ -11,7 +15,7 @@ final class Patterns private (
val descriptorOptional: Boolean,
val skipConsistencyCheck: Boolean) extends Serializable {
private def this() = this(Vector.empty, Vector.empty, true, false, false)
private def this() = this(Vector.empty, Vector.empty, false, false, false)
override def equals(o: Any): Boolean = this.eq(o.asInstanceOf[AnyRef]) || (o match {
case x: Patterns => (this.ivyPatterns == x.ivyPatterns) && (this.artifactPatterns == x.artifactPatterns) && (this.isMavenCompatible == x.isMavenCompatible) && (this.descriptorOptional == x.descriptorOptional) && (this.skipConsistencyCheck == x.skipConsistencyCheck)

View File

@ -532,7 +532,18 @@
"fields": [
{ "name": "ivyPatterns", "type": "String*", "default": "Vector.empty", "since": "0.0.1" },
{ "name": "artifactPatterns", "type": "String*", "default": "Vector.empty", "since": "0.0.1" },
{ "name": "isMavenCompatible", "type": "boolean", "default": "true", "since": "0.0.1" },
{
"name": "isMavenCompatible",
"doc": [
"When false (the default), the [organisation]/[organization] token is substituted literally,",
"so an organization such as `org.example` stays `org.example` in resolved paths, matching the",
"Apache Ivy specification. When true, the Ivy engine rewrites the [organisation] token to the",
"slash-separated form (`org/example`), as in a Maven m2-compatible repository layout."
],
"type": "boolean",
"default": "false",
"since": "0.0.1"
},
{ "name": "descriptorOptional", "type": "boolean", "default": "false", "since": "0.0.1" },
{ "name": "skipConsistencyCheck", "type": "boolean", "default": "false", "since": "0.0.1" }
],

View File

@ -36,7 +36,7 @@ private[librarymanagement] abstract class MavenRepositoryFunctions {
private[librarymanagement] abstract class PatternsFunctions {
implicit def defaultPatterns: Patterns = Resolver.defaultPatterns
def apply(artifactPatterns: String*): Patterns = Patterns(true, artifactPatterns*)
def apply(artifactPatterns: String*): Patterns = Patterns(false, artifactPatterns*)
def apply(isMavenCompatible: Boolean, artifactPatterns: String*): Patterns = {
val patterns = artifactPatterns.toVector
Patterns()
@ -293,13 +293,27 @@ private[librarymanagement] abstract class ResolverFunctions {
construct(name, SshConnection(None, hostname, port), resolvePatterns(basePath, basePatterns))
}
/** A factory to construct an interface to an Ivy SSH resolver. */
/**
* A factory to construct an interface to an Ivy SSH resolver.
*
* The implicit `basePatterns` default to [[mavenStylePatterns]] (`isMavenCompatible = true`), under
* which the `[organisation]` token is rendered in slash-separated form. To keep the organization
* literal (e.g. `org.example`), supply Ivy-style patterns such as a [[Patterns]] built with
* `isMavenCompatible = false` (the [[Patterns]] default). See issue #535.
*/
object ssh extends Define[SshRepository] {
protected def construct(name: String, connection: SshConnection, patterns: Patterns) =
SshRepository(name, connection, patterns, None)
}
/** A factory to construct an interface to an Ivy SFTP resolver. */
/**
* A factory to construct an interface to an Ivy SFTP resolver.
*
* The implicit `basePatterns` default to [[mavenStylePatterns]] (`isMavenCompatible = true`), under
* which the `[organisation]` token is rendered in slash-separated form. To keep the organization
* literal (e.g. `org.example`), supply Ivy-style patterns such as a [[Patterns]] built with
* `isMavenCompatible = false` (the [[Patterns]] default). See issue #535.
*/
object sftp extends Define[SftpRepository] {
protected def construct(name: String, connection: SshConnection, patterns: Patterns) =
SftpRepository(name, connection, patterns)
@ -369,7 +383,18 @@ private[librarymanagement] abstract class ResolverFunctions {
else normBase + "/" + pattern
}
def defaultFileConfiguration = FileConfiguration(true, None)
def mavenStylePatterns = Patterns().withArtifactPatterns(Vector(mavenStyleBasePattern))
/**
* Maven-compatible layout (`isMavenCompatible = true`). The `[organisation]`/`[organization]` token
* is rendered in slash-separated form (`org.example` becomes `org/example`) by the Ivy engine.
*/
def mavenStylePatterns =
Patterns().withArtifactPatterns(Vector(mavenStyleBasePattern)).withIsMavenCompatible(true)
/**
* Ivy-style layout (`isMavenCompatible = false`). The `[organisation]`/`[organization]` token is
* substituted literally, keeping the dots (`org.example` stays `org.example`).
*/
def ivyStylePatterns = defaultIvyPatterns // Patterns(Nil, Nil, false)
def defaultPatterns = mavenStylePatterns

View File

@ -4,7 +4,7 @@ import java.net.URI
import sbt.internal.librarymanagement.UnitSpec
object ResolverTest extends UnitSpec {
class ResolverTest extends UnitSpec {
"Resolver url" should "propagate pattern descriptorOptional and skipConsistencyCheck." in {
val pats = Vector("[orgPath]")
@ -27,4 +27,60 @@ object ResolverTest extends UnitSpec {
assert(patterns.skipConsistencyCheck)
assert(patterns.descriptorOptional)
}
"Patterns" should "default to isMavenCompatible = false (literal [organisation], see #535)." in {
Patterns().isMavenCompatible shouldBe false
}
it should "default the varargs shorthand to isMavenCompatible = false, like the builder forms." in {
// Patterns("[organisation]/...") must agree with Patterns().withArtifactPatterns(...) so that the
// organization token is treated consistently regardless of how the Patterns is constructed (#535).
Patterns("[organisation]/[module]/[artifact]-[revision].[ext]").isMavenCompatible shouldBe false
}
"Resolver.mavenStylePatterns" should "stay Maven-compatible (isMavenCompatible = true)." in {
Resolver.mavenStylePatterns.isMavenCompatible shouldBe true
}
"Resolver.ivyStylePatterns" should "be Ivy-style (isMavenCompatible = false)." in {
Resolver.ivyStylePatterns.isMavenCompatible shouldBe false
}
"Resolver.sftp" should "keep an Ivy SFTP resolver's custom [organisation] patterns literal by default." in {
// Reproduces the configuration from issue #535: a custom Ivy pattern using [organisation].
// With the isMavenCompatible = false default, sbt stores the token verbatim and instructs the
// Ivy engine (via setM2compatible(false)) not to rewrite the organization to slash form.
val ivy = "/var/ivy/repo/[organisation]/[module]/ivys/ivy-[revision].xml"
val artifact = "/var/ivy/repo/[organisation]/[module]/[type]s/[artifact]-[revision].[ext]"
val patterns = Resolver
.sftp("repo", "example.org", 22)(using
Patterns().withIvyPatterns(Vector(ivy)).withArtifactPatterns(Vector(artifact))
)
.patterns
patterns.ivyPatterns shouldBe Vector(ivy)
patterns.artifactPatterns shouldBe Vector(artifact)
patterns.isMavenCompatible shouldBe false
}
it should "stay Maven-compatible when constructed with the default mavenStylePatterns." in {
Resolver.sftp("repo", "example.org", 22).patterns.isMavenCompatible shouldBe true
}
// The historical fluent syntax from issue #535, `Resolver.sftp(...).artifacts(...).ivys(...)`, was
// removed in sbt 2.x; `withPatterns` is the supported replacement. These cases mirror the original
// report's exact patterns (both the `[organization]` and `[organisation]` spellings the reporter
// tried) to lock the behavior down from the client side.
it should "keep the issue #535 patterns literal when set via withPatterns (both org spellings)." in {
val ivys = "/var/ivy/cirque-repo/[organisation]/[module]/ivys/ivy-[revision].xml"
val artifacts =
"/var/ivy/cirque-repo/[organization]/[module]/[type]s/[artifact]-[revision].[ext]"
val repo = Resolver
.sftp("Cirque-ivy-repo", "daisyduck.cirque.dk", 22)
.withPatterns(
Patterns().withIvyPatterns(Vector(ivys)).withArtifactPatterns(Vector(artifacts))
)
repo.patterns.ivyPatterns shouldBe Vector(ivys)
repo.patterns.artifactPatterns shouldBe Vector(artifacts)
repo.patterns.isMavenCompatible shouldBe false
}
}

View File

@ -0,0 +1,60 @@
/*
* sbt
* Copyright 2023, Scala center
* Copyright 2011 - 2022, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt.internal.librarymanagement
import org.apache.ivy.core.settings.IvySettings
import org.apache.ivy.plugins.resolver.AbstractPatternsBasedResolver
import sbt.internal.librarymanagement.ivy.UpdateOptions
import sbt.internal.util.ConsoleLogger
import sbt.librarymanagement.{ Patterns, URLRepository }
import verify.BasicTestSuite
import scala.jdk.CollectionConverters.*
// Regression coverage for sbt/sbt#535: the organization token in an Ivy SFTP/SSH/URL resolver pattern
// should be rendered literally unless the resolver is explicitly Maven-compatible. ConvertResolver is the
// boundary sbt owns: it maps Patterns.isMavenCompatible onto Apache Ivy's setM2compatible, which is what
// makes Ivy keep [organisation] literal (false) or rewrite it to slash form (true).
object ConvertResolverSpec extends BasicTestSuite {
private val log = ConsoleLogger()
// The configuration from issue #535: a custom Ivy pattern using the [organisation] token.
private val orgPatterns =
Patterns()
.withIvyPatterns(
Vector("https://example.org/repo/[organisation]/[module]/ivys/ivy-[revision].xml")
)
.withArtifactPatterns(
Vector(
"https://example.org/repo/[organisation]/[module]/[type]s/[artifact]-[revision].[ext]"
)
)
private def convert(patterns: Patterns): AbstractPatternsBasedResolver =
ConvertResolver(URLRepository("test-repo", patterns), new IvySettings, UpdateOptions(), log)
.asInstanceOf[AbstractPatternsBasedResolver]
test("the default Patterns keeps the Ivy resolver non-m2compatible (issue #535)") {
assert(!orgPatterns.isMavenCompatible)
assert(!convert(orgPatterns).isM2compatible)
}
test("isMavenCompatible = false renders the [organisation] token literally") {
val resolver = convert(orgPatterns)
assert(!resolver.isM2compatible)
// sbt forwards the pattern verbatim; with m2compatible off Ivy does not rewrite the organization.
assert(resolver.getArtifactPatterns.asScala.exists(_.toString.contains("[organisation]")))
}
test(
"isMavenCompatible = true makes the Ivy resolver m2compatible (organization rewritten to slash form)"
) {
assert(convert(orgPatterns.withIsMavenCompatible(true)).isM2compatible)
}
}

View File

@ -0,0 +1,36 @@
## `Patterns.isMavenCompatible` now defaults to `false`
The default value of `Patterns.isMavenCompatible` changed from `true` to `false` so that the
`[organisation]`/`[organization]` token in an Ivy pattern is substituted literally, matching the
Apache Ivy specification (see [#535](https://github.com/sbt/sbt/issues/535)).
### What changed
```scala
// before: organization "org.example" rendered as "org/example"
// now: organization "org.example" rendered as "org.example"
val p = Patterns()
.withIvyPatterns(Vector("[organisation]/[module]/ivys/ivy-[revision].xml"))
.withArtifactPatterns(Vector("[organisation]/[module]/[type]s/[artifact]-[revision].[ext]"))
```
Use the `[orgPath]` token if you want the slash-separated form regardless of the flag, or set
`isMavenCompatible = true` explicitly to opt back into the previous Maven m2-compatible behavior:
```scala
Patterns(/* ... */).withIsMavenCompatible(true)
```
### Who is affected
- Custom Ivy/SFTP/SSH/URL/file resolvers built with a hand-written `Patterns` that uses the
`[organisation]` token and previously relied on the implicit dot-to-slash rewrite.
- Under the Ivy engine, the organization is now rendered literally for these patterns.
- Under Coursier (the default since sbt 1.3), SFTP/SSH resolvers are ignored entirely, so they
are unaffected. Custom **URL/file** pattern resolvers, however, can still be affected: Coursier
chooses between Maven-base extraction and Ivy-pattern parsing based on `isMavenCompatible`, so a
resolver that relied on the old `true` default may now be parsed as an Ivy-pattern repository.
Set `isMavenCompatible = true` explicitly to keep the Maven handling.
- The built-in `Resolver.mavenStylePatterns` is unchanged: it remains explicitly
`isMavenCompatible = true`, so `Resolver.url`/`Resolver.file`/`Resolver.sftp`/`Resolver.ssh`
constructed with the default patterns continue to use the Maven layout.

View File

@ -0,0 +1,16 @@
// Regression guard related to sbt/sbt#535.
//
// Patterns.isMavenCompatible now defaults to false (so a custom Ivy pattern keeps [organisation]
// literal). Resolver.file still uses Resolver.mavenStylePatterns, which is explicitly Maven-compatible,
// so publishing to it must keep producing the standard slash-separated Maven layout.
ThisBuild / scalaVersion := "2.12.21"
lazy val lib = project
.settings(
organization := "org.example",
name := "lib",
version := "1.0",
publishMavenStyle := true,
publishTo := Some(Resolver.file("dist", (ThisBuild / baseDirectory).value / "repo"))
)

View File

@ -0,0 +1,10 @@
# publish under the default (Coursier/ivyless) engine
> lib/publish
# the organization must be laid out slash-separated (Maven layout) via mavenStylePatterns,
# which stays Maven-compatible even though the Patterns default flipped to false
$ exists repo/org/example/lib_2.12/1.0/lib_2.12-1.0.jar
$ exists repo/org/example/lib_2.12/1.0/lib_2.12-1.0.pom
# the literal dotted organization directory (Ivy layout) must NOT be produced
-$ exists repo/org.example/lib_2.12/1.0/lib_2.12-1.0.jar