diff --git a/.github/scripts/build-pr-classification-comment.sc b/.github/scripts/build-pr-classification-comment.sc new file mode 100755 index 0000000000..add43e7cd7 --- /dev/null +++ b/.github/scripts/build-pr-classification-comment.sc @@ -0,0 +1,115 @@ +#!/usr/bin/env -S scala-cli shebang +//> using scala 3 +//> using toolkit default +//> using options -Werror -Wunused:all + +//> using file ./pr-classify-lib/Env.scala +//> using file ./pr-classify-lib/EnvNames.scala +//> using file ./pr-classify-lib/Category.scala +//> using file ./pr-classify-lib/OverrideKey.scala +//> using file ./pr-classify-lib/SuiteGroup.scala +//> using file ./pr-classify-lib/Signals.scala +//> using file ./pr-classify-lib/KeyValueFile.scala + +// Builds the Markdown body for the PR sticky comment that summarizes how the +// `changes` job classified the diff and which suite groups will run / be +// skipped. Intended to be invoked from `ci.yml`'s `changes` job so the +// resulting comment reflects the exact classification CI is using; the +// `pr-classify-comment.yml` workflow then downloads the rendered body as +// an artifact and posts it as a sticky PR comment. +// +// Inputs (env vars): +// CLASSIFY_OUTPUT_FILE - KEY=VALUE file produced by classify-changes.sc. +// OVERRIDE_OUTPUT_FILE - KEY=VALUE file produced by check-override-keywords.sc. +// COMMENT_OUTPUT_FILE - Path to write the rendered Markdown to (default: comment.md). +// CI_RUN_ID - Run ID of the CI workflow run (optional). +// CI_RUN_URL - URL to the CI workflow run (optional). + +import prclassify.* + +/** Which active overrides add suite groups that wouldn't run on their own. */ +def overrideContributions(signals: Signals): Seq[(OverrideKey, Seq[SuiteGroup])] = + val baseline = signals.withoutOverrides + val baselineRuns = SuiteGroup.values.iterator.filter(baseline.shouldRun).toSet + OverrideKey.ordered.flatMap: key => + if !signals.has(key) then None + else + val probe = baseline.withOverride(key, enabled = true) + val added = SuiteGroup.ordered.filter(g => probe.shouldRun(g) && !baselineRuns.contains(g)) + if added.isEmpty then None else Some(key -> added) + +def renderComment( + signals: Signals, + ciRunId: Option[String], + ciRunUrl: Option[String] +): String = + val (runGroups, skipGroups) = SuiteGroup.ordered.partition(signals.shouldRun) + + val categoryRows = Category.ordered + .map(c => s"| ${c.key} | ${signals.has(c)} |") + .mkString("\n") + + def listLine(prefix: String, groups: Seq[SuiteGroup]): String = + if groups.isEmpty then s"- $prefix: _(none)_" + else s"- $prefix: ${groups.map(_.label).mkString(", ")}" + + // categoryRows lines start with `|`, which is stripMargin's margin marker, + // so concatenate them after the stripped template instead of interpolating. + val headerSection = + s"""### CI change classification + | + |**Change categories** + | + || Category | Changed | + || --- | --- | + |""".stripMargin + categoryRows + + val suitesSection = + s"""**Suite groups** + | + |${listLine("Will run", runGroups)} + |${listLine("Will be skipped", skipGroups)}""".stripMargin + + val overridesSection: Option[String] = overrideContributions(signals) match + case Nil => None + case contributions => + val rows = contributions + .map((key, added) => s"- `${key.marker}` forces on: ${added.map(_.label).mkString(", ")}") + .mkString("\n") + Some( + s"""**Override keywords affecting the run set** + | + |$rows""".stripMargin + ) + + val ciRunSection: Option[String] = ciRunId match + case Some(id) => Some(s"Full CI run: [#$id](${ciRunUrl.getOrElse("")})") + case None => ciRunUrl.map(url => s"Full CI run: $url") + + val sections = + Seq(Some(headerSection), Some(suitesSection), overridesSection, ciRunSection).flatten + + sections.mkString("\n\n") + "\n" + +val categoryMap = KeyValueFile.read(Env.requiredFile(EnvNames.ClassifyOutputFile)) +val overrideMap = KeyValueFile.read(Env.requiredFile(EnvNames.OverrideOutputFile)) +val signals = Signals.fromKeyValueMaps(categoryMap, overrideMap) + +val commentPath = Env.toAbsolutePath( + Env.withDefault(EnvNames.CommentOutputFile, "comment.md") +) + +val body = renderComment( + signals, + ciRunId = Env.opt(EnvNames.CiRunId), + ciRunUrl = Env.opt(EnvNames.CiRunUrl) +) +println( + s"""Generated comment body: + |----------------------- + |$body + |-----------------------""".stripMargin +) + +os.write.over(commentPath, body, createFolders = true) +println(s"Wrote comment to $commentPath") diff --git a/.github/scripts/check-override-keywords.sc b/.github/scripts/check-override-keywords.sc new file mode 100755 index 0000000000..7af32d2d83 --- /dev/null +++ b/.github/scripts/check-override-keywords.sc @@ -0,0 +1,59 @@ +#!/usr/bin/env -S scala-cli shebang +//> using scala 3 +//> using toolkit default +//> using options -Werror -Wunused:all + +//> using file ./pr-classify-lib/Env.scala +//> using file ./pr-classify-lib/EnvNames.scala +//> using file ./pr-classify-lib/OverrideKey.scala +//> using file ./pr-classify-lib/KeyValueFile.scala +//> using file ./pr-classify-lib/GitHubOutput.scala + +// Checks the PR body for [test_*] override keywords. +// Inputs (env vars): +// EVENT_NAME - GitHub event name (pull_request, push, ...). +// PR_BODY - The PR body to scan for override keywords. +// OVERRIDE_OUTPUT_FILE - Optional path of a KEY=VALUE file to also write +// override results into (in addition to $GITHUB_OUTPUT). +// Outputs: +// - `${keyword}=true|false` to $GITHUB_OUTPUT for every override, +// - a Markdown summary table to $GITHUB_STEP_SUMMARY, +// - and the same KEY=VALUE pairs to $OVERRIDE_OUTPUT_FILE when set. + +import prclassify.* + +val eventName = Env.opt(EnvNames.EventName).getOrElse("") +val prBody = Env.opt(EnvNames.PrBody).getOrElse("") + +val active: Set[OverrideKey] = + if eventName != "pull_request" then + println("Non-PR event, setting all overrides to true") + OverrideKey.values.toSet + else OverrideKey.values.iterator.filter(o => prBody.contains(o.marker)).toSet + +OverrideKey.ordered.foreach: o => + if active.contains(o) then println(s"Override ${o.marker} found") + +println("Override keywords:") +OverrideKey.ordered.foreach(o => println(s" ${o.keyword}=${active.contains(o)}")) + +val entries: Seq[(String, String)] = + OverrideKey.ordered.map(o => o.keyword -> active.contains(o).toString) + +entries.foreach((k, v) => GitHubOutput.writeScalar(k, v)) + +Env.opt(EnvNames.OverrideOutputFile).foreach: path => + KeyValueFile.appendAll(Env.toAbsolutePath(path), entries) + +val overrideRows = OverrideKey.ordered + .map(o => s"| ${o.marker} | ${active.contains(o)} |") + .mkString("\n") + +// overrideRows lines start with `|` (stripMargin's margin marker), so +// concatenate them after the stripped template instead of interpolating. +GitHubOutput.writeSummary( + s"""## Override keywords + || Keyword | Active | + ||---------|--------| + |""".stripMargin + overrideRows +) diff --git a/.github/scripts/check-override-keywords.sh b/.github/scripts/check-override-keywords.sh deleted file mode 100755 index 51978b4a49..0000000000 --- a/.github/scripts/check-override-keywords.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Checks the PR body for [test_*] override keywords. -# Inputs (env vars): EVENT_NAME, PR_BODY -# Outputs: writes override=true/false pairs to $GITHUB_OUTPUT and a summary table to $GITHUB_STEP_SUMMARY - -if [[ "$EVENT_NAME" != "pull_request" ]]; then - echo "Non-PR event, setting all overrides to true" - for override in test_all test_native test_integration test_docs test_format; do - echo "$override=true" >> "$GITHUB_OUTPUT" - done - exit 0 -fi - -TEST_ALL=false; TEST_NATIVE=false; TEST_INTEGRATION=false; TEST_DOCS=false; TEST_FORMAT=false - -check_override() { - local keyword="$1" - local var_name="$2" - if printf '%s' "$PR_BODY" | grep -qF "$keyword"; then - eval "$var_name=true" - echo "Override $keyword found" - fi -} - -check_override "[test_all]" "TEST_ALL" -check_override "[test_native]" "TEST_NATIVE" -check_override "[test_integration]" "TEST_INTEGRATION" -check_override "[test_docs]" "TEST_DOCS" -check_override "[test_format]" "TEST_FORMAT" - -echo "Override keywords:" -echo " test_all=$TEST_ALL" -echo " test_native=$TEST_NATIVE" -echo " test_integration=$TEST_INTEGRATION" -echo " test_docs=$TEST_DOCS" -echo " test_format=$TEST_FORMAT" - -echo "test_all=$TEST_ALL" >> "$GITHUB_OUTPUT" -echo "test_native=$TEST_NATIVE" >> "$GITHUB_OUTPUT" -echo "test_integration=$TEST_INTEGRATION" >> "$GITHUB_OUTPUT" -echo "test_docs=$TEST_DOCS" >> "$GITHUB_OUTPUT" -echo "test_format=$TEST_FORMAT" >> "$GITHUB_OUTPUT" - -echo "## Override keywords" >> "$GITHUB_STEP_SUMMARY" -echo "| Keyword | Active |" >> "$GITHUB_STEP_SUMMARY" -echo "|---------|--------|" >> "$GITHUB_STEP_SUMMARY" -echo "| [test_all] | $TEST_ALL |" >> "$GITHUB_STEP_SUMMARY" -echo "| [test_native] | $TEST_NATIVE |" >> "$GITHUB_STEP_SUMMARY" -echo "| [test_integration] | $TEST_INTEGRATION |" >> "$GITHUB_STEP_SUMMARY" -echo "| [test_docs] | $TEST_DOCS |" >> "$GITHUB_STEP_SUMMARY" -echo "| [test_format] | $TEST_FORMAT |" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/scripts/classify-changes.sc b/.github/scripts/classify-changes.sc new file mode 100755 index 0000000000..257be0a99a --- /dev/null +++ b/.github/scripts/classify-changes.sc @@ -0,0 +1,80 @@ +#!/usr/bin/env -S scala-cli shebang +//> using scala 3 +//> using toolkit default +//> using options -Werror -Wunused:all + +//> using file ./pr-classify-lib/Env.scala +//> using file ./pr-classify-lib/EnvNames.scala +//> using file ./pr-classify-lib/Category.scala +//> using file ./pr-classify-lib/KeyValueFile.scala +//> using file ./pr-classify-lib/GitHubOutput.scala + +// Classifies changed files into categories for CI job filtering. +// Inputs (env vars): +// EVENT_NAME - GitHub event name (pull_request, push, ...). +// BASE_REF - Base ref of the PR (used to compute the diff). +// CHANGED_FILES_OVERRIDE - Optional newline-separated list of changed files. +// When set, overrides the git-diff-based detection. +// CLASSIFY_OUTPUT_FILE - Optional path of a KEY=VALUE file to also write +// category results into (in addition to $GITHUB_OUTPUT). +// Outputs: +// - `${category}=true|false` to $GITHUB_OUTPUT for every category, +// - a Markdown summary table to $GITHUB_STEP_SUMMARY, +// - and the same KEY=VALUE pairs to $CLASSIFY_OUTPUT_FILE when set. + +import prclassify.* + +enum ChangeSet: + case All + case Specific(files: Seq[String]) + +def splitLines(s: String): Seq[String] = + s.linesIterator.map(_.trim).filter(_.nonEmpty).toIndexedSeq + +val eventName = Env.opt(EnvNames.EventName).getOrElse("") + +val changeSet: ChangeSet = + if eventName != "pull_request" then + println(s"Non-PR event ($eventName), setting all categories to true") + ChangeSet.All + else + Env.opt(EnvNames.ChangedFilesOverride) match + case Some(list) => + ChangeSet.Specific(splitLines(list)) + case None => + val baseRef = Env.required(EnvNames.BaseRef) + val result = os.proc("git", "diff", "--name-only", s"origin/$baseRef...HEAD") + .call(check = false, mergeErrIntoOut = false, stderr = os.Inherit) + if result.exitCode != 0 then + System.err.println("::warning::Failed to compute diff, running all jobs") + println("Diff computation failed, setting all categories to true") + ChangeSet.All + else ChangeSet.Specific(splitLines(result.out.text())) + +val activeCategories: Set[Category] = changeSet match + case ChangeSet.All => Category.values.toSet + case ChangeSet.Specific(files) => files.iterator.flatMap(Category.forPath).toSet + +println("Change categories:") +Category.ordered.foreach(c => println(s" ${c.key}=${activeCategories.contains(c)}")) + +val entries: Seq[(String, String)] = + Category.ordered.map(c => c.key -> activeCategories.contains(c).toString) + +entries.foreach((k, v) => GitHubOutput.writeScalar(k, v)) + +Env.opt(EnvNames.ClassifyOutputFile).foreach: path => + KeyValueFile.appendAll(Env.toAbsolutePath(path), entries) + +val categoryRows = Category.ordered + .map(c => s"| ${c.key} | ${activeCategories.contains(c)} |") + .mkString("\n") + +// categoryRows lines start with `|` (stripMargin's margin marker), so +// concatenate them after the stripped template instead of interpolating. +GitHubOutput.writeSummary( + s"""## Change categories + || Category | Changed | + ||----------|---------| + |""".stripMargin + categoryRows +) diff --git a/.github/scripts/classify-changes.sh b/.github/scripts/classify-changes.sh deleted file mode 100755 index 3f241f1c39..0000000000 --- a/.github/scripts/classify-changes.sh +++ /dev/null @@ -1,62 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Classifies changed files into categories for CI job filtering. -# Inputs (env vars): EVENT_NAME, BASE_REF -# Outputs: writes category=true/false pairs to $GITHUB_OUTPUT and a summary table to $GITHUB_STEP_SUMMARY - -if [[ "$EVENT_NAME" != "pull_request" ]]; then - echo "Non-PR event ($EVENT_NAME), setting all categories to true" - for cat in code docs ci format_config benchmark gifs mill_wrapper; do - echo "$cat=true" >> "$GITHUB_OUTPUT" - done - exit 0 -fi - -CHANGED_FILES=$(git diff --name-only "origin/$BASE_REF...HEAD" || echo "DIFF_FAILED") -if [[ "$CHANGED_FILES" == "DIFF_FAILED" ]]; then - echo "::warning::Failed to compute diff, running all jobs" - for cat in code docs ci format_config benchmark gifs mill_wrapper; do - echo "$cat=true" >> "$GITHUB_OUTPUT" - done - exit 0 -fi - -CODE=false; DOCS=false; CI=false; FORMAT_CONFIG=false; BENCHMARK=false; GIFS=false; MILL_WRAPPER=false - -while IFS= read -r file; do - case "$file" in - modules/*|build.mill|project/*) CODE=true ;; - website/*) DOCS=true ;; - .github/*) CI=true ;; - .scalafmt.conf|.scalafix.conf) FORMAT_CONFIG=true ;; - gcbenchmark/*) BENCHMARK=true ;; - gifs/*) GIFS=true ;; - mill|mill.bat) MILL_WRAPPER=true ;; - esac -done <<< "$CHANGED_FILES" - -echo "Change categories:" -echo " code=$CODE" -echo " docs=$DOCS" -echo " ci=$CI" -echo " format_config=$FORMAT_CONFIG" -echo " benchmark=$BENCHMARK" -echo " gifs=$GIFS" -echo " mill_wrapper=$MILL_WRAPPER" - -echo "code=$CODE" >> "$GITHUB_OUTPUT" -echo "docs=$DOCS" >> "$GITHUB_OUTPUT" -echo "ci=$CI" >> "$GITHUB_OUTPUT" -echo "format_config=$FORMAT_CONFIG" >> "$GITHUB_OUTPUT" -echo "benchmark=$BENCHMARK" >> "$GITHUB_OUTPUT" -echo "gifs=$GIFS" >> "$GITHUB_OUTPUT" -echo "mill_wrapper=$MILL_WRAPPER" >> "$GITHUB_OUTPUT" - -echo "## Change categories" >> "$GITHUB_STEP_SUMMARY" -echo "| Category | Changed |" >> "$GITHUB_STEP_SUMMARY" -echo "|----------|---------|" >> "$GITHUB_STEP_SUMMARY" -for cat in code docs ci format_config benchmark gifs mill_wrapper; do - val=$(eval echo \$$( echo $cat | tr 'a-z' 'A-Z')) - echo "| $cat | $val |" >> "$GITHUB_STEP_SUMMARY" -done diff --git a/.github/scripts/pr-classify-lib/Category.scala b/.github/scripts/pr-classify-lib/Category.scala new file mode 100644 index 0000000000..81b7884c55 --- /dev/null +++ b/.github/scripts/pr-classify-lib/Category.scala @@ -0,0 +1,35 @@ +package prclassify + +/** Change categories recognized by classify-changes. The `key` is what gets written as the KEY in + * KEY=VALUE outputs and what downstream consumers (ci.yml SHOULD_RUN expressions, + * build-pr-classification-comment) look up. + */ +enum Category(val key: String): + case Code extends Category("code") + case Docs extends Category("docs") + case Ci extends Category("ci") + case FormatConfig extends Category("format_config") + case Benchmark extends Category("benchmark") + case Gifs extends Category("gifs") + case MillWrapper extends Category("mill_wrapper") + +object Category: + + /** Display order used in outputs and summaries. */ + val ordered: Seq[Category] = values.toIndexedSeq + + /** Classify a single changed-file path. A file can belong to zero or one category; if it belongs + * to none it's ignored. Kept in sync with the original bash `case "$file" in ... esac` rules. + */ + def forPath(path: String): Option[Category] = path match + case p if p.startsWith("modules/") || p == "build.mill" || p.startsWith("project/") => + Some(Code) + case p if p.startsWith("website/") => Some(Docs) + case p if p.startsWith(".github/") => Some(Ci) + case ".scalafmt.conf" | ".scalafix.conf" => Some(FormatConfig) + case p if p.startsWith("gcbenchmark/") => Some(Benchmark) + case p if p.startsWith("gifs/") => Some(Gifs) + case "mill" | "mill.bat" => Some(MillWrapper) + case _ => None + + def fromKey(key: String): Option[Category] = values.find(_.key == key) diff --git a/.github/scripts/pr-classify-lib/Env.scala b/.github/scripts/pr-classify-lib/Env.scala new file mode 100644 index 0000000000..25c0a57472 --- /dev/null +++ b/.github/scripts/pr-classify-lib/Env.scala @@ -0,0 +1,31 @@ +package prclassify + +import java.nio.file.Paths + +/** Small helpers for reading environment variables with consistent behavior: + * - empty strings are treated as unset, + * - missing required variables print a GitHub Actions `::error::` line and exit with code 1. + */ +object Env: + + def opt(name: String): Option[String] = + sys.env.get(name).filter(_.nonEmpty) + + def required(name: String): String = + opt(name).getOrElse: + System.err.println(s"::error::$name is required") + sys.exit(1) + + def requiredFile(name: String): os.Path = + val raw = required(name) + val path = toAbsolutePath(raw) + if !os.exists(path) then + System.err.println(s"::error::$name points to non-existent file: $path") + sys.exit(1) + path + + def withDefault(name: String, default: String): String = + opt(name).getOrElse(default) + + def toAbsolutePath(s: String): os.Path = + if Paths.get(s).isAbsolute then os.Path(s) else os.Path(s, os.pwd) diff --git a/.github/scripts/pr-classify-lib/EnvNames.scala b/.github/scripts/pr-classify-lib/EnvNames.scala new file mode 100644 index 0000000000..b503cab5d2 --- /dev/null +++ b/.github/scripts/pr-classify-lib/EnvNames.scala @@ -0,0 +1,40 @@ +package prclassify + +/** Canonical names of the environment variables consumed or produced by the PR classification / + * commenting workflows. Kept in one place so renaming a variable is a single-file change. + */ +object EnvNames: + + // ---- Inputs to classify-changes.sc / check-override-keywords.sc ---- + /** "pull_request", "push", ... */ + val EventName = "EVENT_NAME" + + /** Base ref of the PR; used to compute the git diff. */ + val BaseRef = "BASE_REF" + + /** Newline-separated list of changed files. When set, overrides the git-diff-based detection + * (used by workflows without a full checkout). + */ + val ChangedFilesOverride = "CHANGED_FILES_OVERRIDE" + + /** Body of the pull request (scanned for override markers). */ + val PrBody = "PR_BODY" + + // ---- KEY=VALUE / comment artifact paths ---- + /** Path where `classify-changes` writes its KEY=VALUE output. */ + val ClassifyOutputFile = "CLASSIFY_OUTPUT_FILE" + + /** Path where `check-override-keywords` writes its KEY=VALUE output. */ + val OverrideOutputFile = "OVERRIDE_OUTPUT_FILE" + + /** Path where `build-pr-classification-comment` writes the rendered Markdown comment. + */ + val CommentOutputFile = "COMMENT_OUTPUT_FILE" + + // ---- CI run context (embedded in the rendered comment) ---- + val CiRunId = "CI_RUN_ID" + val CiRunUrl = "CI_RUN_URL" + + // ---- GitHub Actions built-ins ---- + val GitHubOutput = "GITHUB_OUTPUT" + val GitHubStepSummary = "GITHUB_STEP_SUMMARY" diff --git a/.github/scripts/pr-classify-lib/GitHubOutput.scala b/.github/scripts/pr-classify-lib/GitHubOutput.scala new file mode 100644 index 0000000000..095946a150 --- /dev/null +++ b/.github/scripts/pr-classify-lib/GitHubOutput.scala @@ -0,0 +1,30 @@ +package prclassify + +import java.util.UUID + +/** Helpers for writing to `$GITHUB_OUTPUT` and `$GITHUB_STEP_SUMMARY`. All methods are no-ops when + * the corresponding env var is unset, which keeps the scripts usable outside GitHub Actions (e.g. + * local smoke-testing). + */ +object GitHubOutput: + + def writeScalar(key: String, value: String): Unit = + Env.opt(EnvNames.GitHubOutput).foreach: path => + os.write.append(Env.toAbsolutePath(path), s"$key=$value\n") + + /** Writes a GitHub Actions heredoc-style multi-line output. Uses a random delimiter so values + * never accidentally collide with the closing marker. + */ + def writeMultiline(key: String, values: Iterable[String]): Unit = + Env.opt(EnvNames.GitHubOutput).foreach: path => + val delimiter = s"EOF_${UUID.randomUUID().toString.replace("-", "")}" + val payload = + (Iterator.single(s"$key<<$delimiter") ++ values.iterator ++ Iterator.single(delimiter)) + .mkString("", "\n", "\n") + os.write.append(Env.toAbsolutePath(path), payload) + + /** Appends `text` to `$GITHUB_STEP_SUMMARY`, ensuring it ends with a newline. */ + def writeSummary(text: String): Unit = + Env.opt(EnvNames.GitHubStepSummary).foreach: path => + val payload = if text.endsWith("\n") then text else text + "\n" + os.write.append(Env.toAbsolutePath(path), payload) diff --git a/.github/scripts/pr-classify-lib/KeyValueFile.scala b/.github/scripts/pr-classify-lib/KeyValueFile.scala new file mode 100644 index 0000000000..76cece11dd --- /dev/null +++ b/.github/scripts/pr-classify-lib/KeyValueFile.scala @@ -0,0 +1,28 @@ +package prclassify + +/** Tiny KEY=VALUE file format used to pass classification/override results between workflow steps. + * Lines are trimmed, blanks and `#`-comment lines are ignored. + */ +object KeyValueFile: + + def read(path: os.Path): Map[String, String] = + if !os.exists(path) then Map.empty + else + os.read.lines(path).iterator.flatMap { line => + val trimmed = line.trim + if trimmed.isEmpty || trimmed.startsWith("#") then None + else + trimmed.split("=", 2) match + case Array(k, v) => Some(k.trim -> v.trim) + case _ => None + }.toMap + + def writeAll(path: os.Path, entries: Iterable[(String, String)]): Unit = + val content = entries.map((k, v) => s"$k=$v").mkString("", "\n", "\n") + os.write.over(path, content, createFolders = true) + + def appendAll(path: os.Path, entries: Iterable[(String, String)]): Unit = + if entries.isEmpty then () + else + val content = entries.map((k, v) => s"$k=$v").mkString("", "\n", "\n") + os.write.append(path, content) diff --git a/.github/scripts/pr-classify-lib/OverrideKey.scala b/.github/scripts/pr-classify-lib/OverrideKey.scala new file mode 100644 index 0000000000..22cd09d480 --- /dev/null +++ b/.github/scripts/pr-classify-lib/OverrideKey.scala @@ -0,0 +1,22 @@ +package prclassify + +/** Override markers users can add to a PR body to force certain suite groups on even when the diff + * wouldn't normally trigger them (e.g. `[test_native]`). + */ +enum OverrideKey(val keyword: String): + case TestAll extends OverrideKey("test_all") + case TestNative extends OverrideKey("test_native") + case TestIntegration extends OverrideKey("test_integration") + case TestDocs extends OverrideKey("test_docs") + case TestFormat extends OverrideKey("test_format") + + /** The literal marker users write in PR bodies, e.g. "[test_native]". */ + def marker: String = s"[$keyword]" + +object OverrideKey: + + /** Display order used in outputs and summaries. */ + val ordered: Seq[OverrideKey] = values.toIndexedSeq + + def fromKeyword(keyword: String): Option[OverrideKey] = + values.find(_.keyword == keyword) diff --git a/.github/scripts/pr-classify-lib/Signals.scala b/.github/scripts/pr-classify-lib/Signals.scala new file mode 100644 index 0000000000..d0b1e65406 --- /dev/null +++ b/.github/scripts/pr-classify-lib/Signals.scala @@ -0,0 +1,72 @@ +package prclassify + +/** A pair of (which change categories fired, which overrides are active). This captures everything + * needed to decide which suite groups will run. + */ +case class Signals(categories: Set[Category], overrides: Set[OverrideKey]): + + def has(c: Category): Boolean = categories.contains(c) + def has(o: OverrideKey): Boolean = overrides.contains(o) + + def withoutOverrides: Signals = copy(overrides = Set.empty) + + def withOverride(key: OverrideKey, enabled: Boolean): Signals = + copy(overrides = if enabled then overrides + key else overrides - key) + + /** Evaluates the SHOULD_RUN boolean for the given suite group. Keep these predicates in sync with + * the `SHOULD_RUN: ...` expressions used in `.github/workflows/ci.yml` — see the file-line + * references next to each branch for where to look. + */ + def shouldRun(group: SuiteGroup): Boolean = + import Category.* + import OverrideKey.* + import SuiteGroup.* + group match + // unit-tests (ci.yml line 54), test-fish-shell (line 109) + case UnitAndMill => + has(Code) || has(Ci) || has(MillWrapper) || has(TestAll) + // jvm-*-tests-* (ci.yml lines 143, 181, 219, 257, 295, 333) + case JvmIntegration => + has(Code) || has(Ci) || has(TestAll) || has(TestIntegration) + // native-*-tests-*, generate-*-launcher (ci.yml lines 371 .. 1601) + case NativeIntegration => + has(Code) || has(Ci) || has(TestAll) || has(TestNative) + // docs-tests (ci.yml line 1650) + case DocsTests => + has(Code) || has(Docs) || has(Ci) || has(Gifs) || has(TestAll) || has(TestDocs) + // checks (ci.yml line 1696) + case Checks => + has(Code) || has(Docs) || has(Ci) || has(FormatConfig) || has(TestAll) + // format (ci.yml line 1740) + case Format => + has(Code) || has(Docs) || has(Ci) || has(FormatConfig) || has(TestAll) || has(TestFormat) + // reference-doc (ci.yml line 1764) + case ReferenceDoc => + has(Code) || has(Docs) || has(Ci) || has(TestAll) + // bloop-memory-footprint (ci.yml line 1793) + case BloopMemoryFootprint => + has(Code) || has(Ci) || has(Benchmark) || has(TestAll) + // test-hypothetical-sbt-export, vc-redist (ci.yml lines 1832, 1860) + case SbtExportVcRedist => + has(Code) || has(Ci) || has(TestAll) + +object Signals: + + val empty: Signals = Signals(Set.empty, Set.empty) + + /** Reads KEY=VALUE maps produced by `classify-changes.sc` and `check-override-keywords.sc` and + * turns them into a `Signals`. + */ + def fromKeyValueMaps( + categoryMap: Map[String, String], + overrideMap: Map[String, String] + ): Signals = + def isTrue(v: String): Boolean = v.equalsIgnoreCase("true") + Signals( + categories = Category.values.iterator + .filter(c => categoryMap.get(c.key).exists(isTrue)) + .toSet, + overrides = OverrideKey.values.iterator + .filter(o => overrideMap.get(o.keyword).exists(isTrue)) + .toSet + ) diff --git a/.github/scripts/pr-classify-lib/SuiteGroup.scala b/.github/scripts/pr-classify-lib/SuiteGroup.scala new file mode 100644 index 0000000000..53652ad9e3 --- /dev/null +++ b/.github/scripts/pr-classify-lib/SuiteGroup.scala @@ -0,0 +1,21 @@ +package prclassify + +/** High-level suite groups reported in the PR classification comment. Each entry corresponds to one + * of the distinct `SHOULD_RUN: ...` expressions used across the jobs in + * `.github/workflows/ci.yml`. + */ +enum SuiteGroup(val label: String): + case UnitAndMill extends SuiteGroup("Unit tests & fish shell") + case JvmIntegration extends SuiteGroup("JVM integration tests") + case NativeIntegration extends SuiteGroup("Native integration tests") + case DocsTests extends SuiteGroup("Docs tests") + case Checks extends SuiteGroup("Checks") + case Format extends SuiteGroup("Format / scalafix") + case ReferenceDoc extends SuiteGroup("Reference docs") + case BloopMemoryFootprint extends SuiteGroup("Bloop memory footprint") + case SbtExportVcRedist extends SuiteGroup("Sbt export / vc-redist") + +object SuiteGroup: + + /** Display order used in the generated comment. */ + val ordered: Seq[SuiteGroup] = values.toIndexedSeq diff --git a/.github/scripts/wait-for-ci-changes-job.sh b/.github/scripts/wait-for-ci-changes-job.sh new file mode 100755 index 0000000000..943de4f8b4 --- /dev/null +++ b/.github/scripts/wait-for-ci-changes-job.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Polls the GitHub REST API until the `changes` job of the CI workflow run +# matching $HEAD_SHA has finished. Exits 0 on success (and writes the run id +# to $GITHUB_OUTPUT as `run_id`), non-zero on failure or timeout. +# +# Expected environment variables: +# GH_TOKEN - token used by `gh api`. +# REPO - "owner/name" of the repository. +# HEAD_SHA - PR head SHA to match CI runs against. +# GITHUB_OUTPUT - standard GitHub Actions output file. +# MAX_ATTEMPTS - (optional, default 40) number of polling attempts. +# INTERVAL_SECONDS - (optional, default 15) seconds between attempts. +# CI_WORKFLOW_NAME - (optional, default "CI") name of the workflow to wait for. +# CI_JOB_NAME - (optional, default "changes") name of the job to wait for. +set -euo pipefail + +: "${GH_TOKEN:?GH_TOKEN is required}" +: "${REPO:?REPO is required}" +: "${HEAD_SHA:?HEAD_SHA is required}" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT is required}" + +max_attempts="${MAX_ATTEMPTS:-40}" +interval="${INTERVAL_SECONDS:-15}" +workflow_name="${CI_WORKFLOW_NAME:-CI}" +job_name="${CI_JOB_NAME:-changes}" + +attempt=0 +while [ "$attempt" -lt "$max_attempts" ]; do + attempt=$((attempt + 1)) + + # Find the most recent matching workflow run for this head SHA. The run + # may not be discoverable for a few seconds after the PR event dispatches. + run_id=$(gh api \ + "repos/$REPO/actions/runs?event=pull_request&head_sha=$HEAD_SHA&per_page=30" \ + --jq "[.workflow_runs[] | select(.name==\"$workflow_name\")] | sort_by(.run_number) | last | .id // empty") + + if [ -z "$run_id" ]; then + echo "$workflow_name run not yet discoverable for $HEAD_SHA (attempt $attempt/$max_attempts)" + sleep "$interval" + continue + fi + + job=$(gh api "repos/$REPO/actions/runs/$run_id/jobs" \ + --jq ".jobs[] | select(.name==\"$job_name\") | {status: .status, conclusion: .conclusion}") + status=$(echo "$job" | jq -r '.status // empty') + conclusion=$(echo "$job" | jq -r '.conclusion // empty') + + if [ "$status" = "completed" ]; then + if [ "$conclusion" = "success" ]; then + echo "$job_name job completed in $workflow_name run $run_id" + echo "run_id=$run_id" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "::error::$job_name job in $workflow_name run $run_id finished with conclusion=$conclusion" + exit 1 + fi + + echo "$workflow_name run $run_id, $job_name job status=$status (attempt $attempt/$max_attempts)" + sleep "$interval" +done + +echo "::error::Timed out after $max_attempts attempts waiting for the $job_name job in $workflow_name" +exit 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6e26a5489..5adf93ec88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,18 +33,44 @@ jobs: - uses: actions/checkout@v6 with: fetch-depth: 0 + - uses: coursier/cache-action@v8 + - uses: VirtusLab/scala-cli-setup@v1 + with: + jvm: "temurin:17" - name: Classify changes id: classify env: EVENT_NAME: ${{ github.event_name }} BASE_REF: ${{ github.event.pull_request.base.ref }} - run: .github/scripts/classify-changes.sh + CLASSIFY_OUTPUT_FILE: ${{ runner.temp }}/classify.env + run: .github/scripts/classify-changes.sc - name: Check override keywords id: overrides env: EVENT_NAME: ${{ github.event_name }} PR_BODY: ${{ github.event.pull_request.body }} - run: .github/scripts/check-override-keywords.sh + OVERRIDE_OUTPUT_FILE: ${{ runner.temp }}/overrides.env + run: .github/scripts/check-override-keywords.sc + # Build the sticky-comment body inside the CI run itself so the comment + # posted by pr-classify-comment.yml reflects the exact classification + # this CI run is using. The artifact is consumed by pr-classify-comment.yml. + - name: Build PR classification comment + if: github.event_name == 'pull_request' + env: + CLASSIFY_OUTPUT_FILE: ${{ runner.temp }}/classify.env + OVERRIDE_OUTPUT_FILE: ${{ runner.temp }}/overrides.env + COMMENT_OUTPUT_FILE: ${{ runner.temp }}/pr-classification-comment.md + CI_RUN_ID: ${{ github.run_id }} + CI_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: .github/scripts/build-pr-classification-comment.sc + - name: Upload PR classification comment artifact + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v7 + with: + name: pr-classification-comment + path: ${{ runner.temp }}/pr-classification-comment.md + if-no-files-found: error + retention-days: 1 unit-tests: needs: [changes] diff --git a/.github/workflows/pr-classify-comment.yml b/.github/workflows/pr-classify-comment.yml new file mode 100644 index 0000000000..ff8e392a31 --- /dev/null +++ b/.github/workflows/pr-classify-comment.yml @@ -0,0 +1,65 @@ +name: PR Classification Comment + +# Posts a sticky PR comment describing how the `changes` job in ci.yml +# classified the diff and which suite groups will run or be skipped. +# +# The comment body is produced by ci.yml itself (see the `changes` job's +# `Build PR classification comment` step, which uploads the rendered +# Markdown as the `pr-classification-comment` artifact). This workflow +# only waits for that artifact to become available, downloads it, and +# posts it. +# +# Using pull_request_target keeps the GITHUB_TOKEN write-capable for +# fork PRs (required to post the comment). This workflow checks out the +# BASE ref only (the default for pull_request_target) — never the PR +# head — so the helper script we run is trusted base-repo code. +on: + pull_request_target: + types: [opened, synchronize, reopened] + +concurrency: + group: pr-classify-comment-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + pull-requests: write + contents: read + actions: read + +jobs: + post-comment: + if: github.event.pull_request != null + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - name: Checkout base ref (for helper scripts) + uses: actions/checkout@v6 + with: + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Wait for CI changes job + id: wait + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + MAX_ATTEMPTS: "40" # 40 * 15s = 10 minutes + INTERVAL_SECONDS: "15" + run: .github/scripts/wait-for-ci-changes-job.sh + + - name: Download classification comment artifact + uses: actions/download-artifact@v8 + with: + name: pr-classification-comment + path: ${{ runner.temp }} + run-id: ${{ steps.wait.outputs.run_id }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Post / update PR comment + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: scala-cli-changes-classification + number: ${{ github.event.pull_request.number }} + path: ${{ runner.temp }}/pr-classification-comment.md