sbt, Mill and scala-cli builds resolve pkg:maven artifacts, so they are
build shapes inside the maven ecosystem, not an ecosystem of their own:
ecosystems: [maven] covers them, and every PURL, rollout budget and ledger
stays Maven's.
| Tool | Agent (patch the cache in place) | Hosted | Vendored |
|---|---|---|---|
| sbt 0.13.18 – 1.2 (Ivy) | Ivy cache crawl | socket-patch.sbt |
socket-patch-vendor.sbt + suffixed tree |
| sbt 1.3 – 1.13, 2.0 (Coursier) | Coursier cache crawl + sidecar resync | socket-patch.sbt |
socket-patch-vendor.sbt + suffixed tree |
| sbt < 0.13.18 | Ivy cache crawl (untested) | refused | refused |
| Mill 0.11 – 1.x | Coursier cache crawl | snippet guidance | docs only (deferred) |
| scala-cli, directory build | Coursier cache crawl | snippet guidance | owned files + same-GAV tree (Linux, macOS) |
| scala-cli, single file | Coursier cache crawl | snippet guidance | docs only (-r / COURSIER_REPOSITORIES) |
Agent mode crawls Coursier's per-repository roots and the Ivy cache next
to ~/.m2 for an sbt / Mill / scala-cli project (a Maven or Gradle project
never reads them, so it keeps crawling ~/.m2 alone; --global crawls
them all), unscoped within them (today's Maven semantics); a Coursier
directory holding only a .pom (a version Coursier considered and evicted)
is no copy. It patches every cached copy
of a GAV: one per root (~/.m2, each Coursier cache, each Ivy cache; inside
one Coursier cache the first per-repository root holding it), since the build
loads whichever its resolver picks. Coursier keeps checksum sidecars beside
each file; they are resynced after a patch and after a rollback. Patch keys
are whole files (<a>-<v>.jar).
Hosted mode writes one generated root file, socket-patch.sbt, and edits
no user file. Inside inThisBuild(…) it pins each patched jar and pom by
sha256, downloads them at load into the gitignored .socket/sbt-hosted/maven2,
resolves from there and forces <base>-socket.<hex8> with
dependencyOverrides; a load-time verifier fails the build when what sbt
resolved is not the pinned artifact. On sbt 0.13 / 1.x the installer moves the
socket-patch resolver to the front of every project's externalResolvers, so
Ivy (sbt 1.0 – 1.2) never aborts on an unreachable repository listed before
it and the build resolves the pin offline. Vendored mode writes
socket-patch-vendor.sbt over the committed, suffixed
.socket/vendor/maven2 tree. The two files never pin the same GA.
The generated bytes (three syntax lines: 0.13, 1.x, 2.x) are frozen by the
template probe, which passed on all six sbt versions (0.13.18, 1.2.8, 1.3.13,
1.9.9, 1.13.0, 2.0.9); results in
sbt-template-probe.md, and the golden fixtures pin
the bytes. The suffixed version and the override are both required: a
same-version copy loses to Maven Central, and without the override the plain
release outranks the suffix. A file written by an earlier (unreleased) build
of the template fails the strict parse as modified; there is no migration.
A build-wide override that forces a version some project does not resolve is
a silent downgrade, so hosted and vendored pins are gated on what sbt itself
resolved, read from target/ (the update-cache JSON on sbt 1.0+, the Ivy XML
reports on 0.13–1.2), never by running sbt. A pin is written only when every
declared project left evidence, no build source is newer than any project's
evidence (each project dated by its own newest record: a partial
sbt core/update vouches for core only), no build source declares the GA
newer than the patch's base, and every project that resolves the GA resolves
the patch's base version. No evidence is one run-level warning and a no-op
(exit 0); run sbt update and re-run. A pin already in place is re-checked:
a shadowed override, an artifact resolved from elsewhere, or a build source
now declaring the GA newer than the pin's base (the override would force it
back down; the load-time verifier also fails update on such a declaration)
leaves the patch unconfirmed. A dependency edit since the pin (deps=
digest) is re-verified against evidence resolved since, and the digest then
refreshed; with older evidence the pin is unverifiable until sbt update.
Hosted, vendored and the gates share one digest: every "g" % "a" % "v"
literal of every build source the evidence walk reads. Vendoring over a
hosted pin (takeover, eject) runs the vendored gate before restoring it, so a
pin the gate would stop stays hosted.
Pure models (crates/socket-patch-core/src/formats/sbt/):
build.rs— sbt version and syntax line, build-root tests, declared projects, the dependency digest, build-source findings.evidence.rs— evidence paths and parsers intoJvmResolution.gate.rs—check_new/check_existing.owned_file.rs— the generated files: model, render, strict parse, value validation.
IO and planners:
| Module | Role |
|---|---|
crawlers/jvm_cache.rs |
markers, classify (Ivy / Coursier rules), push_classified |
crawlers/{coursier_cache,ivy_cache}.rs |
cache locations and layouts |
crawlers/sbt_evidence.rs, crawlers/scala_evidence.rs |
evidence IO |
patch/sidecars/coursier.rs |
Coursier sidecar resync |
hosted/sbt_reads.rs |
evidence for the hosted engine (synthetic key <socket-patch:sbt-resolution>) |
patch/redirect/{sbt,scala_guidance}.rs, patch/redirect/upstream/sbt.rs |
hosted rewrite, snippets, restore |
vex/discover/sbt.rs |
VEX attestation of the generated files |
vendor/jvm/{sbt,sbt_gate,scala_cli,coursier_tree,coursier_gate}.rs |
vendored planners and gates |
Fixtures: crates/socket-patch-core/tests/fixtures/sbt/evidence/<ver>/
(real sbt output, 0.13.18 – 2.0.9). Test helpers:
crates/socket-patch-cli/tests/sbt_common/. Docker image:
tests/docker/Dockerfile.sbt.
- Agent mode patches a shared cache: every project on the machine sees the patch. Restart a running sbt server or Metals after an agent apply.
-Dsbt.override.build.repos=truedrops the build's resolvers; the generated file then fails the load (warned at wiring time).- A
build.sbt.lock(sbt-dependency-lock) is refused until it can be rewritten; runsbt dependencyLockWriteafter wiring. - The in-memory hosted engine has no
target/trees, so it never wires sbt. - Agent mode matches whole-file patch keys only (member-keyed jar patches need the shared JVM apply work).
- Vendored sbt VEX attests through the vendor ledger only: the suffixed tree path carries no full uuid, so the generated file is not a VEX reference on its own.
- scala-cli vendoring is refused on Windows (
file://${.}is unverified there).
The hermetic suites (e2e_sbt, e2e_sbt_hosted, e2e_sbt_vendor,
e2e_scala_cli_vendor, e2e_vex_lockfile sbt:: / sbt_vendored::,
redirect_sbt_golden) run in the normal test job. The real-tool suites and
scripts/sbt-compat-matrix.sh, which runs them per tool version and JDK, are
described in sbt compatibility; CI runs them
in .github/workflows/sbt-compatibility.yml (the full matrix) and ci.yml
(agent cells on 1.2.8 and 1.13.0, hosted and vendored on 1.13.0).
A root holding project.scala (or socket-patch's own socket-patch.scala)
and no pom.xml, Gradle or Mill build file is a scala-cli directory build
(vendor/jvm/scala_cli.rs). Vendoring writes only files socket-patch owns
and never edits a user file:
| File | Bytes |
|---|---|
socket-patch.scala |
// managed by socket-patch + //> using file .socket/vendor/coursier/socket-patch.scala |
.socket/vendor/coursier/socket-patch.scala (the guard) |
// managed by socket-patch + //> using repository file://${.} |
.socket/vendor/coursier/<g/path>/<a>/<v>/ |
patched <a>-<v>.jar, upstream <a>-<v>.pom, both .sha1, socket-patch.vendor.json |
.socket/vendor/coursier-index.tsv |
#socket-patch-coursier-index 1, then g:a:v⇥rel⇥sha256⇥uuid per jar and pom, sorted |
.socket/vendor/coursier/.gitignore, .gitattributes |
!* (a user *.jar rule cannot drop the jar from a clone), * -text |
The tree keeps the upstream GAV: scala-cli has no override directive, and
the directive repository is consulted before the defaults, so the copy wins
by being found first. The guard lives inside the tree, so deleting the tree
deletes the guard and the build fails (File not found) instead of
resolving upstream. The index is the liveness proof; the last revert
deletes the index, both .scala files and the tree's git files. A
modified owned file is refused (vendor_scala_cli_owned_file_modified), a
same-GAV marker the index does not list is vendor_coursier_tree_conflict.
The gate (vendor/jvm/coursier_gate.rs) reads scala-cli's own resolution,
the Bloop project files under .scala-build/.bloop/
(crawlers/scala_evidence.rs), never by running scala-cli: the newest
project of this workspace and its -test twin, their resolution.modules
(artifact paths, classifiers) and sources. No evidence (--server=false
records none), a source newer than the evidence (anywhere in the
directory input, hidden directories aside; evidence that does not list
project.scala is a single-file run's and stale too), or a GA the build
does not resolve skip the patch with one warning and exit 0. The evidence's
time is the newest of the project file and its .bloop/<name>/ entries:
scala-cli rewrites the project file only when its content changes, while
every compile refreshes bloop-internal-classes. Another resolved
version, a classified artifact, the Scala runtime, a repository declared by
any input (every listed source, inside the root or not, plus the directory's
own .scala/.sc/.java files, since a .sc script is listed only as its
generated wrapper), or a post-wiring build that resolved the GA outside the tree, a
project path with % or a non-ASCII character, and Windows are refused.
Scripts and raw output: the campaign scratchpad probe-scala-cli-L5/
(p1.sh–p3.sh, out-p*.txt); the patched GAV was
com.typesafe:config:1.4.3 with an added SOCKET_PATCHED resource.
| # | Case | Result |
|---|---|---|
| G1 | guard inside the tree, file://${.} |
patched; ${.} is the guard's directory |
| G2 | .socket/ or the tree deleted |
[error] File not found: …/socket-patch.scala, exit 1 |
| G6 | jar deleted, pom kept | Error fetching artifacts … not found, exit 1 |
| G6b | whole <g> directory deleted, guard kept |
silent fallback to Central (residual; vendor --check reports it) |
| M3 | .gitignore *.jar, fresh clone |
patched with the tree's !* .gitignore; without it the jar is missing and the build fails |
| K1/K2 | wrong .sha1 / no .sha1 |
wrong checksum, exit 1 / patched: Coursier verifies sidecars on file: repositories |
| C1/C2 | COURSIER_REPOSITORIES=central / a Central mirror URL |
patched: env repositories come after directive repositories |
| C4/O1–O6 | a user //> using repository in any input (before or after ours, project.scala, a subdirectory), or -r on the command line |
not patched, exit 0: the user repository is consulted first. Refused at vendor time; -r is undetectable and caught only by the post-wiring evidence |
| F1/F2 | run from /; package --assembly |
patched |
| F3 | single-file scala-cli run main.scala |
not patched (sibling files ignored): guidance only |
| H1/U4/U5 | path with a space, []+, '& |
patched |
| U3 | path with a literal %20 |
not patched, exit 0 (the URL is percent-decoded): refused |
| U2 | non-ASCII path | IllegalArgumentException: Bad escape, the whole build fails: refused |
| E1–E5 | evidence | .scala-build/.bloop/<dir>_<hash>[-<hash>].json (Bloop 1.4.0) only when Bloop compiles; old input sets' files stay; -test twin for test sources; after vendoring the GA's artifact path is inside the tree |
| E6 (review) | edit a source, scala-cli compile . |
project file mtime unchanged; .bloop/<name>/bloop-internal-classes refreshed (also on a touch-only compile); compile --test refreshes the -test twin, a plain compile does not |
| E7 (review) | subdirectory, hidden directory, .sc sources |
src/deep/a.scala listed; .hid/h.scala not; s.sc listed only as .scala-build/<proj>/src_generated/main/s.scala |
| D1 | project.scala present |
[warn] Using directives detected in multiple files (warning vendor_scala_cli_directives_split) |
| — | Windows file://${.} |
not probed: refused (vendor_scala_cli_windows_unsupported) |
Real-tool check: crates/socket-patch-cli/tests/e2e_scala_cli_vendor.rs
(scala_cli_vendor_*, #[ignore]).
Mill builds get agent mode (the Coursier cache crawl) and hosted guidance
(redirect_mill_manual_snippet). socket-patch vendor does not wire a Mill
build: a root with build.mill, build.mill.yaml or build.sc is never a
scala-cli build, and with no pom.xml or Gradle file it is refused
vendor_jvm_shape_unsupported (reason no_build_file). Automatic vendoring is deferred because the
only committable mechanism the probe found has costs socket-patch should not
impose silently:
- it appends to the user-owned
.mill-jvm-opts(or.config/mill-jvm-opts; Mill 1.x reads only the first that exists); -Dcoursier.repositories=…|ivy2Local|centralreplaces the repository list, dropping corporate mirrors andCOURSIER_REPOSITORIES;- Mill 0.12 keeps a stale
out/after a revert (./mill cleanneeded) and a fresh client start with.socket/missing hangs rather than failing; - Mill 0.11 does not expand
${PWD}in.mill-jvm-opts; - a build overriding
repositorieswithoutsuperignores the property.
To pin a patched artifact in a Mill build today, use hosted mode and apply
the printed snippet (Mill 1.x: def repositories = Task { Seq("<index_url>") ++ super.repositories() } with def depManagement = Task { super.depManagement() ++ Seq(mvn"<g>:<a>:<sv>") }, both appending to what
the module already has;
0.11/0.12: repositoriesTask plus .forceVersion()), or agent mode. The
probe's fail-closed recipe for 0.12 and 1.x, for users who maintain a
same-GAV tree themselves, is two lines at the top of .mill-jvm-opts:
-Dcoursier.repositories=file://${PWD}/.socket/vendor/maven|ivy2Local|central
-XX:VMOptionsFile=${PWD}/.socket/vendor/mill.vmoptions
with .socket/vendor/mill.vmoptions holding one valid option (for example
-Dsocket.patch.vendor.guard=true; a comment-only file stops the JVM). A
daemon started before .socket/ was removed keeps resolving silently: run
./mill shutdown after any change.
scala-cli single-file runs ignore sibling files, so the directory
wiring does not reach them. After vendoring the directory, pass the tree
explicitly: scala-cli run main.scala -r file://$PWD/.socket/vendor/coursier,
or COURSIER_REPOSITORIES="file://$PWD/.socket/vendor/coursier|central"
(which replaces the default list).