From 3715e2d7dad7fff49d3351ed91fe973db76d487e Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Fri, 17 Apr 2026 13:57:45 +0200 Subject: [PATCH 1/3] Post a comment under PRs indicating which tests are ran/skipped by the CI --- .../build-pr-classification-comment.sc | 228 ++++++++++++++++++ .github/scripts/check-override-keywords.sh | 64 +++-- .github/scripts/classify-changes.sh | 87 +++++-- .github/scripts/fetch-pr-changed-files.sc | 65 +++++ .github/scripts/resolve-ci-run-link.sc | 100 ++++++++ .github/workflows/pr-classify-comment.yml | 85 +++++++ 6 files changed, 583 insertions(+), 46 deletions(-) create mode 100755 .github/scripts/build-pr-classification-comment.sc create mode 100755 .github/scripts/fetch-pr-changed-files.sc create mode 100755 .github/scripts/resolve-ci-run-link.sc create mode 100644 .github/workflows/pr-classify-comment.yml diff --git a/.github/scripts/build-pr-classification-comment.sc b/.github/scripts/build-pr-classification-comment.sc new file mode 100755 index 0000000000..8d85bdaee0 --- /dev/null +++ b/.github/scripts/build-pr-classification-comment.sc @@ -0,0 +1,228 @@ +#!/usr/bin/env -S scala-cli shebang +//> using scala 3 +//> using toolkit default +//> using options -Werror -Wunused:all + +// 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. +// +// Inputs (env vars): +// CLASSIFY_OUTPUT_FILE - KEY=VALUE file produced by classify-changes.sh. +// OVERRIDE_OUTPUT_FILE - KEY=VALUE file produced by check-override-keywords.sh. +// COMMENT_OUTPUT_FILE - Path to write the resulting Markdown comment to +// (default: ./comment.md). +// CLASSIFY_RUN_ID - Run ID of the classification workflow (optional). +// CLASSIFY_RUN_URL - URL to the classification workflow run (optional). +// CI_RUN_ID - Run ID of the matching CI workflow run (optional; +// empty means "not yet resolved", link falls back). +// CI_RUN_URL - URL to the matching CI workflow run, or a fallback +// link (e.g. the PR's /checks page). + +import java.nio.file.Paths + +def envOpt(name: String): Option[String] = + sys.env.get(name).filter(_.nonEmpty) + +def envRequiredFile(name: String): os.Path = + val raw = envOpt(name).getOrElse: + System.err.println(s"::error::$name is missing") + sys.exit(1) + 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 toAbsolutePath(s: String): os.Path = + if Paths.get(s).isAbsolute then os.Path(s) else os.Path(s, os.pwd) + +def readKeyValueFile(path: os.Path): Map[String, String] = + 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 + +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") + +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") + +// Signals provided to suite-group expressions — keep the boolean predicates in +// sync with the SHOULD_RUN expressions used in .github/workflows/ci.yml. +case class Signals( + code: Boolean, + docs: Boolean, + ci: Boolean, + formatConfig: Boolean, + benchmark: Boolean, + gifs: Boolean, + millWrapper: Boolean, + testAll: Boolean, + testNative: Boolean, + testIntegration: Boolean, + testDocs: Boolean, + testFormat: Boolean +): + def withoutOverrides: Signals = copy( + testAll = false, + testNative = false, + testIntegration = false, + testDocs = false, + testFormat = false + ) + + def withOverride(key: OverrideKey, enabled: Boolean): Signals = key match + case OverrideKey.TestAll => copy(testAll = enabled) + case OverrideKey.TestNative => copy(testNative = enabled) + case OverrideKey.TestIntegration => copy(testIntegration = enabled) + case OverrideKey.TestDocs => copy(testDocs = enabled) + case OverrideKey.TestFormat => copy(testFormat = enabled) + + def shouldRun(group: SuiteGroup): Boolean = group match + case SuiteGroup.UnitAndMill => + code || ci || millWrapper || testAll + case SuiteGroup.JvmIntegration => + code || ci || testAll || testIntegration + case SuiteGroup.NativeIntegration => + code || ci || testAll || testNative + case SuiteGroup.DocsTests => + code || docs || ci || gifs || testAll || testDocs + case SuiteGroup.Checks => + code || docs || ci || formatConfig || testAll + case SuiteGroup.Format => + code || docs || ci || formatConfig || testAll || testFormat + case SuiteGroup.ReferenceDoc => + code || docs || ci || testAll + case SuiteGroup.BloopMemoryFootprint => + code || ci || benchmark || testAll + case SuiteGroup.SbtExportVcRedist => + code || ci || testAll + +object Signals: + def apply(categories: Map[String, String], overrides: Map[String, String]): Signals = + def boolAt(map: Map[String, String], key: String): Boolean = + map.get(key).exists(_.equalsIgnoreCase("true")) + Signals( + code = boolAt(categories, "code"), + docs = boolAt(categories, "docs"), + ci = boolAt(categories, "ci"), + formatConfig = boolAt(categories, "format_config"), + benchmark = boolAt(categories, "benchmark"), + gifs = boolAt(categories, "gifs"), + millWrapper = boolAt(categories, "mill_wrapper"), + testAll = boolAt(overrides, "test_all"), + testNative = boolAt(overrides, "test_native"), + testIntegration = boolAt(overrides, "test_integration"), + testDocs = boolAt(overrides, "test_docs"), + testFormat = boolAt(overrides, "test_format") + ) + +/** Which individual override keywords (among those currently active) actually + * add suite groups that wouldn't otherwise run. Used to list only the + * overrides that matter. + */ +extension (signals: Signals) + def isOverrideActive(key: OverrideKey): Boolean = key match + case OverrideKey.TestAll => signals.testAll + case OverrideKey.TestNative => signals.testNative + case OverrideKey.TestIntegration => signals.testIntegration + case OverrideKey.TestDocs => signals.testDocs + case OverrideKey.TestFormat => signals.testFormat + +def overrideContributions(signals: Signals): Seq[(OverrideKey, Seq[SuiteGroup])] = + val baseline = signals.withoutOverrides + val baselineRuns = SuiteGroup.values.filter(baseline.shouldRun).toSet + OverrideKey.values.toIndexedSeq.flatMap { key => + if !signals.isOverrideActive(key) then None + else + val probe = baseline.withOverride(key, enabled = true) + val added = SuiteGroup.values.toIndexedSeq + .filter(g => probe.shouldRun(g) && !baselineRuns.contains(g)) + if added.isEmpty then None else Some(key -> added) + } + +def renderComment( + signals: Signals, + classifyRunId: Option[String], + classifyRunUrl: Option[String], + ciRunId: Option[String], + ciRunUrl: Option[String] +): String = + val groups = SuiteGroup.values.toIndexedSeq + val (runGroups, skipGroups) = groups.partition(signals.shouldRun) + val contributions = overrideContributions(signals) + + val sb = StringBuilder() + sb ++= "### CI change classification\n\n" + sb ++= "**Change categories**\n\n" + sb ++= "| Category | Changed |\n" + sb ++= "| --- | --- |\n" + sb ++= s"| code | ${signals.code} |\n" + sb ++= s"| docs | ${signals.docs} |\n" + sb ++= s"| ci | ${signals.ci} |\n" + sb ++= s"| format_config | ${signals.formatConfig} |\n" + sb ++= s"| benchmark | ${signals.benchmark} |\n" + sb ++= s"| gifs | ${signals.gifs} |\n" + sb ++= s"| mill_wrapper | ${signals.millWrapper} |\n" + sb ++= "\n**Suite groups**\n\n" + sb ++= ( + if runGroups.isEmpty then "- Will run: _(none)_\n" + else s"- Will run: ${runGroups.map(_.label).mkString(", ")}\n" + ) + sb ++= ( + if skipGroups.isEmpty then "- Will be skipped: _(none)_\n" + else s"- Will be skipped: ${skipGroups.map(_.label).mkString(", ")}\n" + ) + + if contributions.nonEmpty then + sb ++= "\n**Override keywords affecting the run set**\n\n" + for (key, added) <- contributions do + sb ++= s"- `[${key.keyword}]` forces on: ${added.map(_.label).mkString(", ")}\n" + + ciRunId match + case Some(id) => + val url = ciRunUrl.getOrElse("") + sb ++= s"\nFull CI run: [#$id]($url)\n" + case None => + ciRunUrl.foreach { url => sb ++= s"\nFull CI run: $url\n" } + + classifyRunId.foreach { id => + val url = classifyRunUrl.getOrElse("") + sb ++= s"\n_Classified in run [#$id]($url)._\n" + } + + sb.result() + +val categories = readKeyValueFile(envRequiredFile("CLASSIFY_OUTPUT_FILE")) +val overrides = readKeyValueFile(envRequiredFile("OVERRIDE_OUTPUT_FILE")) +val signals = Signals(categories, overrides) + +val commentPath = toAbsolutePath(envOpt("COMMENT_OUTPUT_FILE").getOrElse("comment.md")) + +val body = renderComment( + signals, + classifyRunId = envOpt("CLASSIFY_RUN_ID"), + classifyRunUrl = envOpt("CLASSIFY_RUN_URL"), + ciRunId = envOpt("CI_RUN_ID"), + ciRunUrl = envOpt("CI_RUN_URL") +) + +os.write.over(commentPath, body, createFolders = true) +println(s"Wrote comment to $commentPath") diff --git a/.github/scripts/check-override-keywords.sh b/.github/scripts/check-override-keywords.sh index 51978b4a49..411fd0b77e 100755 --- a/.github/scripts/check-override-keywords.sh +++ b/.github/scripts/check-override-keywords.sh @@ -2,13 +2,37 @@ 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 +# 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: writes override=true/false pairs to $GITHUB_OUTPUT (when set) and +# a summary table to $GITHUB_STEP_SUMMARY (when set). When OVERRIDE_OUTPUT_FILE +# is provided, also writes the same KEY=VALUE pairs there. -if [[ "$EVENT_NAME" != "pull_request" ]]; then +OVERRIDES=(test_all test_native test_integration test_docs test_format) + +write_output() { + local key="$1" + local val="$2" + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "$key=$val" >> "$GITHUB_OUTPUT" + fi + if [[ -n "${OVERRIDE_OUTPUT_FILE:-}" ]]; then + echo "$key=$val" >> "$OVERRIDE_OUTPUT_FILE" + fi +} + +write_summary() { + [[ -n "${GITHUB_STEP_SUMMARY:-}" ]] || return 0 + echo "$1" >> "$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" + for override in "${OVERRIDES[@]}"; do + write_output "$override" "true" done exit 0 fi @@ -18,7 +42,7 @@ TEST_ALL=false; TEST_NATIVE=false; TEST_INTEGRATION=false; TEST_DOCS=false; TEST check_override() { local keyword="$1" local var_name="$2" - if printf '%s' "$PR_BODY" | grep -qF "$keyword"; then + if printf '%s' "${PR_BODY:-}" | grep -qF "$keyword"; then eval "$var_name=true" echo "Override $keyword found" fi @@ -37,17 +61,17 @@ 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" +write_output test_all "$TEST_ALL" +write_output test_native "$TEST_NATIVE" +write_output test_integration "$TEST_INTEGRATION" +write_output test_docs "$TEST_DOCS" +write_output test_format "$TEST_FORMAT" + +write_summary "## Override keywords" +write_summary "| Keyword | Active |" +write_summary "|---------|--------|" +write_summary "| [test_all] | $TEST_ALL |" +write_summary "| [test_native] | $TEST_NATIVE |" +write_summary "| [test_integration] | $TEST_INTEGRATION |" +write_summary "| [test_docs] | $TEST_DOCS |" +write_summary "| [test_format] | $TEST_FORMAT |" diff --git a/.github/scripts/classify-changes.sh b/.github/scripts/classify-changes.sh index 3f241f1c39..1d9523756c 100755 --- a/.github/scripts/classify-changes.sh +++ b/.github/scripts/classify-changes.sh @@ -2,29 +2,64 @@ 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 +# 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 +# (used by workflows that don't have a full checkout, +# e.g. pull_request_target commenting). +# CLASSIFY_OUTPUT_FILE - Optional path of a KEY=VALUE file to also write +# category results into (in addition to $GITHUB_OUTPUT). +# Outputs: writes category=true/false pairs to $GITHUB_OUTPUT (when set) and +# a summary table to $GITHUB_STEP_SUMMARY (when set). When CLASSIFY_OUTPUT_FILE +# is provided, also writes the same KEY=VALUE pairs there. -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" +CATEGORIES=(code docs ci format_config benchmark gifs mill_wrapper) + +write_output() { + local key="$1" + local val="$2" + if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "$key=$val" >> "$GITHUB_OUTPUT" + fi + if [[ -n "${CLASSIFY_OUTPUT_FILE:-}" ]]; then + echo "$key=$val" >> "$CLASSIFY_OUTPUT_FILE" + fi +} + +write_summary() { + [[ -n "${GITHUB_STEP_SUMMARY:-}" ]] || return 0 + echo "$1" >> "$GITHUB_STEP_SUMMARY" +} + +set_all_true_and_exit() { + local reason="$1" + echo "$reason, setting all categories to true" + for cat in "${CATEGORIES[@]}"; do + write_output "$cat" "true" done exit 0 +} + +if [[ "${EVENT_NAME:-}" != "pull_request" ]]; then + set_all_true_and_exit "Non-PR event (${EVENT_NAME:-unknown})" 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 +if [[ -n "${CHANGED_FILES_OVERRIDE:-}" ]]; then + CHANGED_FILES="$CHANGED_FILES_OVERRIDE" +else + 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" + set_all_true_and_exit "Diff computation failed" + fi fi CODE=false; DOCS=false; CI=false; FORMAT_CONFIG=false; BENCHMARK=false; GIFS=false; MILL_WRAPPER=false while IFS= read -r file; do + [[ -z "$file" ]] && continue case "$file" in modules/*|build.mill|project/*) CODE=true ;; website/*) DOCS=true ;; @@ -45,18 +80,18 @@ 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 +write_output code "$CODE" +write_output docs "$DOCS" +write_output ci "$CI" +write_output format_config "$FORMAT_CONFIG" +write_output benchmark "$BENCHMARK" +write_output gifs "$GIFS" +write_output mill_wrapper "$MILL_WRAPPER" + +write_summary "## Change categories" +write_summary "| Category | Changed |" +write_summary "|----------|---------|" +for cat in "${CATEGORIES[@]}"; do val=$(eval echo \$$( echo $cat | tr 'a-z' 'A-Z')) - echo "| $cat | $val |" >> "$GITHUB_STEP_SUMMARY" + write_summary "| $cat | $val |" done diff --git a/.github/scripts/fetch-pr-changed-files.sc b/.github/scripts/fetch-pr-changed-files.sc new file mode 100755 index 0000000000..2d86e7cf6c --- /dev/null +++ b/.github/scripts/fetch-pr-changed-files.sc @@ -0,0 +1,65 @@ +#!/usr/bin/env -S scala-cli shebang +//> using scala 3 +//> using toolkit default +//> using options -Werror -Wunused:all + +// Fetches the list of files changed in a pull request via the GitHub REST API +// (using the `gh` CLI, which is pre-installed on GitHub Actions runners). +// +// Inputs (env vars): +// REPO - "/" (required). +// PR_NUMBER - Pull-request number (required). +// GH_TOKEN - GitHub token; consumed transparently by `gh`. +// OUTPUT_NAME - Optional name of the step output to write the list under +// (default: "files"). The list is written as a multi-line +// output in $GITHUB_OUTPUT when that env var is set. +// +// Behavior: +// - Calls `gh api --paginate repos//pulls//files` and +// collects each `.filename`. +// - Echoes the list to stdout for log visibility. +// - When $GITHUB_OUTPUT is defined, appends a heredoc-style multi-line +// output (e.g. `files< + val normalized = + if page.startsWith("[") && page.endsWith("]") then page + else if page.startsWith("[") then page + "]" + else if page.endsWith("]") then "[" + page + else "[" + page + "]" + ujson.read(normalized).arr.flatMap(entry => entry.obj.get("filename").map(_.str)) + } + +println("Changed files:") +files.foreach(println) + +sys.env.get("GITHUB_OUTPUT").foreach { ghOutputPath => + val delimiter = s"EOF_${UUID.randomUUID().toString.replace("-", "")}" + val payload = (Seq(s"$outputKey<<$delimiter") ++ files :+ delimiter).mkString("\n") + "\n" + os.write.append(os.Path(ghOutputPath), payload) +} diff --git a/.github/scripts/resolve-ci-run-link.sc b/.github/scripts/resolve-ci-run-link.sc new file mode 100755 index 0000000000..1c10521e58 --- /dev/null +++ b/.github/scripts/resolve-ci-run-link.sc @@ -0,0 +1,100 @@ +#!/usr/bin/env -S scala-cli shebang +//> using scala 3 +//> using toolkit default +//> using options -Werror -Wunused:all + +// Finds the most recent GitHub Actions run of the "CI" workflow for a given +// head SHA, with a few retries to tolerate the race between this workflow +// (triggered by pull_request_target) and the main CI workflow (triggered by +// pull_request) both starting at roughly the same time. +// +// Inputs (env vars): +// REPO - "/" (required). +// HEAD_SHA - Head SHA of the PR to search runs for (required). +// SERVER_URL - GitHub server URL (e.g. https://github.com), used only +// for the fallback link (required). +// PR_NUMBER - Pull-request number, used only for the fallback link +// (required). +// GH_TOKEN - GitHub token; consumed transparently by `gh`. +// WORKFLOW_NAME - Workflow to look for (default: "CI"). +// MAX_ATTEMPTS - Retry budget (default: 4). +// RETRY_DELAY_MS - Delay between retries in milliseconds (default: 10000). +// RUN_ID_OUTPUT - Output key for the resolved run id (default: "run_id"). +// RUN_URL_OUTPUT - Output key for the resolved run URL (default: "run_url"). +// +// Behavior: +// - Polls `gh api repos//actions/runs?event=pull_request&head_sha=` +// up to MAX_ATTEMPTS times, picking the run with the highest run_number +// whose `name` matches WORKFLOW_NAME. +// - If no run is found after all attempts, falls back to the PR checks page +// URL (`//pull//checks`) with an empty run id. +// - Writes `=` and `=` to +// $GITHUB_OUTPUT when that env var is set. + +def envRequired(name: String): String = + sys.env.get(name).filter(_.nonEmpty).getOrElse: + System.err.println(s"::error::$name is required") + sys.exit(1) + +def envWithDefault(name: String, default: String): String = + sys.env.get(name).filter(_.nonEmpty).getOrElse(default) + +val repo = envRequired("REPO") +val headSha = envRequired("HEAD_SHA") +val serverUrl = envRequired("SERVER_URL") +val prNumber = envRequired("PR_NUMBER") +val workflowName = envWithDefault("WORKFLOW_NAME", "CI") +val maxAttempts = envWithDefault("MAX_ATTEMPTS", "4").toInt +val retryDelayMs = envWithDefault("RETRY_DELAY_MS", "10000").toLong +val runIdKey = envWithDefault("RUN_ID_OUTPUT", "run_id") +val runUrlKey = envWithDefault("RUN_URL_OUTPUT", "run_url") + +case class ResolvedRun(id: String, url: String) + +def queryLatestRun(): Option[ResolvedRun] = + val result = os + .proc("gh", "api", "-H", "Accept: application/vnd.github+json", + s"repos/$repo/actions/runs?event=pull_request&head_sha=$headSha&per_page=30") + .call(check = false) + if result.exitCode != 0 then + System.err.println(s"gh api failed (exit ${result.exitCode}): ${result.err.text()}") + None + else + val body = result.out.text().trim + if body.isEmpty then None + else + val runs = ujson.read(body).obj.get("workflow_runs").map(_.arr).getOrElse(Seq.empty) + runs + .filter(r => r.obj.get("name").map(_.str).contains(workflowName)) + .sortBy(r => r.obj.get("run_number").map(_.num.toLong).getOrElse(0L)) + .lastOption + .flatMap { run => + for + id <- run.obj.get("id").map(_.num.toLong.toString) + url <- run.obj.get("html_url").map(_.str) + if id.nonEmpty && url.nonEmpty + yield ResolvedRun(id, url) + } + +val resolved: ResolvedRun = { + var found: Option[ResolvedRun] = None + var attempt = 1 + while found.isEmpty && attempt <= maxAttempts do + found = queryLatestRun() + if found.isEmpty then + println(s"CI run not yet discoverable for $headSha (attempt $attempt); retrying...") + if attempt < maxAttempts then Thread.sleep(retryDelayMs) + attempt += 1 + found.getOrElse { + println("Falling back to PR checks page") + ResolvedRun(id = "", url = s"$serverUrl/$repo/pull/$prNumber/checks") + } +} + +println(s"Resolved run id: ${resolved.id}") +println(s"Resolved run url: ${resolved.url}") + +sys.env.get("GITHUB_OUTPUT").foreach { ghOutputPath => + val payload = s"$runIdKey=${resolved.id}\n$runUrlKey=${resolved.url}\n" + os.write.append(os.Path(ghOutputPath), payload) +} diff --git a/.github/workflows/pr-classify-comment.yml b/.github/workflows/pr-classify-comment.yml new file mode 100644 index 0000000000..4d195966d1 --- /dev/null +++ b/.github/workflows/pr-classify-comment.yml @@ -0,0 +1,85 @@ +name: PR Classification Comment + +# Runs on pull_request_target so the job has write permissions even for +# fork PRs (needed to post the comment). We deliberately do NOT check out +# the PR head; we only read metadata via `gh api`, so running with base-ref +# scripts is safe. +on: + pull_request_target: + types: [opened, synchronize, reopened, edited] + +concurrency: + group: pr-classify-comment-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + pull-requests: write + contents: read + actions: read + +jobs: + classify-and-comment: + if: github.event.pull_request != null + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Checkout base ref + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.base.sha }} + persist-credentials: false + - uses: coursier/cache-action@v8 + with: + ignoreJob: true + - uses: VirtusLab/scala-cli-setup@v1 + with: + jvm: "temurin:17" + - name: Fetch list of changed files + id: files + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: scala-cli run .github/scripts/fetch-pr-changed-files.sc + - name: Classify changes + env: + EVENT_NAME: pull_request + BASE_REF: ${{ github.event.pull_request.base.ref }} + CHANGED_FILES_OVERRIDE: ${{ steps.files.outputs.files }} + CLASSIFY_OUTPUT_FILE: ${{ runner.temp }}/classify.env + run: | + : > "$CLASSIFY_OUTPUT_FILE" + .github/scripts/classify-changes.sh + - name: Check override keywords + env: + EVENT_NAME: pull_request + PR_BODY: ${{ github.event.pull_request.body }} + OVERRIDE_OUTPUT_FILE: ${{ runner.temp }}/overrides.env + run: | + : > "$OVERRIDE_OUTPUT_FILE" + .github/scripts/check-override-keywords.sh + - name: Resolve CI run link + id: ci_run + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + SERVER_URL: ${{ github.server_url }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: scala-cli run .github/scripts/resolve-ci-run-link.sc + - name: Build comment body + env: + CLASSIFY_OUTPUT_FILE: ${{ runner.temp }}/classify.env + OVERRIDE_OUTPUT_FILE: ${{ runner.temp }}/overrides.env + COMMENT_OUTPUT_FILE: ${{ runner.temp }}/comment.md + CLASSIFY_RUN_ID: ${{ github.run_id }} + CLASSIFY_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + CI_RUN_ID: ${{ steps.ci_run.outputs.run_id }} + CI_RUN_URL: ${{ steps.ci_run.outputs.run_url }} + run: scala-cli run .github/scripts/build-pr-classification-comment.sc + - 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 }}/comment.md From c2f118ddaa34949ba871367c90128d3896fba79b Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Fri, 17 Apr 2026 14:26:37 +0200 Subject: [PATCH 2/3] Rewrite `changes` CI job scripts into Scala, extract commons with comment logic, refactor --- .../build-pr-classification-comment.sc | 297 ++++++------------ .github/scripts/check-override-keywords.sc | 59 ++++ .github/scripts/check-override-keywords.sh | 77 ----- .github/scripts/classify-changes.sc | 80 +++++ .github/scripts/classify-changes.sh | 97 ------ .github/scripts/fetch-pr-changed-files.sc | 59 ++-- .../scripts/pr-classify-lib/Category.scala | 35 +++ .github/scripts/pr-classify-lib/Env.scala | 31 ++ .../scripts/pr-classify-lib/EnvNames.scala | 61 ++++ .../pr-classify-lib/GitHubOutput.scala | 30 ++ .../pr-classify-lib/KeyValueFile.scala | 28 ++ .../scripts/pr-classify-lib/OverrideKey.scala | 22 ++ .github/scripts/pr-classify-lib/Signals.scala | 72 +++++ .../scripts/pr-classify-lib/SuiteGroup.scala | 21 ++ .github/scripts/resolve-ci-run-link.sc | 73 ++--- .github/workflows/ci.yml | 8 +- .github/workflows/pr-classify-comment.yml | 14 +- 17 files changed, 605 insertions(+), 459 deletions(-) create mode 100755 .github/scripts/check-override-keywords.sc delete mode 100755 .github/scripts/check-override-keywords.sh create mode 100755 .github/scripts/classify-changes.sc delete mode 100755 .github/scripts/classify-changes.sh create mode 100644 .github/scripts/pr-classify-lib/Category.scala create mode 100644 .github/scripts/pr-classify-lib/Env.scala create mode 100644 .github/scripts/pr-classify-lib/EnvNames.scala create mode 100644 .github/scripts/pr-classify-lib/GitHubOutput.scala create mode 100644 .github/scripts/pr-classify-lib/KeyValueFile.scala create mode 100644 .github/scripts/pr-classify-lib/OverrideKey.scala create mode 100644 .github/scripts/pr-classify-lib/Signals.scala create mode 100644 .github/scripts/pr-classify-lib/SuiteGroup.scala diff --git a/.github/scripts/build-pr-classification-comment.sc b/.github/scripts/build-pr-classification-comment.sc index 8d85bdaee0..993bea8df7 100755 --- a/.github/scripts/build-pr-classification-comment.sc +++ b/.github/scripts/build-pr-classification-comment.sc @@ -3,225 +3,118 @@ //> 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. +// `changes` job classified the diff and which suite groups will run / be +// skipped. // // Inputs (env vars): -// CLASSIFY_OUTPUT_FILE - KEY=VALUE file produced by classify-changes.sh. -// OVERRIDE_OUTPUT_FILE - KEY=VALUE file produced by check-override-keywords.sh. -// COMMENT_OUTPUT_FILE - Path to write the resulting Markdown comment to -// (default: ./comment.md). +// 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). // CLASSIFY_RUN_ID - Run ID of the classification workflow (optional). // CLASSIFY_RUN_URL - URL to the classification workflow run (optional). -// CI_RUN_ID - Run ID of the matching CI workflow run (optional; -// empty means "not yet resolved", link falls back). -// CI_RUN_URL - URL to the matching CI workflow run, or a fallback -// link (e.g. the PR's /checks page). - -import java.nio.file.Paths - -def envOpt(name: String): Option[String] = - sys.env.get(name).filter(_.nonEmpty) - -def envRequiredFile(name: String): os.Path = - val raw = envOpt(name).getOrElse: - System.err.println(s"::error::$name is missing") - sys.exit(1) - 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 toAbsolutePath(s: String): os.Path = - if Paths.get(s).isAbsolute then os.Path(s) else os.Path(s, os.pwd) - -def readKeyValueFile(path: os.Path): Map[String, String] = - 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 - -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") - -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") - -// Signals provided to suite-group expressions — keep the boolean predicates in -// sync with the SHOULD_RUN expressions used in .github/workflows/ci.yml. -case class Signals( - code: Boolean, - docs: Boolean, - ci: Boolean, - formatConfig: Boolean, - benchmark: Boolean, - gifs: Boolean, - millWrapper: Boolean, - testAll: Boolean, - testNative: Boolean, - testIntegration: Boolean, - testDocs: Boolean, - testFormat: Boolean -): - def withoutOverrides: Signals = copy( - testAll = false, - testNative = false, - testIntegration = false, - testDocs = false, - testFormat = false - ) - - def withOverride(key: OverrideKey, enabled: Boolean): Signals = key match - case OverrideKey.TestAll => copy(testAll = enabled) - case OverrideKey.TestNative => copy(testNative = enabled) - case OverrideKey.TestIntegration => copy(testIntegration = enabled) - case OverrideKey.TestDocs => copy(testDocs = enabled) - case OverrideKey.TestFormat => copy(testFormat = enabled) - - def shouldRun(group: SuiteGroup): Boolean = group match - case SuiteGroup.UnitAndMill => - code || ci || millWrapper || testAll - case SuiteGroup.JvmIntegration => - code || ci || testAll || testIntegration - case SuiteGroup.NativeIntegration => - code || ci || testAll || testNative - case SuiteGroup.DocsTests => - code || docs || ci || gifs || testAll || testDocs - case SuiteGroup.Checks => - code || docs || ci || formatConfig || testAll - case SuiteGroup.Format => - code || docs || ci || formatConfig || testAll || testFormat - case SuiteGroup.ReferenceDoc => - code || docs || ci || testAll - case SuiteGroup.BloopMemoryFootprint => - code || ci || benchmark || testAll - case SuiteGroup.SbtExportVcRedist => - code || ci || testAll - -object Signals: - def apply(categories: Map[String, String], overrides: Map[String, String]): Signals = - def boolAt(map: Map[String, String], key: String): Boolean = - map.get(key).exists(_.equalsIgnoreCase("true")) - Signals( - code = boolAt(categories, "code"), - docs = boolAt(categories, "docs"), - ci = boolAt(categories, "ci"), - formatConfig = boolAt(categories, "format_config"), - benchmark = boolAt(categories, "benchmark"), - gifs = boolAt(categories, "gifs"), - millWrapper = boolAt(categories, "mill_wrapper"), - testAll = boolAt(overrides, "test_all"), - testNative = boolAt(overrides, "test_native"), - testIntegration = boolAt(overrides, "test_integration"), - testDocs = boolAt(overrides, "test_docs"), - testFormat = boolAt(overrides, "test_format") - ) - -/** Which individual override keywords (among those currently active) actually - * add suite groups that wouldn't otherwise run. Used to list only the - * overrides that matter. - */ -extension (signals: Signals) - def isOverrideActive(key: OverrideKey): Boolean = key match - case OverrideKey.TestAll => signals.testAll - case OverrideKey.TestNative => signals.testNative - case OverrideKey.TestIntegration => signals.testIntegration - case OverrideKey.TestDocs => signals.testDocs - case OverrideKey.TestFormat => signals.testFormat +// CI_RUN_ID - Run ID of the matching CI workflow run (optional). +// CI_RUN_URL - URL to the matching CI workflow run, or a fallback. +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.filter(baseline.shouldRun).toSet - OverrideKey.values.toIndexedSeq.flatMap { key => - if !signals.isOverrideActive(key) then None + 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.values.toIndexedSeq - .filter(g => probe.shouldRun(g) && !baselineRuns.contains(g)) + 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, - classifyRunId: Option[String], - classifyRunUrl: Option[String], - ciRunId: Option[String], - ciRunUrl: Option[String] + signals: Signals, + classifyRunId: Option[String], + classifyRunUrl: Option[String], + ciRunId: Option[String], + ciRunUrl: Option[String] ): String = - val groups = SuiteGroup.values.toIndexedSeq - val (runGroups, skipGroups) = groups.partition(signals.shouldRun) - val contributions = overrideContributions(signals) - - val sb = StringBuilder() - sb ++= "### CI change classification\n\n" - sb ++= "**Change categories**\n\n" - sb ++= "| Category | Changed |\n" - sb ++= "| --- | --- |\n" - sb ++= s"| code | ${signals.code} |\n" - sb ++= s"| docs | ${signals.docs} |\n" - sb ++= s"| ci | ${signals.ci} |\n" - sb ++= s"| format_config | ${signals.formatConfig} |\n" - sb ++= s"| benchmark | ${signals.benchmark} |\n" - sb ++= s"| gifs | ${signals.gifs} |\n" - sb ++= s"| mill_wrapper | ${signals.millWrapper} |\n" - sb ++= "\n**Suite groups**\n\n" - sb ++= ( - if runGroups.isEmpty then "- Will run: _(none)_\n" - else s"- Will run: ${runGroups.map(_.label).mkString(", ")}\n" - ) - sb ++= ( - if skipGroups.isEmpty then "- Will be skipped: _(none)_\n" - else s"- Will be skipped: ${skipGroups.map(_.label).mkString(", ")}\n" - ) - - if contributions.nonEmpty then - sb ++= "\n**Override keywords affecting the run set**\n\n" - for (key, added) <- contributions do - sb ++= s"- `[${key.keyword}]` forces on: ${added.map(_.label).mkString(", ")}\n" - - ciRunId match - case Some(id) => - val url = ciRunUrl.getOrElse("") - sb ++= s"\nFull CI run: [#$id]($url)\n" - case None => - ciRunUrl.foreach { url => sb ++= s"\nFull CI run: $url\n" } - - classifyRunId.foreach { id => - val url = classifyRunUrl.getOrElse("") - sb ++= s"\n_Classified in run [#$id]($url)._\n" - } - - sb.result() - -val categories = readKeyValueFile(envRequiredFile("CLASSIFY_OUTPUT_FILE")) -val overrides = readKeyValueFile(envRequiredFile("OVERRIDE_OUTPUT_FILE")) -val signals = Signals(categories, overrides) - -val commentPath = toAbsolutePath(envOpt("COMMENT_OUTPUT_FILE").getOrElse("comment.md")) + 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 classifySection: Option[String] = classifyRunId.map: id => + s"_Classified in run [#$id](${classifyRunUrl.getOrElse("")})._" + + val sections = + Seq( + Some(headerSection), + Some(suitesSection), + overridesSection, + ciRunSection, + classifySection + ).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, - classifyRunId = envOpt("CLASSIFY_RUN_ID"), - classifyRunUrl = envOpt("CLASSIFY_RUN_URL"), - ciRunId = envOpt("CI_RUN_ID"), - ciRunUrl = envOpt("CI_RUN_URL") + classifyRunId = Env.opt(EnvNames.ClassifyRunId), + classifyRunUrl = Env.opt(EnvNames.ClassifyRunUrl), + ciRunId = Env.opt(EnvNames.CiRunId), + ciRunUrl = Env.opt(EnvNames.CiRunUrl) ) os.write.over(commentPath, body, createFolders = true) 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 411fd0b77e..0000000000 --- a/.github/scripts/check-override-keywords.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# 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: writes override=true/false pairs to $GITHUB_OUTPUT (when set) and -# a summary table to $GITHUB_STEP_SUMMARY (when set). When OVERRIDE_OUTPUT_FILE -# is provided, also writes the same KEY=VALUE pairs there. - -OVERRIDES=(test_all test_native test_integration test_docs test_format) - -write_output() { - local key="$1" - local val="$2" - if [[ -n "${GITHUB_OUTPUT:-}" ]]; then - echo "$key=$val" >> "$GITHUB_OUTPUT" - fi - if [[ -n "${OVERRIDE_OUTPUT_FILE:-}" ]]; then - echo "$key=$val" >> "$OVERRIDE_OUTPUT_FILE" - fi -} - -write_summary() { - [[ -n "${GITHUB_STEP_SUMMARY:-}" ]] || return 0 - echo "$1" >> "$GITHUB_STEP_SUMMARY" -} - -if [[ "${EVENT_NAME:-}" != "pull_request" ]]; then - echo "Non-PR event, setting all overrides to true" - for override in "${OVERRIDES[@]}"; do - write_output "$override" "true" - 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" - -write_output test_all "$TEST_ALL" -write_output test_native "$TEST_NATIVE" -write_output test_integration "$TEST_INTEGRATION" -write_output test_docs "$TEST_DOCS" -write_output test_format "$TEST_FORMAT" - -write_summary "## Override keywords" -write_summary "| Keyword | Active |" -write_summary "|---------|--------|" -write_summary "| [test_all] | $TEST_ALL |" -write_summary "| [test_native] | $TEST_NATIVE |" -write_summary "| [test_integration] | $TEST_INTEGRATION |" -write_summary "| [test_docs] | $TEST_DOCS |" -write_summary "| [test_format] | $TEST_FORMAT |" 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 1d9523756c..0000000000 --- a/.github/scripts/classify-changes.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# 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 -# (used by workflows that don't have a full checkout, -# e.g. pull_request_target commenting). -# CLASSIFY_OUTPUT_FILE - Optional path of a KEY=VALUE file to also write -# category results into (in addition to $GITHUB_OUTPUT). -# Outputs: writes category=true/false pairs to $GITHUB_OUTPUT (when set) and -# a summary table to $GITHUB_STEP_SUMMARY (when set). When CLASSIFY_OUTPUT_FILE -# is provided, also writes the same KEY=VALUE pairs there. - -CATEGORIES=(code docs ci format_config benchmark gifs mill_wrapper) - -write_output() { - local key="$1" - local val="$2" - if [[ -n "${GITHUB_OUTPUT:-}" ]]; then - echo "$key=$val" >> "$GITHUB_OUTPUT" - fi - if [[ -n "${CLASSIFY_OUTPUT_FILE:-}" ]]; then - echo "$key=$val" >> "$CLASSIFY_OUTPUT_FILE" - fi -} - -write_summary() { - [[ -n "${GITHUB_STEP_SUMMARY:-}" ]] || return 0 - echo "$1" >> "$GITHUB_STEP_SUMMARY" -} - -set_all_true_and_exit() { - local reason="$1" - echo "$reason, setting all categories to true" - for cat in "${CATEGORIES[@]}"; do - write_output "$cat" "true" - done - exit 0 -} - -if [[ "${EVENT_NAME:-}" != "pull_request" ]]; then - set_all_true_and_exit "Non-PR event (${EVENT_NAME:-unknown})" -fi - -if [[ -n "${CHANGED_FILES_OVERRIDE:-}" ]]; then - CHANGED_FILES="$CHANGED_FILES_OVERRIDE" -else - 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" - set_all_true_and_exit "Diff computation failed" - fi -fi - -CODE=false; DOCS=false; CI=false; FORMAT_CONFIG=false; BENCHMARK=false; GIFS=false; MILL_WRAPPER=false - -while IFS= read -r file; do - [[ -z "$file" ]] && continue - 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" - -write_output code "$CODE" -write_output docs "$DOCS" -write_output ci "$CI" -write_output format_config "$FORMAT_CONFIG" -write_output benchmark "$BENCHMARK" -write_output gifs "$GIFS" -write_output mill_wrapper "$MILL_WRAPPER" - -write_summary "## Change categories" -write_summary "| Category | Changed |" -write_summary "|----------|---------|" -for cat in "${CATEGORIES[@]}"; do - val=$(eval echo \$$( echo $cat | tr 'a-z' 'A-Z')) - write_summary "| $cat | $val |" -done diff --git a/.github/scripts/fetch-pr-changed-files.sc b/.github/scripts/fetch-pr-changed-files.sc index 2d86e7cf6c..9dcad40fc6 100755 --- a/.github/scripts/fetch-pr-changed-files.sc +++ b/.github/scripts/fetch-pr-changed-files.sc @@ -3,63 +3,60 @@ //> using toolkit default //> using options -Werror -Wunused:all -// Fetches the list of files changed in a pull request via the GitHub REST API -// (using the `gh` CLI, which is pre-installed on GitHub Actions runners). +//> using file ./pr-classify-lib/Env.scala +//> using file ./pr-classify-lib/EnvNames.scala +//> using file ./pr-classify-lib/GitHubOutput.scala + +// Fetches the list of files changed in a pull request via `gh api`. The `gh` +// CLI is pre-installed on GitHub Actions runners and will pick up the token +// from $GH_TOKEN. // // Inputs (env vars): // REPO - "/" (required). // PR_NUMBER - Pull-request number (required). // GH_TOKEN - GitHub token; consumed transparently by `gh`. -// OUTPUT_NAME - Optional name of the step output to write the list under -// (default: "files"). The list is written as a multi-line -// output in $GITHUB_OUTPUT when that env var is set. +// OUTPUT_NAME - Optional $GITHUB_OUTPUT key to use (default: "files"). // -// Behavior: -// - Calls `gh api --paginate repos//pulls//files` and -// collects each `.filename`. -// - Echoes the list to stdout for log visibility. -// - When $GITHUB_OUTPUT is defined, appends a heredoc-style multi-line -// output (e.g. `files<< + .flatMap: page => val normalized = if page.startsWith("[") && page.endsWith("]") then page else if page.startsWith("[") then page + "]" else if page.endsWith("]") then "[" + page else "[" + page + "]" ujson.read(normalized).arr.flatMap(entry => entry.obj.get("filename").map(_.str)) - } println("Changed files:") files.foreach(println) -sys.env.get("GITHUB_OUTPUT").foreach { ghOutputPath => - val delimiter = s"EOF_${UUID.randomUUID().toString.replace("-", "")}" - val payload = (Seq(s"$outputKey<<$delimiter") ++ files :+ delimiter).mkString("\n") + "\n" - os.write.append(os.Path(ghOutputPath), payload) -} +GitHubOutput.writeMultiline(outputKey, files) 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..20c2a77360 --- /dev/null +++ b/.github/scripts/pr-classify-lib/EnvNames.scala @@ -0,0 +1,61 @@ +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 ---- + /** "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" + + /** "owner/repo". */ + val Repo = "REPO" + val PrNumber = "PR_NUMBER" + val HeadSha = "HEAD_SHA" + + /** e.g. "https://github.com". */ + val ServerUrl = "SERVER_URL" + + // ---- Outputs (file-based) ---- + /** 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" + + // ---- Run-link context ---- + val ClassifyRunId = "CLASSIFY_RUN_ID" + val ClassifyRunUrl = "CLASSIFY_RUN_URL" + val CiRunId = "CI_RUN_ID" + val CiRunUrl = "CI_RUN_URL" + + // ---- Script tunables ---- + /** Name of the workflow to search for when resolving the CI run link. */ + val WorkflowName = "WORKFLOW_NAME" + val MaxAttempts = "MAX_ATTEMPTS" + val RetryDelayMs = "RETRY_DELAY_MS" + val RunIdOutput = "RUN_ID_OUTPUT" + val RunUrlOutput = "RUN_URL_OUTPUT" + + /** Name of the GITHUB_OUTPUT key `fetch-pr-changed-files` writes to. */ + val OutputName = "OUTPUT_NAME" + + // ---- 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/resolve-ci-run-link.sc b/.github/scripts/resolve-ci-run-link.sc index 1c10521e58..a0b02e509a 100755 --- a/.github/scripts/resolve-ci-run-link.sc +++ b/.github/scripts/resolve-ci-run-link.sc @@ -3,6 +3,10 @@ //> 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/GitHubOutput.scala + // Finds the most recent GitHub Actions run of the "CI" workflow for a given // head SHA, with a few retries to tolerate the race between this workflow // (triggered by pull_request_target) and the main CI workflow (triggered by @@ -11,10 +15,8 @@ // Inputs (env vars): // REPO - "/" (required). // HEAD_SHA - Head SHA of the PR to search runs for (required). -// SERVER_URL - GitHub server URL (e.g. https://github.com), used only -// for the fallback link (required). -// PR_NUMBER - Pull-request number, used only for the fallback link -// (required). +// SERVER_URL - GitHub server URL (required; only used for fallback link). +// PR_NUMBER - Pull-request number (required; only used for fallback link). // GH_TOKEN - GitHub token; consumed transparently by `gh`. // WORKFLOW_NAME - Workflow to look for (default: "CI"). // MAX_ATTEMPTS - Retry budget (default: 4). @@ -22,39 +24,33 @@ // RUN_ID_OUTPUT - Output key for the resolved run id (default: "run_id"). // RUN_URL_OUTPUT - Output key for the resolved run URL (default: "run_url"). // -// Behavior: -// - Polls `gh api repos//actions/runs?event=pull_request&head_sha=` -// up to MAX_ATTEMPTS times, picking the run with the highest run_number -// whose `name` matches WORKFLOW_NAME. -// - If no run is found after all attempts, falls back to the PR checks page -// URL (`//pull//checks`) with an empty run id. -// - Writes `=` and `=` to -// $GITHUB_OUTPUT when that env var is set. - -def envRequired(name: String): String = - sys.env.get(name).filter(_.nonEmpty).getOrElse: - System.err.println(s"::error::$name is required") - sys.exit(1) +// Outputs: +// - `=` and `=` to $GITHUB_OUTPUT, +// with an empty id and the PR checks-page URL on fallback. -def envWithDefault(name: String, default: String): String = - sys.env.get(name).filter(_.nonEmpty).getOrElse(default) +import prclassify.* -val repo = envRequired("REPO") -val headSha = envRequired("HEAD_SHA") -val serverUrl = envRequired("SERVER_URL") -val prNumber = envRequired("PR_NUMBER") -val workflowName = envWithDefault("WORKFLOW_NAME", "CI") -val maxAttempts = envWithDefault("MAX_ATTEMPTS", "4").toInt -val retryDelayMs = envWithDefault("RETRY_DELAY_MS", "10000").toLong -val runIdKey = envWithDefault("RUN_ID_OUTPUT", "run_id") -val runUrlKey = envWithDefault("RUN_URL_OUTPUT", "run_url") +val repo = Env.required(EnvNames.Repo) +val headSha = Env.required(EnvNames.HeadSha) +val serverUrl = Env.required(EnvNames.ServerUrl) +val prNumber = Env.required(EnvNames.PrNumber) +val workflowName = Env.withDefault(EnvNames.WorkflowName, "CI") +val maxAttempts = Env.withDefault(EnvNames.MaxAttempts, "4").toInt +val retryDelayMs = Env.withDefault(EnvNames.RetryDelayMs, "10000").toLong +val runIdKey = Env.withDefault(EnvNames.RunIdOutput, "run_id") +val runUrlKey = Env.withDefault(EnvNames.RunUrlOutput, "run_url") case class ResolvedRun(id: String, url: String) def queryLatestRun(): Option[ResolvedRun] = val result = os - .proc("gh", "api", "-H", "Accept: application/vnd.github+json", - s"repos/$repo/actions/runs?event=pull_request&head_sha=$headSha&per_page=30") + .proc( + "gh", + "api", + "-H", + "Accept: application/vnd.github+json", + s"repos/$repo/actions/runs?event=pull_request&head_sha=$headSha&per_page=30" + ) .call(check = false) if result.exitCode != 0 then System.err.println(s"gh api failed (exit ${result.exitCode}): ${result.err.text()}") @@ -68,33 +64,28 @@ def queryLatestRun(): Option[ResolvedRun] = .filter(r => r.obj.get("name").map(_.str).contains(workflowName)) .sortBy(r => r.obj.get("run_number").map(_.num.toLong).getOrElse(0L)) .lastOption - .flatMap { run => + .flatMap: run => for id <- run.obj.get("id").map(_.num.toLong.toString) url <- run.obj.get("html_url").map(_.str) if id.nonEmpty && url.nonEmpty yield ResolvedRun(id, url) - } -val resolved: ResolvedRun = { +val resolved: ResolvedRun = var found: Option[ResolvedRun] = None - var attempt = 1 + var attempt = 1 while found.isEmpty && attempt <= maxAttempts do found = queryLatestRun() if found.isEmpty then println(s"CI run not yet discoverable for $headSha (attempt $attempt); retrying...") if attempt < maxAttempts then Thread.sleep(retryDelayMs) attempt += 1 - found.getOrElse { + found.getOrElse: println("Falling back to PR checks page") ResolvedRun(id = "", url = s"$serverUrl/$repo/pull/$prNumber/checks") - } -} println(s"Resolved run id: ${resolved.id}") println(s"Resolved run url: ${resolved.url}") -sys.env.get("GITHUB_OUTPUT").foreach { ghOutputPath => - val payload = s"$runIdKey=${resolved.id}\n$runUrlKey=${resolved.url}\n" - os.write.append(os.Path(ghOutputPath), payload) -} +GitHubOutput.writeScalar(runIdKey, resolved.id) +GitHubOutput.writeScalar(runUrlKey, resolved.url) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6e26a5489..cd12c80213 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,18 +33,22 @@ 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 + 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 + run: .github/scripts/check-override-keywords.sc unit-tests: needs: [changes] diff --git a/.github/workflows/pr-classify-comment.yml b/.github/workflows/pr-classify-comment.yml index 4d195966d1..75ac03780f 100644 --- a/.github/workflows/pr-classify-comment.yml +++ b/.github/workflows/pr-classify-comment.yml @@ -40,24 +40,20 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} REPO: ${{ github.repository }} - run: scala-cli run .github/scripts/fetch-pr-changed-files.sc + run: .github/scripts/fetch-pr-changed-files.sc - name: Classify changes env: EVENT_NAME: pull_request BASE_REF: ${{ github.event.pull_request.base.ref }} CHANGED_FILES_OVERRIDE: ${{ steps.files.outputs.files }} CLASSIFY_OUTPUT_FILE: ${{ runner.temp }}/classify.env - run: | - : > "$CLASSIFY_OUTPUT_FILE" - .github/scripts/classify-changes.sh + run: .github/scripts/classify-changes.sc - name: Check override keywords env: EVENT_NAME: pull_request PR_BODY: ${{ github.event.pull_request.body }} OVERRIDE_OUTPUT_FILE: ${{ runner.temp }}/overrides.env - run: | - : > "$OVERRIDE_OUTPUT_FILE" - .github/scripts/check-override-keywords.sh + run: .github/scripts/check-override-keywords.sc - name: Resolve CI run link id: ci_run env: @@ -66,7 +62,7 @@ jobs: HEAD_SHA: ${{ github.event.pull_request.head.sha }} SERVER_URL: ${{ github.server_url }} PR_NUMBER: ${{ github.event.pull_request.number }} - run: scala-cli run .github/scripts/resolve-ci-run-link.sc + run: .github/scripts/resolve-ci-run-link.sc - name: Build comment body env: CLASSIFY_OUTPUT_FILE: ${{ runner.temp }}/classify.env @@ -76,7 +72,7 @@ jobs: CLASSIFY_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} CI_RUN_ID: ${{ steps.ci_run.outputs.run_id }} CI_RUN_URL: ${{ steps.ci_run.outputs.run_url }} - run: scala-cli run .github/scripts/build-pr-classification-comment.sc + run: .github/scripts/build-pr-classification-comment.sc - name: Post / update PR comment uses: marocchino/sticky-pull-request-comment@v2 with: From b06eae4015783b1cc9fd328941a75e167908051c Mon Sep 17 00:00:00 2001 From: Piotr Chabelski Date: Fri, 17 Apr 2026 14:58:17 +0200 Subject: [PATCH 3/3] Produce the comment text within the main PR CI workflow to ensure comment and CI always match --- .../build-pr-classification-comment.sc | 32 +++---- .github/scripts/fetch-pr-changed-files.sc | 62 ------------- .../scripts/pr-classify-lib/EnvNames.scala | 31 +------ .github/scripts/resolve-ci-run-link.sc | 91 ------------------- .github/scripts/wait-for-ci-changes-job.sh | 63 +++++++++++++ .github/workflows/ci.yml | 22 +++++ .github/workflows/pr-classify-comment.yml | 86 +++++++----------- 7 files changed, 138 insertions(+), 249 deletions(-) delete mode 100755 .github/scripts/fetch-pr-changed-files.sc delete mode 100755 .github/scripts/resolve-ci-run-link.sc create mode 100755 .github/scripts/wait-for-ci-changes-job.sh diff --git a/.github/scripts/build-pr-classification-comment.sc b/.github/scripts/build-pr-classification-comment.sc index 993bea8df7..add43e7cd7 100755 --- a/.github/scripts/build-pr-classification-comment.sc +++ b/.github/scripts/build-pr-classification-comment.sc @@ -13,16 +13,17 @@ // 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. +// 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). -// CLASSIFY_RUN_ID - Run ID of the classification workflow (optional). -// CLASSIFY_RUN_URL - URL to the classification workflow run (optional). -// CI_RUN_ID - Run ID of the matching CI workflow run (optional). -// CI_RUN_URL - URL to the matching CI workflow run, or a fallback. +// CI_RUN_ID - Run ID of the CI workflow run (optional). +// CI_RUN_URL - URL to the CI workflow run (optional). import prclassify.* @@ -39,8 +40,6 @@ def overrideContributions(signals: Signals): Seq[(OverrideKey, Seq[SuiteGroup])] def renderComment( signals: Signals, - classifyRunId: Option[String], - classifyRunUrl: Option[String], ciRunId: Option[String], ciRunUrl: Option[String] ): String = @@ -87,17 +86,8 @@ def renderComment( case Some(id) => Some(s"Full CI run: [#$id](${ciRunUrl.getOrElse("")})") case None => ciRunUrl.map(url => s"Full CI run: $url") - val classifySection: Option[String] = classifyRunId.map: id => - s"_Classified in run [#$id](${classifyRunUrl.getOrElse("")})._" - val sections = - Seq( - Some(headerSection), - Some(suitesSection), - overridesSection, - ciRunSection, - classifySection - ).flatten + Seq(Some(headerSection), Some(suitesSection), overridesSection, ciRunSection).flatten sections.mkString("\n\n") + "\n" @@ -111,11 +101,15 @@ val commentPath = Env.toAbsolutePath( val body = renderComment( signals, - classifyRunId = Env.opt(EnvNames.ClassifyRunId), - classifyRunUrl = Env.opt(EnvNames.ClassifyRunUrl), 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/fetch-pr-changed-files.sc b/.github/scripts/fetch-pr-changed-files.sc deleted file mode 100755 index 9dcad40fc6..0000000000 --- a/.github/scripts/fetch-pr-changed-files.sc +++ /dev/null @@ -1,62 +0,0 @@ -#!/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/GitHubOutput.scala - -// Fetches the list of files changed in a pull request via `gh api`. The `gh` -// CLI is pre-installed on GitHub Actions runners and will pick up the token -// from $GH_TOKEN. -// -// Inputs (env vars): -// REPO - "/" (required). -// PR_NUMBER - Pull-request number (required). -// GH_TOKEN - GitHub token; consumed transparently by `gh`. -// OUTPUT_NAME - Optional $GITHUB_OUTPUT key to use (default: "files"). -// -// Outputs: -// - Echoes the file list to stdout for log visibility. -// - Writes a multi-line output (`< - val normalized = - if page.startsWith("[") && page.endsWith("]") then page - else if page.startsWith("[") then page + "]" - else if page.endsWith("]") then "[" + page - else "[" + page + "]" - ujson.read(normalized).arr.flatMap(entry => entry.obj.get("filename").map(_.str)) - -println("Changed files:") -files.foreach(println) - -GitHubOutput.writeMultiline(outputKey, files) diff --git a/.github/scripts/pr-classify-lib/EnvNames.scala b/.github/scripts/pr-classify-lib/EnvNames.scala index 20c2a77360..b503cab5d2 100644 --- a/.github/scripts/pr-classify-lib/EnvNames.scala +++ b/.github/scripts/pr-classify-lib/EnvNames.scala @@ -5,7 +5,7 @@ package prclassify */ object EnvNames: - // ---- Inputs ---- + // ---- Inputs to classify-changes.sc / check-override-keywords.sc ---- /** "pull_request", "push", ... */ val EventName = "EVENT_NAME" @@ -20,15 +20,7 @@ object EnvNames: /** Body of the pull request (scanned for override markers). */ val PrBody = "PR_BODY" - /** "owner/repo". */ - val Repo = "REPO" - val PrNumber = "PR_NUMBER" - val HeadSha = "HEAD_SHA" - - /** e.g. "https://github.com". */ - val ServerUrl = "SERVER_URL" - - // ---- Outputs (file-based) ---- + // ---- KEY=VALUE / comment artifact paths ---- /** Path where `classify-changes` writes its KEY=VALUE output. */ val ClassifyOutputFile = "CLASSIFY_OUTPUT_FILE" @@ -39,22 +31,9 @@ object EnvNames: */ val CommentOutputFile = "COMMENT_OUTPUT_FILE" - // ---- Run-link context ---- - val ClassifyRunId = "CLASSIFY_RUN_ID" - val ClassifyRunUrl = "CLASSIFY_RUN_URL" - val CiRunId = "CI_RUN_ID" - val CiRunUrl = "CI_RUN_URL" - - // ---- Script tunables ---- - /** Name of the workflow to search for when resolving the CI run link. */ - val WorkflowName = "WORKFLOW_NAME" - val MaxAttempts = "MAX_ATTEMPTS" - val RetryDelayMs = "RETRY_DELAY_MS" - val RunIdOutput = "RUN_ID_OUTPUT" - val RunUrlOutput = "RUN_URL_OUTPUT" - - /** Name of the GITHUB_OUTPUT key `fetch-pr-changed-files` writes to. */ - val OutputName = "OUTPUT_NAME" + // ---- 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" diff --git a/.github/scripts/resolve-ci-run-link.sc b/.github/scripts/resolve-ci-run-link.sc deleted file mode 100755 index a0b02e509a..0000000000 --- a/.github/scripts/resolve-ci-run-link.sc +++ /dev/null @@ -1,91 +0,0 @@ -#!/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/GitHubOutput.scala - -// Finds the most recent GitHub Actions run of the "CI" workflow for a given -// head SHA, with a few retries to tolerate the race between this workflow -// (triggered by pull_request_target) and the main CI workflow (triggered by -// pull_request) both starting at roughly the same time. -// -// Inputs (env vars): -// REPO - "/" (required). -// HEAD_SHA - Head SHA of the PR to search runs for (required). -// SERVER_URL - GitHub server URL (required; only used for fallback link). -// PR_NUMBER - Pull-request number (required; only used for fallback link). -// GH_TOKEN - GitHub token; consumed transparently by `gh`. -// WORKFLOW_NAME - Workflow to look for (default: "CI"). -// MAX_ATTEMPTS - Retry budget (default: 4). -// RETRY_DELAY_MS - Delay between retries in milliseconds (default: 10000). -// RUN_ID_OUTPUT - Output key for the resolved run id (default: "run_id"). -// RUN_URL_OUTPUT - Output key for the resolved run URL (default: "run_url"). -// -// Outputs: -// - `=` and `=` to $GITHUB_OUTPUT, -// with an empty id and the PR checks-page URL on fallback. - -import prclassify.* - -val repo = Env.required(EnvNames.Repo) -val headSha = Env.required(EnvNames.HeadSha) -val serverUrl = Env.required(EnvNames.ServerUrl) -val prNumber = Env.required(EnvNames.PrNumber) -val workflowName = Env.withDefault(EnvNames.WorkflowName, "CI") -val maxAttempts = Env.withDefault(EnvNames.MaxAttempts, "4").toInt -val retryDelayMs = Env.withDefault(EnvNames.RetryDelayMs, "10000").toLong -val runIdKey = Env.withDefault(EnvNames.RunIdOutput, "run_id") -val runUrlKey = Env.withDefault(EnvNames.RunUrlOutput, "run_url") - -case class ResolvedRun(id: String, url: String) - -def queryLatestRun(): Option[ResolvedRun] = - val result = os - .proc( - "gh", - "api", - "-H", - "Accept: application/vnd.github+json", - s"repos/$repo/actions/runs?event=pull_request&head_sha=$headSha&per_page=30" - ) - .call(check = false) - if result.exitCode != 0 then - System.err.println(s"gh api failed (exit ${result.exitCode}): ${result.err.text()}") - None - else - val body = result.out.text().trim - if body.isEmpty then None - else - val runs = ujson.read(body).obj.get("workflow_runs").map(_.arr).getOrElse(Seq.empty) - runs - .filter(r => r.obj.get("name").map(_.str).contains(workflowName)) - .sortBy(r => r.obj.get("run_number").map(_.num.toLong).getOrElse(0L)) - .lastOption - .flatMap: run => - for - id <- run.obj.get("id").map(_.num.toLong.toString) - url <- run.obj.get("html_url").map(_.str) - if id.nonEmpty && url.nonEmpty - yield ResolvedRun(id, url) - -val resolved: ResolvedRun = - var found: Option[ResolvedRun] = None - var attempt = 1 - while found.isEmpty && attempt <= maxAttempts do - found = queryLatestRun() - if found.isEmpty then - println(s"CI run not yet discoverable for $headSha (attempt $attempt); retrying...") - if attempt < maxAttempts then Thread.sleep(retryDelayMs) - attempt += 1 - found.getOrElse: - println("Falling back to PR checks page") - ResolvedRun(id = "", url = s"$serverUrl/$repo/pull/$prNumber/checks") - -println(s"Resolved run id: ${resolved.id}") -println(s"Resolved run url: ${resolved.url}") - -GitHubOutput.writeScalar(runIdKey, resolved.id) -GitHubOutput.writeScalar(runUrlKey, resolved.url) 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 cd12c80213..5adf93ec88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,13 +42,35 @@ jobs: env: EVENT_NAME: ${{ github.event_name }} BASE_REF: ${{ github.event.pull_request.base.ref }} + 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 }} + 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 index 75ac03780f..ff8e392a31 100644 --- a/.github/workflows/pr-classify-comment.yml +++ b/.github/workflows/pr-classify-comment.yml @@ -1,12 +1,21 @@ name: PR Classification Comment -# Runs on pull_request_target so the job has write permissions even for -# fork PRs (needed to post the comment). We deliberately do NOT check out -# the PR head; we only read metadata via `gh api`, so running with base-ref -# scripts is safe. +# 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, edited] + types: [opened, synchronize, reopened] concurrency: group: pr-classify-comment-${{ github.event.pull_request.number }} @@ -18,64 +27,39 @@ permissions: actions: read jobs: - classify-and-comment: + post-comment: if: github.event.pull_request != null runs-on: ubuntu-24.04 - timeout-minutes: 5 + timeout-minutes: 15 steps: - - name: Checkout base ref + - name: Checkout base ref (for helper scripts) uses: actions/checkout@v6 with: - ref: ${{ github.event.pull_request.base.sha }} + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: false persist-credentials: false - - uses: coursier/cache-action@v8 - with: - ignoreJob: true - - uses: VirtusLab/scala-cli-setup@v1 - with: - jvm: "temurin:17" - - name: Fetch list of changed files - id: files - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number }} - REPO: ${{ github.repository }} - run: .github/scripts/fetch-pr-changed-files.sc - - name: Classify changes - env: - EVENT_NAME: pull_request - BASE_REF: ${{ github.event.pull_request.base.ref }} - CHANGED_FILES_OVERRIDE: ${{ steps.files.outputs.files }} - CLASSIFY_OUTPUT_FILE: ${{ runner.temp }}/classify.env - run: .github/scripts/classify-changes.sc - - name: Check override keywords - env: - EVENT_NAME: pull_request - PR_BODY: ${{ github.event.pull_request.body }} - OVERRIDE_OUTPUT_FILE: ${{ runner.temp }}/overrides.env - run: .github/scripts/check-override-keywords.sc - - name: Resolve CI run link - id: ci_run + + - 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 }} - SERVER_URL: ${{ github.server_url }} - PR_NUMBER: ${{ github.event.pull_request.number }} - run: .github/scripts/resolve-ci-run-link.sc - - name: Build comment body - env: - CLASSIFY_OUTPUT_FILE: ${{ runner.temp }}/classify.env - OVERRIDE_OUTPUT_FILE: ${{ runner.temp }}/overrides.env - COMMENT_OUTPUT_FILE: ${{ runner.temp }}/comment.md - CLASSIFY_RUN_ID: ${{ github.run_id }} - CLASSIFY_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - CI_RUN_ID: ${{ steps.ci_run.outputs.run_id }} - CI_RUN_URL: ${{ steps.ci_run.outputs.run_url }} - run: .github/scripts/build-pr-classification-comment.sc + 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 }}/comment.md + path: ${{ runner.temp }}/pr-classification-comment.md