From d56b637c373957c15a55b6d0e292bbd04ad9c124 Mon Sep 17 00:00:00 2001 From: ChrisRackauckas-Claude Date: Tue, 21 Jul 2026 20:41:48 -0400 Subject: [PATCH 1/2] Preserve downgraded old-style test extras Keep floor-resolved test extras reachable through build and Pkg.test, while preserving source/no-promote exclusions and restoring each Project.toml byte-for-byte. Add cross-version sandbox, cleanup, and workflow synchronization coverage. Co-Authored-By: Chris Rackauckas --- .github/workflows/downgrade.yml | 107 ++++++++++ .github/workflows/sublibrary-downgrade.yml | 105 ++++++++++ README.md | 16 ++ test/runtests.jl | 228 +++++++++++++++++++++ 4 files changed, 456 insertions(+) diff --git a/.github/workflows/downgrade.yml b/.github/workflows/downgrade.yml index a90d823..f57743a 100644 --- a/.github/workflows/downgrade.yml +++ b/.github/workflows/downgrade.yml @@ -129,6 +129,105 @@ jobs: mode: "${{ inputs.mode != '' && inputs.mode || 'deps' }}" no_promote: "${{ inputs.no_promote }}" + - name: "Keep resolved test extras reachable during build and test" + env: + DOWNGRADE_TEST_PROJECT: "${{ inputs.project }}" + DOWNGRADE_NO_PROMOTE: "${{ inputs.no_promote }}" + run: | + project_dir="$DOWNGRADE_TEST_PROJECT" + [ "$project_dir" = "@." ] && project_dir="." + case "$project_dir" in + @*) echo "Project must be a directory path or @.: $project_dir" >&2; exit 1 ;; + esac + project_file="" + for filename in Project.toml JuliaProject.toml; do + candidate="$project_dir/$filename" + if [ -f "$candidate" ]; then project_file="$candidate"; break; fi + done + [ -n "$project_file" ] || { echo "No Project.toml or JuliaProject.toml in $project_dir" >&2; exit 1; } + backup="$RUNNER_TEMP/sciml-downgrade-original-project.toml" + cp -- "$project_file" "$backup" + echo "DOWNGRADE_PROJECT_FILE=$project_file" >> "$GITHUB_ENV" + echo "DOWNGRADE_PROJECT_BACKUP=$backup" >> "$GITHUB_ENV" + julia --startup-file=no - "$DOWNGRADE_TEST_PROJECT" "$DOWNGRADE_NO_PROMOTE" <<'JULIA' + using TOML + + function project_file(project::AbstractString) + directory = project == "@." ? "." : project + startswith(directory, "@") && + error("project must be a directory path or @., got: $project") + for filename in ("Project.toml", "JuliaProject.toml") + candidate = joinpath(directory, filename) + isfile(candidate) && return candidate + end + error("no Project.toml or JuliaProject.toml in $directory") + end + + function write_project(path::AbstractString, project::AbstractDict) + temporary, io = mktemp(dirname(path)) + try + TOML.print(io, project) + close(io) + mv(temporary, path; force = true) + catch + isopen(io) && close(io) + ispath(temporary) && rm(temporary; force = true) + rethrow() + end + return nothing + end + + function promote_test_extras(project::AbstractString, no_promote = String[]) + path = project_file(project) + parsed = TOML.parsefile(path) + extras = get(parsed, "extras", Dict{String, Any}()) + targets = get(parsed, "targets", Dict{String, Any}()) + test_target = get(targets, "test", String[]) + test_target isa AbstractVector || error("[targets].test must be an array in $path") + excluded = Set(strip.(filter(!isempty, no_promote))) + sources = Set(keys(get(parsed, "sources", Dict{String, Any}()))) + names = sort!(unique!(String.(test_target))) + deps = get(parsed, "deps", Dict{String, Any}()) + for name in names + if haskey(extras, name) && haskey(deps, name) && deps[name] != extras[name] + error("$name has different UUIDs in [deps] and [extras] in $path") + end + end + targeted_extras = filter(name -> haskey(extras, name), names) + skipped_no_promote = filter(name -> name ∈ excluded, targeted_extras) + skipped_sources = filter(name -> name ∈ sources, targeted_extras) + promoted = filter(names) do name + haskey(extras, name) && !haskey(deps, name) && + name ∉ excluded && name ∉ sources + end + @info "Selected old-style test extras for the downgraded environment" project = path promoted skipped_no_promote skipped_sources + isempty(promoted) && return promoted + + deps = get!(parsed, "deps", Dict{String, Any}()) + weakdeps = get(parsed, "weakdeps", nothing) + for name in promoted + uuid = extras[name] + deps[name] = uuid + weakdeps isa AbstractDict && delete!(weakdeps, name) + end + weakdeps isa AbstractDict && isempty(weakdeps) && delete!(parsed, "weakdeps") + write_project(path, parsed) + @info "Promoted resolved test extras into [deps]" project = path promoted + return promoted + end + + function main(args) + length(args) == 2 || error("usage: promote_test_extras.jl PROJECT NO_PROMOTE") + no_promote = filter(!isempty, strip.(split(args[2], ','))) + promote_test_extras(args[1], no_promote) + return nothing + end + + if PROGRAM_FILE == "-" || abspath(PROGRAM_FILE) == @__FILE__ + main(ARGS) + end + JULIA + - name: "Install system packages" if: "${{ inputs.apt-packages != '' && runner.os == 'Linux' }}" run: | @@ -150,3 +249,11 @@ jobs: force_latest_compatible_version: false env: GROUP: "${{ inputs.group }}" + + - name: "Restore the original project file" + if: ${{ always() }} + run: | + if [ -n "${DOWNGRADE_PROJECT_BACKUP:-}" ] && [ -f "$DOWNGRADE_PROJECT_BACKUP" ]; then + cp -- "$DOWNGRADE_PROJECT_BACKUP" "$DOWNGRADE_PROJECT_FILE" + rm -- "$DOWNGRADE_PROJECT_BACKUP" + fi diff --git a/.github/workflows/sublibrary-downgrade.yml b/.github/workflows/sublibrary-downgrade.yml index 5c5174c..d7efdb9 100644 --- a/.github/workflows/sublibrary-downgrade.yml +++ b/.github/workflows/sublibrary-downgrade.yml @@ -172,6 +172,104 @@ jobs: projects: ${{ matrix.project }} skip: ${{ env.EFFECTIVE_SKIP }} julia_version: ${{ env.JULIA_MM }} + - name: "Keep resolved test extras reachable during build and test" + env: + DOWNGRADE_TEST_PROJECT: ${{ matrix.project }} + DOWNGRADE_NO_PROMOTE: "" + run: | + project_dir="$DOWNGRADE_TEST_PROJECT" + [ "$project_dir" = "@." ] && project_dir="." + case "$project_dir" in + @*) echo "Project must be a directory path or @.: $project_dir" >&2; exit 1 ;; + esac + project_file="" + for filename in Project.toml JuliaProject.toml; do + candidate="$project_dir/$filename" + if [ -f "$candidate" ]; then project_file="$candidate"; break; fi + done + [ -n "$project_file" ] || { echo "No Project.toml or JuliaProject.toml in $project_dir" >&2; exit 1; } + backup="$RUNNER_TEMP/sciml-downgrade-original-project.toml" + cp -- "$project_file" "$backup" + echo "DOWNGRADE_PROJECT_FILE=$project_file" >> "$GITHUB_ENV" + echo "DOWNGRADE_PROJECT_BACKUP=$backup" >> "$GITHUB_ENV" + julia --startup-file=no - "$DOWNGRADE_TEST_PROJECT" "$DOWNGRADE_NO_PROMOTE" <<'JULIA' + using TOML + + function project_file(project::AbstractString) + directory = project == "@." ? "." : project + startswith(directory, "@") && + error("project must be a directory path or @., got: $project") + for filename in ("Project.toml", "JuliaProject.toml") + candidate = joinpath(directory, filename) + isfile(candidate) && return candidate + end + error("no Project.toml or JuliaProject.toml in $directory") + end + + function write_project(path::AbstractString, project::AbstractDict) + temporary, io = mktemp(dirname(path)) + try + TOML.print(io, project) + close(io) + mv(temporary, path; force = true) + catch + isopen(io) && close(io) + ispath(temporary) && rm(temporary; force = true) + rethrow() + end + return nothing + end + + function promote_test_extras(project::AbstractString, no_promote = String[]) + path = project_file(project) + parsed = TOML.parsefile(path) + extras = get(parsed, "extras", Dict{String, Any}()) + targets = get(parsed, "targets", Dict{String, Any}()) + test_target = get(targets, "test", String[]) + test_target isa AbstractVector || error("[targets].test must be an array in $path") + excluded = Set(strip.(filter(!isempty, no_promote))) + sources = Set(keys(get(parsed, "sources", Dict{String, Any}()))) + names = sort!(unique!(String.(test_target))) + deps = get(parsed, "deps", Dict{String, Any}()) + for name in names + if haskey(extras, name) && haskey(deps, name) && deps[name] != extras[name] + error("$name has different UUIDs in [deps] and [extras] in $path") + end + end + targeted_extras = filter(name -> haskey(extras, name), names) + skipped_no_promote = filter(name -> name ∈ excluded, targeted_extras) + skipped_sources = filter(name -> name ∈ sources, targeted_extras) + promoted = filter(names) do name + haskey(extras, name) && !haskey(deps, name) && + name ∉ excluded && name ∉ sources + end + @info "Selected old-style test extras for the downgraded environment" project = path promoted skipped_no_promote skipped_sources + isempty(promoted) && return promoted + + deps = get!(parsed, "deps", Dict{String, Any}()) + weakdeps = get(parsed, "weakdeps", nothing) + for name in promoted + uuid = extras[name] + deps[name] = uuid + weakdeps isa AbstractDict && delete!(weakdeps, name) + end + weakdeps isa AbstractDict && isempty(weakdeps) && delete!(parsed, "weakdeps") + write_project(path, parsed) + @info "Promoted resolved test extras into [deps]" project = path promoted + return promoted + end + + function main(args) + length(args) == 2 || error("usage: promote_test_extras.jl PROJECT NO_PROMOTE") + no_promote = filter(!isempty, strip.(split(args[2], ','))) + promote_test_extras(args[1], no_promote) + return nothing + end + + if PROGRAM_FILE == "-" || abspath(PROGRAM_FILE) == @__FILE__ + main(ARGS) + end + JULIA - name: "Install system packages" if: "${{ inputs.apt-packages != '' && runner.os == 'Linux' }}" run: | @@ -184,3 +282,10 @@ jobs: with: project: ${{ matrix.project }} allow_reresolve: false + - name: "Restore the original project file" + if: ${{ always() }} + run: | + if [ -n "${DOWNGRADE_PROJECT_BACKUP:-}" ] && [ -f "$DOWNGRADE_PROJECT_BACKUP" ]; then + cp -- "$DOWNGRADE_PROJECT_BACKUP" "$DOWNGRADE_PROJECT_FILE" + rm -- "$DOWNGRADE_PROJECT_BACKUP" + fi diff --git a/README.md b/README.md index 7116328..e7a480d 100644 --- a/README.md +++ b/README.md @@ -259,6 +259,8 @@ to catch under-specified `[compat]` lower bounds. | `skip` | string | `""` | **Additional** deps to skip when downgrading, beyond the auto-included Julia stdlibs and any `[sources]` path/url deps (see note). | | `projects` | string | `"."` | Comma-separated project dirs to downgrade. | | `project` | string | `"@."` | `--project` for build/test (a workspace submodule or `lib/X`); default tests the repo root. | +| `mode` | string | `"deps"` | Dependency set minimized by `julia-downgrade-compat`: `deps`, `alldeps`, `weakdeps`, or `forcedeps`. | +| `no_promote` | string | `"Mooncake"` | Comma-separated old-style test extras left out of the joint floor resolve. | | `self-hosted` / `os` | | `false` / `ubuntu-latest` | Runner selection. | | `apt-packages` | string | `""` | Space-separated apt packages to install before building/testing (Linux only). | | `container` | string | `""` | Docker container image to run the job in (e.g. `cmhyett/julia-fenics:latest` for Python-stack packages). Empty = no container. | @@ -282,6 +284,14 @@ jobs: > [SciML/.github #73](https://github.com/SciML/.github/pull/73); strict > `allow_reresolve: false` is already in effect.) +For old-style `[extras]` / `[targets].test` dependencies, the workflow +temporarily adds each floor-resolved test extra to the active project's +`[deps]` before build and test. This keeps `Pkg.test` from replacing an orphaned +manifest floor with the newest compatible version. `[sources]` and +`no_promote` entries are left untouched, new-style `test/Project.toml` +environments keep their existing handling, and an `always()` cleanup restores +the original project file byte-for-byte. + ### `documentation.yml` Builds and deploys [Documenter](https://documenter.juliadocs.org/) @@ -780,6 +790,12 @@ jobs: > [SciML/.github #73](https://github.com/SciML/.github/pull/73); strict > `allow_reresolve: false` is already in effect.) +Old-style `[extras]` / `[targets].test` dependencies are temporarily added to +the sublibrary's `[deps]` after floor resolution so `Pkg.build` and `Pkg.test` +retain those exact manifest versions. Source-backed extras are not rewritten, +new-style `test/Project.toml` environments keep their existing handling, and +an `always()` cleanup restores the original project file byte-for-byte. + --- ## Recommended repository setup diff --git a/test/runtests.jl b/test/runtests.jl index 67fba1a..fc5d094 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,4 +1,5 @@ using Test +using TOML # Load the detection script's functions without running main(). const SCRIPT = joinpath(@__DIR__, "..", "scripts", "compute_affected_sublibraries.jl") @@ -592,3 +593,230 @@ end root = make_sources_fixture() @test isempty(collect_source_paths(joinpath(root, "Leaf"))) end + +function embedded_promote_test_extras(path) + lines = readlines(path) + first_line = findfirst(line -> occursin("<<'JULIA'", line), lines) + first_line === nothing && error("no Julia heredoc in $path") + last_line = findnext(line -> strip(line) == "JULIA", lines, first_line + 1) + last_line === nothing && error("unterminated Julia heredoc in $path") + source = map(lines[(first_line + 1):(last_line - 1)]) do line + startswith(line, " ") ? line[11:end] : line + end + return join(source, '\n') * '\n' +end + +const PROMOTE_TEST_EXTRAS_SOURCE = embedded_promote_test_extras( + joinpath(@__DIR__, "..", ".github", "workflows", "downgrade.yml") +) +include_string(Main, PROMOTE_TEST_EXTRAS_SOURCE, "embedded promote_test_extras.jl") + +@testset "promote_test_extras production source stays synchronized" begin + workflows = ("downgrade.yml", "sublibrary-downgrade.yml") + for workflow in workflows + path = joinpath(@__DIR__, "..", ".github", "workflows", workflow) + text = read(path, String) + @test embedded_promote_test_extras(path) == PROMOTE_TEST_EXTRAS_SOURCE + @test occursin("cp -- \"\$project_file\" \"\$backup\"", text) + @test occursin("cp -- \"\$DOWNGRADE_PROJECT_BACKUP\" \"\$DOWNGRADE_PROJECT_FILE\"", text) + @test occursin(raw"if: ${{ always() }}", text) + resolve_at = findfirst("julia-actions/julia-downgrade-compat", text) + promote_at = findfirst("Keep resolved test extras reachable", text) + build_at = findfirst("julia-actions/julia-buildpkg", text) + test_at = findfirst("julia-actions/julia-runtest", text) + restore_at = findfirst("Restore the original project file", text) + @test all(!isnothing, (resolve_at, promote_at, build_at, test_at, restore_at)) + @test first(resolve_at) < first(promote_at) < first(build_at) < first(test_at) < + first(restore_at) + end +end + +@testset "promote_test_extras selects only resolved old-style test dependencies" begin + root = mktempdir() + project_path = joinpath(root, "Project.toml") + original = """ + name = "PromotionFixture" + uuid = "00000000-0000-0000-0000-000000000001" + + [deps] + Existing = "00000000-0000-0000-0000-000000000002" + + [weakdeps] + PromotedWeak = "00000000-0000-0000-0000-000000000003" + UntargetedWeak = "00000000-0000-0000-0000-000000000004" + + [extras] + PromotedPure = "00000000-0000-0000-0000-000000000005" + PromotedWeak = "00000000-0000-0000-0000-000000000003" + Excluded = "00000000-0000-0000-0000-000000000006" + SourceExtra = "00000000-0000-0000-0000-000000000007" + NotTargeted = "00000000-0000-0000-0000-000000000008" + + [sources] + SourceExtra = {path = "../SourceExtra"} + + [targets] + test = ["PromotedPure", "PromotedWeak", "Excluded", "SourceExtra"] + """ + write(project_path, original) + + promoted = promote_test_extras(root, ["Excluded"]) + project = TOML.parsefile(project_path) + @test promoted == ["PromotedPure", "PromotedWeak"] + @test project["deps"]["PromotedPure"] == project["extras"]["PromotedPure"] + @test project["deps"]["PromotedWeak"] == project["extras"]["PromotedWeak"] + @test !haskey(project["deps"], "Excluded") + @test !haskey(project["deps"], "SourceExtra") + @test !haskey(project["deps"], "NotTargeted") + @test !haskey(project["weakdeps"], "PromotedWeak") + @test haskey(project["weakdeps"], "UntargetedWeak") + @test promote_test_extras(root, ["Excluded"]) == String[] +end + +@testset "promote_test_extras keeps no-op projects byte-identical" begin + root = mktempdir() + project_path = joinpath(root, "JuliaProject.toml") + original = """ + name = "NoPromotionFixture" + uuid = "00000000-0000-0000-0000-000000000010" + + [extras] + NotTargeted = "00000000-0000-0000-0000-000000000011" + + [targets] + test = [] + """ + write(project_path, original) + cd(root) do + @test promote_test_extras("@.") == String[] + end + @test read(project_path, String) == original +end + +@testset "promote_test_extras leaves new-style test environments untouched" begin + root = mktempdir() + project = joinpath(root, "lib", "NewStyleFixture") + test_directory = joinpath(project, "test") + mkpath(test_directory) + root_project = """ + name = "NewStyleFixture" + uuid = "00000000-0000-0000-0000-000000000012" + + [deps] + RuntimeDep = "00000000-0000-0000-0000-000000000013" + """ + test_project = """ + [deps] + TestDep = "00000000-0000-0000-0000-000000000014" + """ + write(joinpath(project, "Project.toml"), root_project) + write(joinpath(test_directory, "Project.toml"), test_project) + + @test promote_test_extras(project) == String[] + @test read(joinpath(project, "Project.toml"), String) == root_project + @test read(joinpath(test_directory, "Project.toml"), String) == test_project +end + +function with_project_backup(f, project_path, backup_path) + cp(project_path, backup_path; force = true) + try + return f() + finally + cp(backup_path, project_path; force = true) + rm(backup_path; force = true) + end +end + +@testset "project backup restores exact bytes after success and failure" begin + for fail in (false, true) + root = mktempdir() + project_path = joinpath(root, "Project.toml") + backup_path = joinpath(root, "original.toml") + original = """ + # This comment and ordering must survive cleanup exactly. + name = "RestoreFixture" + uuid = "00000000-0000-0000-0000-000000000015" + + [extras] + TestDep = "00000000-0000-0000-0000-000000000016" + + [targets] + test = ["TestDep"] + """ + write(project_path, original) + operation = () -> with_project_backup(project_path, backup_path) do + @test promote_test_extras(root) == ["TestDep"] + @test read(project_path, String) != original + fail && error("restore failure-path fixture") + end + fail ? (@test_throws ErrorException operation()) : operation() + @test read(project_path, String) == original + @test !isfile(backup_path) + end +end + +@testset "promoted test extras survive the Pkg.test sandbox" begin + root = mktempdir() + package = joinpath(root, "PromotionPkg") + dependency = joinpath(root, "FloorFixtureDep") + mkpath(joinpath(package, "src")) + mkpath(joinpath(package, "test")) + mkpath(joinpath(dependency, "src")) + write( + joinpath(package, "Project.toml"), + """ + name = "PromotionPkg" + uuid = "00000000-0000-0000-0000-000000000020" + version = "0.1.0" + + [extras] + FloorFixtureDep = "00000000-0000-0000-0000-000000000021" + + [targets] + test = ["FloorFixtureDep"] + + [compat] + FloorFixtureDep = "0.1, 0.2" + julia = "1.10" + """ + ) + manifest_path = joinpath(package, "Manifest.toml") + write( + manifest_path, + """ + julia_version = "$(VERSION)" + manifest_format = "2.0" + + [[deps.FloorFixtureDep]] + path = "../FloorFixtureDep" + uuid = "00000000-0000-0000-0000-000000000021" + version = "0.1.0" + """ + ) + write(joinpath(package, "src", "PromotionPkg.jl"), "module PromotionPkg\nend\n") + write( + joinpath(package, "test", "runtests.jl"), + "using FloorFixtureDep\n@assert FloorFixtureDep.VERSION_MARKER == v\"0.1.0\"\n" + ) + write( + joinpath(dependency, "Project.toml"), + """ + name = "FloorFixtureDep" + uuid = "00000000-0000-0000-0000-000000000021" + version = "0.1.0" + """ + ) + write( + joinpath(dependency, "src", "FloorFixtureDep.jl"), + "module FloorFixtureDep\nconst VERSION_MARKER = v\"0.1.0\"\nend\n" + ) + + @test promote_test_extras(package) == ["FloorFixtureDep"] + locked_manifest = read(manifest_path) + julia = joinpath(Sys.BINDIR, "julia" * (Sys.iswindows() ? ".exe" : "")) + command = `$julia --startup-file=no --project=$package -e 'using Pkg; Pkg.build(; verbose=true); Pkg.test(; allow_reresolve=false)'` + @test success(pipeline(command; stdout, stderr)) + @test read(manifest_path) == locked_manifest + manifest = TOML.parsefile(manifest_path) + @test manifest["deps"]["FloorFixtureDep"][1]["version"] == "0.1.0" +end From 4d16630091d8a7c4397f0fe3653d88beaad7b15c Mon Sep 17 00:00:00 2001 From: ChrisRackauckas-Claude Date: Tue, 21 Jul 2026 22:14:34 -0400 Subject: [PATCH 2/2] Preserve package default test group Co-Authored-By: Chris Rackauckas --- .github/workflows/downgrade.yml | 8 ++++++-- test/runtests.jl | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.github/workflows/downgrade.yml b/.github/workflows/downgrade.yml index f57743a..f6adb73 100644 --- a/.github/workflows/downgrade.yml +++ b/.github/workflows/downgrade.yml @@ -238,6 +238,12 @@ jobs: with: project: "${{ inputs.project }}" + - name: "Export requested test group" + if: "${{ inputs.group != '' }}" + env: + REQUESTED_GROUP: "${{ inputs.group }}" + run: printf 'GROUP=%s\n' "$REQUESTED_GROUP" >> "$GITHUB_ENV" + - uses: julia-actions/julia-runtest@v1 with: project: "${{ inputs.project }}" @@ -247,8 +253,6 @@ jobs: # and fails resolution (e.g. "empty intersection between X@min and project # compatibility latest"). Downgrade tests must keep the minimum versions. force_latest_compatible_version: false - env: - GROUP: "${{ inputs.group }}" - name: "Restore the original project file" if: ${{ always() }} diff --git a/test/runtests.jl b/test/runtests.jl index fc5d094..f7124ec 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -631,6 +631,21 @@ include_string(Main, PROMOTE_TEST_EXTRAS_SOURCE, "embedded promote_test_extras.j end end +@testset "empty downgrade group preserves the package default" begin + path = joinpath(@__DIR__, "..", ".github", "workflows", "downgrade.yml") + text = read(path, String) + export_at = findfirst("Export requested test group", text) + test_at = findfirst("julia-actions/julia-runtest", text) + @test all(!isnothing, (export_at, test_at)) + @test first(export_at) < first(test_at) + @test occursin(raw"if: \"${{ inputs.group != '' }}\"", text) + @test occursin(raw"REQUESTED_GROUP: \"${{ inputs.group }}\"", text) + @test occursin(raw"run: printf 'GROUP=%s\n' \"$REQUESTED_GROUP\" >> \"$GITHUB_ENV\"", text) + @test !any( + line -> strip(line) == raw"GROUP: \"${{ inputs.group }}\"", eachline(IOBuffer(text)) + ) +end + @testset "promote_test_extras selects only resolved old-style test dependencies" begin root = mktempdir() project_path = joinpath(root, "Project.toml")