@@ -8,98 +8,203 @@ Look at the `usageMessage` below for more details.
8
8
9
9
import sys .process ._
10
10
import scala .io .Source
11
- import Releases .Release
12
11
import java .io .File
12
+ import java .nio .file .attribute .PosixFilePermissions
13
+ import java .nio .charset .StandardCharsets
14
+ import java .nio .file .Files
13
15
14
16
val usageMessage = """
15
17
|Usage:
16
- | > scala-cli project/scripts/bisect.scala -- <validation-script-path> [versions-range]
18
+ | > scala-cli project/scripts/bisect.scala -- [<bisect-options>] <validation-command>
17
19
|
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.
19
28
|Look at bisect-cli-example.sh and bisect-expect-example.exp for reference.
20
- |The optional versions range specifies which releases should be taken into account while bisecting.
21
- |The range format is <first>...<last>, where both <first> and <last> are optional, e.g.
22
- |* 3.1.0-RC1-bin-20210827-427d313-NIGHTLY..3.2.1-RC1-bin-20220716-bb9c8ff-NIGHTLY
23
- |* 3.2.1-RC1-bin-20220620-de3a82c-NIGHTLY..
24
- |* ..3.3.0-RC1-bin-20221124-e25362d-NIGHTLY
25
- |The ranges are treated as inclusive.
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.
26
30
|
27
- |Don't use the example scripts modified in place as they might disappear from the repo during a checkout.
28
- |Instead copy them to a different location first.
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
29
43
|
30
44
|Warning: The bisect script should not be run multiple times in parallel because of a potential race condition while publishing artifacts locally.
31
- |
32
- |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.
45
+
33
46
""" .stripMargin
34
47
35
- @ main def dottyCompileBisect (args : String * ): Unit =
36
- val (validationScriptRawPath, versionsRange) = args match
37
- case Seq (path) =>
38
- (path, VersionsRange .all)
39
- case Seq (path, ParsedVersionsRange (range)) =>
40
- (path, range)
41
- case _ =>
42
- println(" Wrong script parameters." )
43
- println()
44
- println(usageMessage)
45
- System .exit(1 )
46
- null
47
-
48
- val validationScriptPath = (new File (validationScriptRawPath)).getAbsolutePath.toString
49
- given releases : Releases = Releases .fromRange(versionsRange)
50
-
51
- val releaseBisect = ReleaseBisect (validationScriptPath)
52
- val bisectedBadRelease = releaseBisect.bisectedBadRelease(releases.releases)
53
- println(" \n Finished bisecting releases\n " )
54
-
55
- bisectedBadRelease match
56
- case Some (firstBadRelease) =>
57
- firstBadRelease.previous match
58
- case Some (lastGoodRelease) =>
59
- println(s " Last good release: $lastGoodRelease" )
60
- println(s " First bad release: $firstBadRelease" )
61
- val commitBisect = CommitBisect (validationScriptPath)
62
- commitBisect.bisect(lastGoodRelease.hash, firstBadRelease.hash)
63
- case None =>
64
- println(s " No good release found " )
65
- case None =>
66
- println(s " No bad release found " )
67
-
68
- case class VersionsRange (first : Option [String ], last : Option [String ]):
69
- def filter (versions : Seq [String ]) =
70
- def versionIndex (version : String ) =
71
- val lastMatchingNightly =
72
- if version.contains(" -bin-" ) then version else
73
- versions.filter(_.startsWith(version)).last
74
- versions.indexOf(lastMatchingNightly)
75
-
76
- val startIdx = first.map(versionIndex(_)).getOrElse(0 )
77
- assert(startIdx >= 0 , s " ${first} is not a nightly compiler release " )
78
- val endIdx = last.map(versionIndex(_) + 1 ).getOrElse(versions.length)
79
- assert(endIdx > 0 , s " ${endIdx} is not a nightly compiler release " )
80
- val filtered = versions.slice(startIdx, endIdx).toVector
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(" \n Finished 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
81
149
assert(filtered.nonEmpty, " No matching releases" )
82
150
filtered
83
151
84
-
85
- object VersionsRange :
86
- def all = VersionsRange (None , None )
87
-
88
- object ParsedVersionsRange :
89
- def unapply (range : String ): Option [VersionsRange ] = range match
90
- case s " ${first}... ${last}" => Some (VersionsRange (
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 (
91
156
Some (first).filter(_.nonEmpty),
92
157
Some (last).filter(_.nonEmpty)
93
158
))
94
159
case _ => None
95
160
96
- class ReleaseBisect (validationScriptPath : String ):
97
- def bisectedBadRelease (releases : Vector [Release ]): Option [Release ] =
98
- Some (bisect(releases))
99
- .filter(! isGoodRelease(_))
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" )
100
181
101
- def bisect (releases : Vector [Release ]): Release =
102
- assert(releases.length > 1 , " Need at least 2 releases to bisect" )
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 =
103
208
if releases.length == 2 then
104
209
if isGoodRelease(releases.head) then releases.last
105
210
else releases.head
@@ -109,48 +214,24 @@ class ReleaseBisect(validationScriptPath: String):
109
214
else bisect(releases.take(releases.length / 2 + 1 ))
110
215
111
216
private def isGoodRelease (release : Release ): Boolean =
112
- println(s " Testing ${release.version}" )
113
- val result = Seq (validationScriptPath, release.version).!
114
- val isGood = result == 0
115
- println(s " Test result: ${release.version} is a ${if isGood then " good" else " bad" } release \n " )
116
- isGood
117
-
118
- class Releases (val releases : Vector [Release ])
119
-
120
- object Releases :
121
- private lazy val allReleases : Vector [String ] =
122
- val re = raw """ (?<=title=")(.+-bin-\d{8}-\w{7}-NIGHTLY)(?=/") """ .r
123
- val html = Source .fromURL(" https://repo1.maven.org/maven2/org/scala-lang/scala3-compiler_3/" )
124
- re.findAllIn(html.mkString).toVector
125
-
126
- def fromRange (range : VersionsRange ): Releases = Releases (range.filter(allReleases).map(Release (_)))
127
-
128
- case class Release (version : String ):
129
- private val re = raw " .+-bin-(\d{8})-(\w{7})-NIGHTLY " .r
130
- def date : String =
131
- version match
132
- case re(date, _) => date
133
- case _ => sys.error(s " Could not extract date from version $version" )
134
- def hash : String =
135
- version match
136
- case re(_, hash) => hash
137
- case _ => sys.error(s " Could not extract hash from version $version" )
138
-
139
- def previous (using r : Releases ): Option [Release ] =
140
- val idx = r.releases.indexOf(this )
141
- if idx == 0 then None
142
- else Some (r.releases(idx - 1 ))
143
-
144
- override def toString : String = version
145
-
146
- class CommitBisect (validationScriptPath : String ):
147
- def bisect (lastGoodHash : String , fistBadHash : String ): Unit =
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
+ })
224
+
225
+ class CommitBisect (validationScript : File , bootstrapped : Boolean , lastGoodHash : String , fistBadHash : String ):
226
+ def bisect (): Unit =
148
227
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"
149
230
val bisectRunScript = s """
150
- |scalaVersion= $$ (sbt "print scala3-compiler-bootstrapped /version" | tail -n1)
231
+ |scalaVersion= $$ (sbt "print ${scala3CompilerProject} /version" | tail -n1)
151
232
|rm -r out
152
- |sbt "clean; scala3-bootstrapped /publishLocal"
153
- | $validationScriptPath " $$ scalaVersion"
233
+ |sbt "clean; ${scala3Project} /publishLocal"
234
+ | ${validationScript.getAbsolutePath} " $$ scalaVersion"
154
235
""" .stripMargin
155
236
" git bisect start" .!
156
237
s " git bisect bad $fistBadHash" .!
0 commit comments