Skip to content

Commit e62ae12

Browse files
More robust bisect script (#16594)
* Simplify the flow for most common kinds of release/commit validation - compiling and running sources * Allow specifying a range of releases to bisect with `--releases` flag (needed for finding regressions in features introduced after 3.0.0) * Automatically assert correctness of the validation script - it should succeed for the first validated release and fail for the last one * Add dry run mode (`--dry-run` flag) to check the validation script without running the entire bisection * Use `--server=false` in scala-cli by default * this avoids false negatives of validation failing for some compiler versions for which it should succeed but fails because of some zinc/scala-cli related errors * this allows using a non-bootstrapped compiler for testing, which significantly speeds up `publishLocal`; non-boostrapped is now the default, bootstrapped can be turned on with `--bootstrapped` flag
2 parents 483b1e8 + a39d405 commit e62ae12

File tree

3 files changed

+203
-78
lines changed

3 files changed

+203
-78
lines changed

project/scripts/bisect.scala

Lines changed: 201 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -8,58 +8,203 @@ Look at the `usageMessage` below for more details.
88

99
import sys.process._
1010
import scala.io.Source
11-
import Releases.Release
1211
import java.io.File
12+
import java.nio.file.attribute.PosixFilePermissions
13+
import java.nio.charset.StandardCharsets
14+
import java.nio.file.Files
1315

1416
val usageMessage = """
1517
|Usage:
16-
| > scala-cli project/scripts/bisect.scala -- <validation-script>
18+
| > scala-cli project/scripts/bisect.scala -- [<bisect-options>] <validation-command>
1719
|
18-
|The validation script should be executable and accept a single parameter, which will be the scala version to validate.
20+
|The <validation-command> should be one of:
21+
|* compile <arg1> <arg2> ...
22+
|* run <arg1> <arg2> ...
23+
|* <custom-validation-script-path>
24+
|
25+
|The arguments for 'compile' and 'run' should be paths to the source file(s) and optionally additional options passed directly to scala-cli.
26+
|
27+
|A custom validation script should be executable and accept a single parameter, which will be the scala version to validate.
1928
|Look at bisect-cli-example.sh and bisect-expect-example.exp for reference.
20-
|Don't use the example scripts modified in place as they might disappear from the repo during a checkout.
21-
|Instead copy them to a different location first.
29+
|If you want to use one of the example scripts - use a copy of the file instead of modifying it in place because that might mess up the checkout.
2230
|
23-
|Warning: The bisect script should not be run multiple times in parallel because of a potential race condition while publishing artifacts locally.
31+
|The optional <bisect-options> may be any combination of:
32+
|* --dry-run
33+
| Don't try to bisect - just make sure the validation command works correctly
34+
|* --releases <releases-range>
35+
| Bisect only releases from the given range (defaults to all releases).
36+
| The range format is <first>...<last>, where both <first> and <last> are optional, e.g.
37+
| * 3.1.0-RC1-bin-20210827-427d313-NIGHTLY..3.2.1-RC1-bin-20220716-bb9c8ff-NIGHTLY
38+
| * 3.2.1-RC1-bin-20220620-de3a82c-NIGHTLY..
39+
| * ..3.3.0-RC1-bin-20221124-e25362d-NIGHTLY
40+
| The ranges are treated as inclusive.
41+
|* --bootstrapped
42+
| Publish locally and test a bootstrapped compiler rather than a nonboostrapped one
2443
|
25-
|Tip: Before running the bisect script run the validation script manually with some published versions of the compiler to make sure it succeeds and fails as expected.
44+
|Warning: The bisect script should not be run multiple times in parallel because of a potential race condition while publishing artifacts locally.
45+
2646
""".stripMargin
2747

28-
@main def dottyCompileBisect(args: String*): Unit =
29-
val validationScriptPath = args match
30-
case Seq(path) =>
31-
(new File(path)).getAbsolutePath.toString
32-
case _ =>
33-
println("Wrong script parameters.")
34-
println()
35-
println(usageMessage)
36-
System.exit(1)
37-
null
38-
39-
val releaseBisect = ReleaseBisect(validationScriptPath)
40-
val bisectedBadRelease = releaseBisect.bisectedBadRelease(Releases.allReleases)
41-
println("\nFinished bisecting releases\n")
42-
43-
bisectedBadRelease match
44-
case Some(firstBadRelease) =>
45-
firstBadRelease.previous match
46-
case Some(lastGoodRelease) =>
47-
println(s"Last good release: $lastGoodRelease")
48-
println(s"First bad release: $firstBadRelease")
49-
val commitBisect = CommitBisect(validationScriptPath)
50-
commitBisect.bisect(lastGoodRelease.hash, firstBadRelease.hash)
51-
case None =>
52-
println(s"No good release found")
53-
case None =>
54-
println(s"No bad release found")
55-
56-
class ReleaseBisect(validationScriptPath: String):
57-
def bisectedBadRelease(releases: Vector[Release]): Option[Release] =
58-
Some(bisect(releases: Vector[Release]))
59-
.filter(!isGoodRelease(_))
60-
61-
def bisect(releases: Vector[Release]): Release =
62-
assert(releases.length > 1, "Need at least 2 releases to bisect")
48+
@main def run(args: String*): Unit =
49+
val scriptOptions =
50+
try ScriptOptions.fromArgs(args)
51+
catch
52+
case _ =>
53+
sys.error(s"Wrong script parameters.\n${usageMessage}")
54+
55+
val validationScript = scriptOptions.validationCommand.validationScript
56+
val releases = Releases.fromRange(scriptOptions.releasesRange)
57+
val releaseBisect = ReleaseBisect(validationScript, releases)
58+
59+
releaseBisect.verifyEdgeReleases()
60+
61+
if (!scriptOptions.dryRun) then
62+
val (lastGoodRelease, firstBadRelease) = releaseBisect.bisectedGoodAndBadReleases()
63+
println(s"Last good release: ${lastGoodRelease.version}")
64+
println(s"First bad release: ${firstBadRelease.version}")
65+
println("\nFinished bisecting releases\n")
66+
67+
val commitBisect = CommitBisect(validationScript, bootstrapped = scriptOptions.bootstrapped, lastGoodRelease.hash, firstBadRelease.hash)
68+
commitBisect.bisect()
69+
70+
71+
case class ScriptOptions(validationCommand: ValidationCommand, dryRun: Boolean, bootstrapped: Boolean, releasesRange: ReleasesRange)
72+
object ScriptOptions:
73+
def fromArgs(args: Seq[String]) =
74+
val defaultOptions = ScriptOptions(
75+
validationCommand = null,
76+
dryRun = false,
77+
bootstrapped = false,
78+
ReleasesRange(first = None, last = None)
79+
)
80+
parseArgs(args, defaultOptions)
81+
82+
private def parseArgs(args: Seq[String], options: ScriptOptions): ScriptOptions =
83+
args match
84+
case "--dry-run" :: argsRest => parseArgs(argsRest, options.copy(dryRun = true))
85+
case "--bootstrapped" :: argsRest => parseArgs(argsRest, options.copy(bootstrapped = true))
86+
case "--releases" :: argsRest =>
87+
val range = ReleasesRange.tryParse(argsRest.head).get
88+
parseArgs(argsRest.tail, options.copy(releasesRange = range))
89+
case _ =>
90+
val command = ValidationCommand.fromArgs(args)
91+
options.copy(validationCommand = command)
92+
93+
enum ValidationCommand:
94+
case Compile(args: Seq[String])
95+
case Run(args: Seq[String])
96+
case CustomValidationScript(scriptFile: File)
97+
98+
def validationScript: File = this match
99+
case Compile(args) =>
100+
ValidationScript.tmpScalaCliScript(command = "compile", args)
101+
case Run(args) =>
102+
ValidationScript.tmpScalaCliScript(command = "run", args)
103+
case CustomValidationScript(scriptFile) =>
104+
ValidationScript.copiedFrom(scriptFile)
105+
106+
object ValidationCommand:
107+
def fromArgs(args: Seq[String]) = args match
108+
case Seq("compile", commandArgs*) => Compile(commandArgs)
109+
case Seq("run", commandArgs*) => Run(commandArgs)
110+
case Seq(path) => CustomValidationScript(new File(path))
111+
112+
113+
object ValidationScript:
114+
def copiedFrom(file: File): File =
115+
val fileContent = scala.io.Source.fromFile(file).mkString
116+
tmpScript(fileContent)
117+
118+
def tmpScalaCliScript(command: String, args: Seq[String]): File = tmpScript(s"""
119+
|#!/usr/bin/env bash
120+
|scala-cli ${command} -S "$$1" --server=false ${args.mkString(" ")}
121+
|""".stripMargin
122+
)
123+
124+
private def tmpScript(content: String): File =
125+
val executableAttr = PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwxr-xr-x"))
126+
val tmpPath = Files.createTempFile("scala-bisect-validator", "", executableAttr)
127+
val tmpFile = tmpPath.toFile
128+
129+
print(s"Bisecting with validation script: ${tmpPath.toAbsolutePath}\n")
130+
print("#####################################\n")
131+
print(s"${content}\n\n")
132+
print("#####################################\n\n")
133+
134+
tmpFile.deleteOnExit()
135+
Files.write(tmpPath, content.getBytes(StandardCharsets.UTF_8))
136+
tmpFile
137+
138+
139+
case class ReleasesRange(first: Option[String], last: Option[String]):
140+
def filter(releases: Seq[Release]) =
141+
def releaseIndex(version: String): Int =
142+
val index = releases.indexWhere(_.version == version)
143+
assert(index > 0, s"${version} matches no nightly compiler release")
144+
index
145+
146+
val startIdx = first.map(releaseIndex(_)).getOrElse(0)
147+
val endIdx = last.map(releaseIndex(_) + 1).getOrElse(releases.length)
148+
val filtered = releases.slice(startIdx, endIdx).toVector
149+
assert(filtered.nonEmpty, "No matching releases")
150+
filtered
151+
152+
object ReleasesRange:
153+
def all = ReleasesRange(None, None)
154+
def tryParse(range: String): Option[ReleasesRange] = range match
155+
case s"${first}...${last}" => Some(ReleasesRange(
156+
Some(first).filter(_.nonEmpty),
157+
Some(last).filter(_.nonEmpty)
158+
))
159+
case _ => None
160+
161+
class Releases(val releases: Vector[Release])
162+
163+
object Releases:
164+
lazy val allReleases: Vector[Release] =
165+
val re = raw"""(?<=title=")(.+-bin-\d{8}-\w{7}-NIGHTLY)(?=/")""".r
166+
val html = Source.fromURL("https://repo1.maven.org/maven2/org/scala-lang/scala3-compiler_3/")
167+
re.findAllIn(html.mkString).map(Release.apply).toVector
168+
169+
def fromRange(range: ReleasesRange): Vector[Release] = range.filter(allReleases)
170+
171+
case class Release(version: String):
172+
private val re = raw".+-bin-(\d{8})-(\w{7})-NIGHTLY".r
173+
def date: String =
174+
version match
175+
case re(date, _) => date
176+
case _ => sys.error(s"Could not extract date from release name: $version")
177+
def hash: String =
178+
version match
179+
case re(_, hash) => hash
180+
case _ => sys.error(s"Could not extract hash from release name: $version")
181+
182+
override def toString: String = version
183+
184+
185+
class ReleaseBisect(validationScript: File, allReleases: Vector[Release]):
186+
assert(allReleases.length > 1, "Need at least 2 releases to bisect")
187+
188+
private val isGoodReleaseCache = collection.mutable.Map.empty[Release, Boolean]
189+
190+
def verifyEdgeReleases(): Unit =
191+
println(s"Verifying the first release: ${allReleases.head.version}")
192+
assert(isGoodRelease(allReleases.head), s"The evaluation script unexpectedly failed for the first checked release")
193+
println(s"Verifying the last release: ${allReleases.last.version}")
194+
assert(!isGoodRelease(allReleases.last), s"The evaluation script unexpectedly succeeded for the last checked release")
195+
196+
def bisectedGoodAndBadReleases(): (Release, Release) =
197+
val firstBadRelease = bisect(allReleases)
198+
assert(!isGoodRelease(firstBadRelease), s"Bisection error: the 'first bad release' ${firstBadRelease.version} is not a bad release")
199+
val lastGoodRelease = firstBadRelease.previous
200+
assert(isGoodRelease(lastGoodRelease), s"Bisection error: the 'last good release' ${lastGoodRelease.version} is not a good release")
201+
(lastGoodRelease, firstBadRelease)
202+
203+
extension (release: Release) private def previous: Release =
204+
val idx = allReleases.indexOf(release)
205+
allReleases(idx - 1)
206+
207+
private def bisect(releases: Vector[Release]): Release =
63208
if releases.length == 2 then
64209
if isGoodRelease(releases.head) then releases.last
65210
else releases.head
@@ -69,44 +214,24 @@ class ReleaseBisect(validationScriptPath: String):
69214
else bisect(releases.take(releases.length / 2 + 1))
70215

71216
private def isGoodRelease(release: Release): Boolean =
72-
println(s"Testing ${release.version}")
73-
val result = Seq(validationScriptPath, release.version).!
74-
val isGood = result == 0
75-
println(s"Test result: ${release.version} is a ${if isGood then "good" else "bad"} release\n")
76-
isGood
77-
78-
object Releases:
79-
lazy val allReleases: Vector[Release] =
80-
val re = raw"(?<=title=$")(.+-bin-\d{8}-\w{7}-NIGHTLY)(?=/$")".r
81-
val html = Source.fromURL("https://repo1.maven.org/maven2/org/scala-lang/scala3-compiler_3/")
82-
re.findAllIn(html.mkString).map(Release.apply).toVector
217+
isGoodReleaseCache.getOrElseUpdate(release, {
218+
println(s"Testing ${release.version}")
219+
val result = Seq(validationScript.getAbsolutePath, release.version).!
220+
val isGood = result == 0
221+
println(s"Test result: ${release.version} is a ${if isGood then "good" else "bad"} release\n")
222+
isGood
223+
})
83224

84-
case class Release(version: String):
85-
private val re = raw".+-bin-(\d{8})-(\w{7})-NIGHTLY".r
86-
def date: String =
87-
version match
88-
case re(date, _) => date
89-
case _ => sys.error(s"Could not extract date from version $version")
90-
def hash: String =
91-
version match
92-
case re(_, hash) => hash
93-
case _ => sys.error(s"Could not extract hash from version $version")
94-
95-
def previous: Option[Release] =
96-
val idx = allReleases.indexOf(this)
97-
if idx == 0 then None
98-
else Some(allReleases(idx - 1))
99-
100-
override def toString: String = version
101-
102-
class CommitBisect(validationScriptPath: String):
103-
def bisect(lastGoodHash: String, fistBadHash: String): Unit =
225+
class CommitBisect(validationScript: File, bootstrapped: Boolean, lastGoodHash: String, fistBadHash: String):
226+
def bisect(): Unit =
104227
println(s"Starting bisecting commits $lastGoodHash..$fistBadHash\n")
228+
val scala3CompilerProject = if bootstrapped then "scala3-compiler-bootstrapped" else "scala3-compiler"
229+
val scala3Project = if bootstrapped then "scala3-bootstrapped" else "scala3"
105230
val bisectRunScript = s"""
106-
|scalaVersion=$$(sbt "print scala3-compiler-bootstrapped/version" | tail -n1)
231+
|scalaVersion=$$(sbt "print ${scala3CompilerProject}/version" | tail -n1)
107232
|rm -r out
108-
|sbt "clean; scala3-bootstrapped/publishLocal"
109-
|$validationScriptPath "$$scalaVersion"
233+
|sbt "clean; ${scala3Project}/publishLocal"
234+
|${validationScript.getAbsolutePath} "$$scalaVersion"
110235
""".stripMargin
111236
"git bisect start".!
112237
s"git bisect bad $fistBadHash".!

project/scripts/examples/bisect-cli-example.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33
# Don't use this example script modified in place as it might disappear from the repo during a checkout.
44
# Instead copy it to a different location first.
55

6-
scala-cli compile -S "$1" file1.scala file2.scala
6+
scala-cli compile -S "$1" --server=false file1.scala file2.scala

project/scripts/examples/bisect-expect-example.exp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
set scalaVersion [lindex $argv 0] ;# Get the script argument
77

88
set timeout 30 ;# Give scala-cli some time to download the compiler
9-
spawn scala-cli repl -S "$scalaVersion" ;# Start the REPL
9+
spawn scala-cli repl -S "$scalaVersion" --server=false ;# Start the REPL
1010
expect "scala>" ;# REPL has started
1111
set timeout 5
1212
send -- "Seq.empty.len\t" ;# Tab pressed to trigger code completion

0 commit comments

Comments
 (0)