From e456ff4e4e73c723f6c84a44e0b9108df14d317f Mon Sep 17 00:00:00 2001 From: ChrisRackauckas-Claude Date: Fri, 5 Jun 2026 05:01:16 -0400 Subject: [PATCH] ci: self-test this repo (actionlint + shellcheck + script tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These reusable workflows + compute_affected_sublibraries.jl ship to the whole org via @v1, but nothing validated a change to them. Add CI: - actionlint (rhysd/actionlint image, which bundles shellcheck) lints every workflow and the shell in their run: steps. - A Julia test suite for compute_affected_sublibraries.jl with fixture lib/ trees, asserting: internal-only dependency graph, transitive reverse-deps, direct vs downstream(→v1), test-only changes don't propagate, Project.toml does, non-lib changes select nothing, test_groups.toml (custom group + GPU runner + timeout + local_only-skipped-when-downstream), EXCLUDES, and end-to-end --projects-matrix JSON output. Guard main() behind `PROGRAM_FILE == @__FILE__` so the script is includable from tests without running. Also fix the shellcheck findings the new lint surfaced in format-check.yml / format-suggestions-on-pr.yml (quote $GITHUB_OUTPUT; drop useless `echo "$(...)"`). Verified locally: actionlint+shellcheck exit 0; 37/37 tests pass. Co-Authored-By: Chris Rackauckas Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 43 +++++ .github/workflows/format-check.yml | 6 +- .../workflows/format-suggestions-on-pr.yml | 3 +- scripts/compute_affected_sublibraries.jl | 6 +- test/runtests.jl | 172 ++++++++++++++++++ 5 files changed, 224 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 test/runtests.jl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a9e78ee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,43 @@ +name: "CI (self-test)" + +# CI for this repo's own contents: lint the reusable workflows (and the shell +# in their `run:` steps via shellcheck) and run the test suite for the +# sublibrary affected-set detection script. These workflows ship to ~all of +# SciML via `@v1`, so a regression here breaks every consumer on the next tag. + +on: + pull_request: + branches: + - master + push: + branches: + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ startsWith(github.ref, 'refs/pull/') }} + +jobs: + actionlint: + name: "actionlint + shellcheck" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + # The rhysd/actionlint image bundles shellcheck, so `run:` scripts in the + # workflows are shellcheck-linted too. + - name: "Lint workflows" + uses: docker://rhysd/actionlint:1.7.12 + with: + args: "-color" + + script-tests: + name: "compute_affected_sublibraries.jl tests" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: julia-actions/setup-julia@v3 + with: + version: "1" + - uses: julia-actions/cache@v3 + - name: "Run detection-script tests" + run: julia --color=yes test/runtests.jl diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml index 3d969bb..d09aac0 100644 --- a/.github/workflows/format-check.yml +++ b/.github/workflows/format-check.yml @@ -69,16 +69,16 @@ jobs: MODIFIED_FILES="$(git diff --name-only)" if [ -z "$MODIFIED_FILES" ]; then - echo "formatted=true" >> $GITHUB_OUTPUT + echo "formatted=true" >> "$GITHUB_OUTPUT" else echo "Format check failed. Please format the following file(s) with JuliaFormatter v${{ inputs.juliaformatter-version }}." echo "$MODIFIED_FILES" { echo "formatting-changes<> $GITHUB_OUTPUT + } >> "$GITHUB_OUTPUT" exit 1 fi diff --git a/.github/workflows/format-suggestions-on-pr.yml b/.github/workflows/format-suggestions-on-pr.yml index e6d57bb..499768a 100644 --- a/.github/workflows/format-suggestions-on-pr.yml +++ b/.github/workflows/format-suggestions-on-pr.yml @@ -50,10 +50,9 @@ jobs: - name: "Apply formatting changes" run: | - echo "$(cat <<'END_FORMATTING_DIFF' + cat <<'END_FORMATTING_DIFF' | git apply ${{ needs.check-formatting.outputs.format-diff-patch }} END_FORMATTING_DIFF - )" | git apply - uses: reviewdog/action-suggester@v1 with: diff --git a/scripts/compute_affected_sublibraries.jl b/scripts/compute_affected_sublibraries.jl index f8bf715..29efbf0 100644 --- a/scripts/compute_affected_sublibraries.jl +++ b/scripts/compute_affected_sublibraries.jl @@ -347,4 +347,8 @@ function main() return print_json(matrix) end -main() +# Only run when executed as a script; `include`-ing the file (e.g. from the +# test suite) gets the functions without invoking main(). +if abspath(PROGRAM_FILE) == @__FILE__ + main() +end diff --git a/test/runtests.jl b/test/runtests.jl new file mode 100644 index 0000000..a6824cd --- /dev/null +++ b/test/runtests.jl @@ -0,0 +1,172 @@ +using Test + +# Load the detection script's functions without running main(). +const SCRIPT = joinpath(@__DIR__, "..", "scripts", "compute_affected_sublibraries.jl") +include(SCRIPT) + +# Build a fixture monorepo `lib/` tree in a temp dir. +# A (base, no internal deps) +# B deps A +# C deps B -> transitively depends on A +# D independent +# Reverse-dep closure: A -> {B, C}, B -> {C}, C -> {}, D -> {}. +function make_fixture(; c_groups::Union{Nothing, String} = nothing) + root = mktempdir() + lib = joinpath(root, "lib") + function pkg(name, deps; groups = nothing) + d = joinpath(lib, name) + mkpath(joinpath(d, "src")) + mkpath(joinpath(d, "test")) + depblock = isempty(deps) ? "" : "[deps]\n" * join(["$dep = \"$(lpad(i, 4, '0'))\"" for (i, dep) in enumerate(deps)], "\n") * "\n" + write(joinpath(d, "Project.toml"), "name = \"$name\"\nuuid = \"00000000-0000-0000-0000-0000000000$(lpad(hash(name) % 100, 2, '0'))\"\n$depblock") + write(joinpath(d, "src", "$name.jl"), "module $name\nend\n") + write(joinpath(d, "test", "runtests.jl"), "using Test\n") + groups === nothing || write(joinpath(d, "test", "test_groups.toml"), groups) + end + pkg("A", String[]) + pkg("B", ["A"]) + pkg("C", ["B"]; groups = c_groups) + pkg("D", String[]) + return root +end + +@testset "compute_affected_sublibraries" begin + root = make_fixture() + lib = joinpath(root, "lib") + graph = build_dependency_graph(lib) + rev = compute_reverse_deps(graph) + + @testset "dependency graph (only internal [deps])" begin + @test Set(graph["A"]) == Set(String[]) + @test Set(graph["B"]) == Set(["A"]) + @test Set(graph["C"]) == Set(["B"]) + @test Set(graph["D"]) == Set(String[]) + end + + @testset "transitive reverse deps" begin + @test rev["A"] == Set(["B", "C"]) + @test rev["B"] == Set(["C"]) + @test rev["C"] == Set(String[]) + @test rev["D"] == Set(String[]) + end + + @testset "affected: direct + transitive on src change" begin + direct, trans = compute_affected(["lib/A/src/A.jl"], graph, rev) + @test direct == Set(["A"]) + @test trans == Set(["B", "C"]) + end + + @testset "affected: test-only change does NOT propagate" begin + direct, trans = compute_affected(["lib/A/test/runtests.jl"], graph, rev) + @test direct == Set(["A"]) + @test trans == Set(String[]) + end + + @testset "affected: Project.toml change propagates" begin + direct, trans = compute_affected(["lib/A/Project.toml"], graph, rev) + @test direct == Set(["A"]) + @test trans == Set(["B", "C"]) + end + + @testset "affected: leaf change only itself" begin + direct, trans = compute_affected(["lib/D/src/D.jl"], graph, rev) + @test direct == Set(["D"]) + @test trans == Set(String[]) + end + + @testset "affected: non-lib change selects nothing" begin + direct, trans = compute_affected(["README.md", "docs/src/index.md"], graph, rev) + @test isempty(direct) && isempty(trans) + end + + @testset "projects-matrix: default groups + downstream→v1" begin + direct, trans = compute_affected(["lib/A/src/A.jl"], graph, rev) + m = build_projects_matrix(direct, trans, lib) + # A is directly changed: default Core on lts,1.11,1,pre + QA on 1 + a = filter(e -> e.project == "lib/A", m) + @test Set((e.group, e.version) for e in a) == + Set([("Core", "lts"), ("Core", "1.11"), ("Core", "1"), ("Core", "pre"), ("QA", "1")]) + # B and C are downstream: version "1" only + for p in ("lib/B", "lib/C") + ds = filter(e -> e.project == p, m) + @test !isempty(ds) + @test all(e -> e.version == "1", ds) + end + @test isempty(filter(e -> e.project == "lib/D", m)) # D unaffected + end + + @testset "--projects mode paths" begin + direct, trans = compute_affected(["lib/A/src/A.jl"], graph, rev) + # union of direct+transitive, as lib/ paths, sorted + pkgs = sort!(collect(union(direct, trans))) + @test pkgs == ["A", "B", "C"] + end +end + +@testset "test_groups.toml: custom group, runner, timeout, local_only" begin + groups = """ + [Core] + versions = ["1"] + + [GPU] + versions = ["1"] + runner = ["self-hosted", "Linux", "X64", "gpu"] + timeout = 60 + + [Heavy] + versions = ["1"] + local_only = true + """ + root = make_fixture(; c_groups = groups) + lib = joinpath(root, "lib") + graph = build_dependency_graph(lib) + rev = compute_reverse_deps(graph) + + @testset "C changed directly: all its groups incl local_only, GPU on its runner" begin + direct, trans = compute_affected(["lib/C/src/C.jl"], graph, rev) + m = build_projects_matrix(direct, trans, lib) + c = filter(e -> e.project == "lib/C", m) + @test Set(e.group for e in c) == Set(["Core", "GPU", "Heavy"]) + gpu = only(filter(e -> e.group == "GPU", c)) + @test gpu.runner == ["self-hosted", "Linux", "X64", "gpu"] + @test gpu.timeout == 60 + end + + @testset "C pulled in transitively: local_only group skipped" begin + direct, trans = compute_affected(["lib/B/src/B.jl"], graph, rev) # C is downstream of B + @test "C" in trans + m = build_projects_matrix(direct, trans, lib) + c = filter(e -> e.project == "lib/C", m) + @test "Heavy" ∉ Set(e.group for e in c) # local_only skipped when downstream + @test "Core" in Set(e.group for e in c) + end +end + +@testset "EXCLUDES drops (group,version) pairs" begin + # OrdinaryDiffEqBDF + pre is in EXCLUDES; a sublib of that name must not + # emit a Core/pre entry. + root = mktempdir() + lib = joinpath(root, "lib") + d = joinpath(lib, "OrdinaryDiffEqBDF") + mkpath(joinpath(d, "src")) + write(joinpath(d, "Project.toml"), "name = \"OrdinaryDiffEqBDF\"\nuuid = \"00000000-0000-0000-0000-000000000099\"\n") + write(joinpath(d, "src", "OrdinaryDiffEqBDF.jl"), "module OrdinaryDiffEqBDF\nend\n") + graph = build_dependency_graph(lib) + rev = compute_reverse_deps(graph) + direct, trans = compute_affected(["lib/OrdinaryDiffEqBDF/src/x.jl"], graph, rev) + m = build_projects_matrix(direct, trans, lib) + @test ("OrdinaryDiffEqBDF", "pre") in EXCLUDES + @test isempty(filter(e -> e.group == "Core" && e.version == "pre", m)) + @test !isempty(filter(e -> e.group == "Core" && e.version == "1", m)) +end + +@testset "end-to-end CLI output (--projects-matrix is valid JSON)" begin + root = make_fixture() + out = read(pipeline(IOBuffer("lib/A/src/A.jl\n"), `$(Base.julia_cmd()) $SCRIPT $root --projects-matrix`), String) + @test startswith(strip(out), "[") + @test occursin("\"project\":\"lib/A\"", out) + @test occursin("\"group\":\"Core\"", out) + # empty input -> [] + out2 = read(pipeline(IOBuffer("README.md\n"), `$(Base.julia_cmd()) $SCRIPT $root --projects-matrix`), String) + @test strip(out2) == "[]" +end