Skip to content

Latest commit

 

History

History
244 lines (210 loc) · 14.9 KB

File metadata and controls

244 lines (210 loc) · 14.9 KB

sbt, Mill and scala-cli support

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.

Modes

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.

Scoping: sbt's own evidence

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.

Code map

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 into JvmResolution.
  • 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.

Known limits

  • 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=true drops 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; run sbt dependencyLockWrite after 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).

Testing

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).

scala-cli vendored

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.

Step-0 probe (scala-cli 1.17.1, eclipse-temurin 17, 2026-10-02)

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 (vendored mode deferred)

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|central replaces the repository list, dropping corporate mirrors and COURSIER_REPOSITORIES;
  • Mill 0.12 keeps a stale out/ after a revert (./mill clean needed) 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 repositories without super ignores 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).