From 31a436b4370afc7ef557a76b66f445ae59c96f0f Mon Sep 17 00:00:00 2001 From: ChrisRackauckas Date: Tue, 16 Dec 2025 09:19:51 -0500 Subject: [PATCH 1/8] Handle packages with local [sources] in test/Project.toml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Julia 1.13+, test dependencies often use [sources.PackageName] with path=".." to reference the main package. When Resolver.jl tries to resolve these packages from the registry, it fails with "unknown package UUID" for unregistered packages, or resolves to an old version for registered packages. This fix: - Detects packages with local path sources via [sources] section - Temporarily removes them from [deps], [compat], [extras], and [sources] before running Resolver.jl - Restores the original Project.toml after resolution - Excludes local source packages from forcedeps verification Fixes #36 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- downgrade.jl | 142 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 138 insertions(+), 4 deletions(-) diff --git a/downgrade.jl b/downgrade.jl index 53584c4..75eeb55 100644 --- a/downgrade.jl +++ b/downgrade.jl @@ -8,6 +8,127 @@ julia_version = length(ARGS) >= 4 ? ARGS[4] : "1.10" valid_modes = ["deps", "alldeps", "weakdeps", "forcedeps"] mode in valid_modes || error("mode must be one of: $(join(valid_modes, ", "))") +""" + get_local_source_packages(project_file) + +Parse a Project.toml and find packages that have local path sources. +Returns a Set of package names that should be excluded from resolution +because they are sourced from local paths (e.g., the main package in test/Project.toml). + +In Julia 1.13+, test dependencies often use [sources.PackageName] with path=".." +to reference the main package. These cannot be resolved from the registry. +""" +function get_local_source_packages(project_file::String) + local_pkgs = Set{String}() + + if !isfile(project_file) + return local_pkgs + end + + project = TOML.parsefile(project_file) + + # Check for [sources] section entries with path keys + sources = get(project, "sources", Dict()) + for (pkg_name, source_info) in sources + if source_info isa Dict && haskey(source_info, "path") + push!(local_pkgs, pkg_name) + @info "Found local source package: $pkg_name (path=$(source_info["path"]))" + end + end + + return local_pkgs +end + +""" + remove_local_packages_from_project(project_file, local_pkgs) + +Create a modified version of the Project.toml with local source packages +removed from [deps], [compat], [extras], and [sources] sections. +Returns the original content so it can be restored later. + +Note: We must also remove from [sources] because Pkg validates that any +package in [sources] must be in [deps] or [extras]. +""" +function remove_local_packages_from_project(project_file::String, local_pkgs::Set{String}) + if isempty(local_pkgs) + return nothing # No modification needed + end + + original_content = read(project_file, String) + project = TOML.parsefile(project_file) + modified = false + + # Remove from [deps] + if haskey(project, "deps") + for pkg in local_pkgs + if haskey(project["deps"], pkg) + delete!(project["deps"], pkg) + modified = true + @info "Temporarily removing $pkg from [deps] for resolution" + end + end + end + + # Remove from [extras] + if haskey(project, "extras") + for pkg in local_pkgs + if haskey(project["extras"], pkg) + delete!(project["extras"], pkg) + modified = true + @info "Temporarily removing $pkg from [extras] for resolution" + end + end + end + + # Remove from [compat] + if haskey(project, "compat") + for pkg in local_pkgs + if haskey(project["compat"], pkg) + delete!(project["compat"], pkg) + modified = true + @info "Temporarily removing $pkg from [compat] for resolution" + end + end + end + + # Remove from [sources] - must do this because Pkg validates that + # packages in [sources] must be in [deps] or [extras] + if haskey(project, "sources") + for pkg in local_pkgs + if haskey(project["sources"], pkg) + delete!(project["sources"], pkg) + modified = true + @info "Temporarily removing $pkg from [sources] for resolution" + end + end + # Remove empty [sources] section + if isempty(project["sources"]) + delete!(project, "sources") + end + end + + if modified + open(project_file, "w") do io + TOML.print(io, project) + end + return original_content + end + + return nothing +end + +""" + restore_project_file(project_file, original_content) + +Restore the original Project.toml content after resolution. +""" +function restore_project_file(project_file::String, original_content::Union{String,Nothing}) + if original_content !== nothing + write(project_file, original_content) + @info "Restored original Project.toml" + end +end + @info "Using Resolver.jl with mode: $mode" # Clone the resolver @@ -173,14 +294,27 @@ for dir in dirs project_file = first(project_files) manifest_file = joinpath(dir, "Manifest.toml") - @info "Running resolver on $dir with --min=@$resolver_mode" - run(`julia --project=$resolver_path/bin $resolver_path/bin/resolve.jl $dir --min=@$resolver_mode --julia=$julia_version`) - @info "Successfully resolved minimal versions for $dir" + # Handle packages with local [sources] entries (e.g., test/Project.toml referencing main package) + # These packages cannot be resolved from the registry, so we temporarily remove them + local_pkgs = get_local_source_packages(project_file) + original_content = remove_local_packages_from_project(project_file, local_pkgs) + + try + @info "Running resolver on $dir with --min=@$resolver_mode" + run(`julia --project=$resolver_path/bin $resolver_path/bin/resolve.jl $dir --min=@$resolver_mode --julia=$julia_version`) + @info "Successfully resolved minimal versions for $dir" + finally + # Always restore the original Project.toml, even if resolution fails + restore_project_file(project_file, original_content) + end # For forcedeps mode, verify that the resolved versions match the lower bounds + # Note: we check against the original project file (now restored), but skip local source packages if mode == "forcedeps" @info "Checking that resolved versions match forced lower bounds..." - if !check_forced_lower_bounds(project_file, manifest_file, ignore_pkgs) + # Add local source packages to the ignore list for forcedeps check + forcedeps_ignore = union(ignore_pkgs, local_pkgs) + if !check_forced_lower_bounds(project_file, manifest_file, forcedeps_ignore) error(""" forcedeps check failed: Some packages did not resolve to their lower bounds. From e104f36617b8f9857e76e7e79caf0e5dcd2c3af5 Mon Sep 17 00:00:00 2001 From: ChrisRackauckas Date: Tue, 16 Dec 2025 09:53:26 -0500 Subject: [PATCH 2/8] Merge main and test projects for combined resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When both "." and "test" are specified, merge their dependencies into a single project before running Resolver.jl. This ensures that the resolved versions are compatible when tests run (which combines both environments). The v1 action worked this way because it modified both Project.toml files before running tests. The v2 action was resolving them independently, which could result in incompatible versions when combined. Changes: - Add create_merged_project() to combine main and test deps - Add should_merge_projects() to detect when merging is needed - Merge deps, compat, and weakdeps from test into main project - Exclude local source packages (main package referenced via path) - Copy resulting manifest to main project directory - Fix scoping warnings in loops Addresses feedback from @JoshuaLampert in PR #37. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- downgrade.jl | 250 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 224 insertions(+), 26 deletions(-) diff --git a/downgrade.jl b/downgrade.jl index 75eeb55..9667777 100644 --- a/downgrade.jl +++ b/downgrade.jl @@ -129,6 +129,103 @@ function restore_project_file(project_file::String, original_content::Union{Stri end end +""" + create_merged_project(main_project_file, test_project_file, merged_dir) + +Create a merged Project.toml that combines dependencies from both the main +project and test project. This ensures that when tests run (which combine +both environments), the resolved versions are compatible. + +Returns a Set of local source packages that were excluded from the merge. +""" +function create_merged_project(main_project_file::String, test_project_file::String, merged_dir::String) + main_project = TOML.parsefile(main_project_file) + test_project = TOML.parsefile(test_project_file) + + # Get local source packages from test project (e.g., the main package itself) + local_pkgs = get_local_source_packages(test_project_file) + + # Start with a copy of the main project + merged = deepcopy(main_project) + + # Remove workspace section (not needed for resolution) + delete!(merged, "workspace") + + # Merge deps from test project (excluding local source packages) + test_deps = get(test_project, "deps", Dict()) + if !haskey(merged, "deps") + merged["deps"] = Dict{String,Any}() + end + for (pkg, uuid) in test_deps + if pkg ∉ local_pkgs && !haskey(merged["deps"], pkg) + merged["deps"][pkg] = uuid + @info "Adding test dependency to merged project: $pkg" + end + end + + # Merge compat entries from test project + test_compat = get(test_project, "compat", Dict()) + if !haskey(merged, "compat") + merged["compat"] = Dict{String,Any}() + end + for (pkg, compat) in test_compat + if pkg ∉ local_pkgs + if haskey(merged["compat"], pkg) + # Both have compat - keep both constraints (Resolver.jl will find intersection) + # For simplicity, we keep the main project's compat if they differ + @info "Package $pkg has compat in both projects, using main project's compat" + else + merged["compat"][pkg] = compat + @info "Adding test compat to merged project: $pkg = \"$compat\"" + end + end + end + + # Merge weakdeps from test project + test_weakdeps = get(test_project, "weakdeps", Dict()) + if !isempty(test_weakdeps) + if !haskey(merged, "weakdeps") + merged["weakdeps"] = Dict{String,Any}() + end + for (pkg, uuid) in test_weakdeps + if pkg ∉ local_pkgs && !haskey(merged["weakdeps"], pkg) + merged["weakdeps"][pkg] = uuid + @info "Adding test weakdep to merged project: $pkg" + end + end + end + + # Write merged project + mkpath(merged_dir) + merged_file = joinpath(merged_dir, "Project.toml") + open(merged_file, "w") do io + TOML.print(io, merged) + end + + @info "Created merged project at $merged_file" + return local_pkgs +end + +""" + should_merge_projects(dirs) + +Check if we should merge the main and test projects for resolution. +Returns (should_merge, main_dir, test_dir) tuple. +""" +function should_merge_projects(dirs) + # Normalize directory names + normalized = [d == "." ? "." : rstrip(d, '/') for d in dirs] + + has_main = "." in normalized + has_test = "test" in normalized + + if has_main && has_test + return (true, ".", "test") + end + + return (false, nothing, nothing) +end + @info "Using Resolver.jl with mode: $mode" # Clone the resolver @@ -285,36 +382,49 @@ end # For forcedeps, we use "deps" mode and then verify the results resolver_mode = mode == "forcedeps" ? "deps" : mode -# Process each directory -for dir in dirs - project_files = [joinpath(dir, "Project.toml"), joinpath(dir, "JuliaProject.toml")] - filter!(isfile, project_files) - isempty(project_files) && error("could not find Project.toml or JuliaProject.toml in $dir") - - project_file = first(project_files) - manifest_file = joinpath(dir, "Manifest.toml") - - # Handle packages with local [sources] entries (e.g., test/Project.toml referencing main package) - # These packages cannot be resolved from the registry, so we temporarily remove them - local_pkgs = get_local_source_packages(project_file) - original_content = remove_local_packages_from_project(project_file, local_pkgs) - - try - @info "Running resolver on $dir with --min=@$resolver_mode" - run(`julia --project=$resolver_path/bin $resolver_path/bin/resolve.jl $dir --min=@$resolver_mode --julia=$julia_version`) - @info "Successfully resolved minimal versions for $dir" - finally - # Always restore the original Project.toml, even if resolution fails - restore_project_file(project_file, original_content) +# Check if we should merge main and test projects +(do_merge, main_dir, test_dir) = should_merge_projects(dirs) + +if do_merge + # Merged resolution: combine main and test projects, resolve together + @info "Merging main (.) and test projects for combined resolution" + + main_project_file = isfile(joinpath(main_dir, "Project.toml")) ? + joinpath(main_dir, "Project.toml") : joinpath(main_dir, "JuliaProject.toml") + test_project_file = isfile(joinpath(test_dir, "Project.toml")) ? + joinpath(test_dir, "Project.toml") : joinpath(test_dir, "JuliaProject.toml") + + if !isfile(main_project_file) + error("could not find Project.toml or JuliaProject.toml in $main_dir") + end + if !isfile(test_project_file) + error("could not find Project.toml or JuliaProject.toml in $test_dir") end - # For forcedeps mode, verify that the resolved versions match the lower bounds - # Note: we check against the original project file (now restored), but skip local source packages + # Create merged project in temp directory + merged_dir = mktempdir() + local_pkgs = create_merged_project(main_project_file, test_project_file, merged_dir) + + # Run resolver on merged project + @info "Running resolver on merged project with --min=@$resolver_mode" + run(`julia --project=$resolver_path/bin $resolver_path/bin/resolve.jl $merged_dir --min=@$resolver_mode --julia=$julia_version`) + @info "Successfully resolved minimal versions for merged project" + + # Copy manifest to main project directory + merged_manifest = joinpath(merged_dir, "Manifest.toml") + main_manifest = joinpath(main_dir, "Manifest.toml") + if isfile(merged_manifest) + cp(merged_manifest, main_manifest; force=true) + @info "Copied merged manifest to $main_manifest" + end + + # For forcedeps mode, verify lower bounds for both projects if mode == "forcedeps" @info "Checking that resolved versions match forced lower bounds..." - # Add local source packages to the ignore list for forcedeps check forcedeps_ignore = union(ignore_pkgs, local_pkgs) - if !check_forced_lower_bounds(project_file, manifest_file, forcedeps_ignore) + + # Check main project + if !check_forced_lower_bounds(main_project_file, main_manifest, forcedeps_ignore) error(""" forcedeps check failed: Some packages did not resolve to their lower bounds. @@ -325,6 +435,94 @@ for dir in dirs See the errors above for which packages need their bounds adjusted. """) end - @info "All forcedeps checks passed for $dir" + + # Check test project (excluding local source packages) + if !check_forced_lower_bounds(test_project_file, main_manifest, forcedeps_ignore) + error(""" + forcedeps check failed: Some test dependencies did not resolve to their lower bounds. + + See the errors above for which packages need their bounds adjusted. + """) + end + + @info "All forcedeps checks passed for merged project" + end + + # Process any remaining directories that aren't main or test + other_dirs = filter(d -> d != "." && d != "test", dirs) + for dir in other_dirs + project_files = [joinpath(dir, "Project.toml"), joinpath(dir, "JuliaProject.toml")] + filter!(isfile, project_files) + isempty(project_files) && error("could not find Project.toml or JuliaProject.toml in $dir") + + project_file = first(project_files) + manifest_file = joinpath(dir, "Manifest.toml") + + dir_local_pkgs = get_local_source_packages(project_file) + original_content = remove_local_packages_from_project(project_file, dir_local_pkgs) + + try + @info "Running resolver on $dir with --min=@$resolver_mode" + run(`julia --project=$resolver_path/bin $resolver_path/bin/resolve.jl $dir --min=@$resolver_mode --julia=$julia_version`) + @info "Successfully resolved minimal versions for $dir" + finally + restore_project_file(project_file, original_content) + end + + if mode == "forcedeps" + @info "Checking that resolved versions match forced lower bounds for $dir..." + local forcedeps_ignore = union(ignore_pkgs, dir_local_pkgs) + if !check_forced_lower_bounds(project_file, manifest_file, forcedeps_ignore) + error(""" + forcedeps check failed for $dir: Some packages did not resolve to their lower bounds. + See the errors above for which packages need their bounds adjusted. + """) + end + @info "All forcedeps checks passed for $dir" + end + end +else + # Independent resolution: process each directory separately + for dir in dirs + project_files = [joinpath(dir, "Project.toml"), joinpath(dir, "JuliaProject.toml")] + filter!(isfile, project_files) + isempty(project_files) && error("could not find Project.toml or JuliaProject.toml in $dir") + + project_file = first(project_files) + manifest_file = joinpath(dir, "Manifest.toml") + + # Handle packages with local [sources] entries (e.g., test/Project.toml referencing main package) + # These packages cannot be resolved from the registry, so we temporarily remove them + local local_pkgs = get_local_source_packages(project_file) + original_content = remove_local_packages_from_project(project_file, local_pkgs) + + try + @info "Running resolver on $dir with --min=@$resolver_mode" + run(`julia --project=$resolver_path/bin $resolver_path/bin/resolve.jl $dir --min=@$resolver_mode --julia=$julia_version`) + @info "Successfully resolved minimal versions for $dir" + finally + # Always restore the original Project.toml, even if resolution fails + restore_project_file(project_file, original_content) + end + + # For forcedeps mode, verify that the resolved versions match the lower bounds + # Note: we check against the original project file (now restored), but skip local source packages + if mode == "forcedeps" + @info "Checking that resolved versions match forced lower bounds..." + # Add local source packages to the ignore list for forcedeps check + local forcedeps_ignore = union(ignore_pkgs, local_pkgs) + if !check_forced_lower_bounds(project_file, manifest_file, forcedeps_ignore) + error(""" + forcedeps check failed: Some packages did not resolve to their lower bounds. + + This means the lowest compatible versions of your direct dependencies are + incompatible with each other. To fix this, you need to increase the lower + bounds in your compat entries to versions that are mutually compatible. + + See the errors above for which packages need their bounds adjusted. + """) + end + @info "All forcedeps checks passed for $dir" + end end end From d9f99897d834dd6c009cee0013fbb7b104d89925 Mon Sep 17 00:00:00 2001 From: ChrisRackauckas Date: Tue, 16 Dec 2025 13:13:43 -0500 Subject: [PATCH 3/8] Add main package to manifest after merged resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the main package is excluded from resolution (because it has a local source in test/Project.toml), it was missing from the generated manifest. This caused issues because the test project depends on the main package but couldn't find it in the manifest. Now after copying the merged manifest to the main project directory, we append the main package as a path dependency with: - path = "." - uuid from Project.toml - version from Project.toml This fixes the issue reported by @sethaxen where the manifest was missing the package itself. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- downgrade.jl | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/downgrade.jl b/downgrade.jl index 9667777..2c041fe 100644 --- a/downgrade.jl +++ b/downgrade.jl @@ -226,6 +226,59 @@ function should_merge_projects(dirs) return (false, nothing, nothing) end +""" + add_main_package_to_manifest(manifest_file, main_project_file) + +Add the main package itself to the manifest as a path dependency. +This is needed because the main package is excluded from resolution +(it's a local source), but the manifest needs to include it for +workspace projects to work correctly. +""" +function add_main_package_to_manifest(manifest_file::String, main_project_file::String) + if !isfile(manifest_file) + @warn "Manifest file not found: $manifest_file" + return + end + + main_project = TOML.parsefile(main_project_file) + + # Get main package info + pkg_name = get(main_project, "name", nothing) + pkg_uuid = get(main_project, "uuid", nothing) + pkg_version = get(main_project, "version", nothing) + + if pkg_name === nothing || pkg_uuid === nothing + @warn "Main project missing name or uuid, cannot add to manifest" + return + end + + # Read the manifest content as text to preserve formatting + manifest_content = read(manifest_file, String) + + # Build the entry for the main package + entry_lines = String[] + push!(entry_lines, "[[deps.$pkg_name]]") + push!(entry_lines, "path = \".\"") + push!(entry_lines, "uuid = \"$pkg_uuid\"") + if pkg_version !== nothing + push!(entry_lines, "version = \"$pkg_version\"") + end + push!(entry_lines, "") + + main_pkg_entry = join(entry_lines, "\n") + + # Append the main package entry to the manifest + open(manifest_file, "w") do io + print(io, manifest_content) + if !endswith(manifest_content, "\n") + println(io) + end + print(io, main_pkg_entry) + end + + @info "Added main package $pkg_name to manifest" +end + @info "Using Resolver.jl with mode: $mode" # Clone the resolver @@ -416,6 +469,10 @@ if do_merge if isfile(merged_manifest) cp(merged_manifest, main_manifest; force=true) @info "Copied merged manifest to $main_manifest" + + # Add the main package itself to the manifest as a path dependency + # This is needed for workspace projects where the test project depends on the main package + add_main_package_to_manifest(main_manifest, main_project_file) end # For forcedeps mode, verify lower bounds for both projects From 27b72612c1007a12b43502609490e44b1ba95a83 Mon Sep 17 00:00:00 2001 From: ChrisRackauckas Date: Tue, 16 Dec 2025 13:17:45 -0500 Subject: [PATCH 4/8] Add tests for local sources and merged resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests added: - "test/Project.toml with local sources": Verifies that packages with local [sources] entries are correctly detected and excluded from resolution, and the main package is added to the manifest as a path dependency. - "merged resolution with test dependencies": Verifies that when both main and test projects are specified, their dependencies are merged and minimized together, ensuring compatible versions when combined. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- test/runtests.jl | 128 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/test/runtests.jl b/test/runtests.jl index d5e33ef..725ffaa 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -172,4 +172,132 @@ downgrade_jl = joinpath(dirname(@__DIR__), "downgrade.jl") end end end + + @testset "test/Project.toml with local sources" begin + mktempdir() do dir + cd(dir) do + # Create main Project.toml + main_toml = """ + name = "TestPackage" + uuid = "598b003f-0677-49cf-8d2a-39b1658b755a" + version = "0.1.0" + + [workspace] + projects = ["test"] + """ + write("Project.toml", main_toml) + + # Create src directory and module + mkdir("src") + write("src/TestPackage.jl", "module TestPackage\nend\n") + + # Create test/Project.toml with local source reference + mkdir("test") + test_toml = """ + [deps] + TestPackage = "598b003f-0677-49cf-8d2a-39b1658b755a" + Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + + [sources.TestPackage] + path = ".." + """ + write("test/Project.toml", test_toml) + write("test/runtests.jl", "using TestPackage, Test\n@testset \"tests\" begin @test true end\n") + + # Run the downgrade script with merged resolution + run(`$(Base.julia_cmd()) $downgrade_jl "" ".,test" "deps" "1.10"`) + + # Verify Manifest.toml was created + @test isfile("Manifest.toml") + + # Parse the manifest + manifest = TOML.parsefile("Manifest.toml") + deps = manifest["deps"] + + # Verify TestPackage is in the manifest as a path dependency + deps_TestPackage = get(deps, "TestPackage", []) + @test !isempty(deps_TestPackage) + @test deps_TestPackage[1]["path"] == "." + @test deps_TestPackage[1]["uuid"] == "598b003f-0677-49cf-8d2a-39b1658b755a" + + # Verify Test stdlib is in the manifest + deps_Test = get(deps, "Test", []) + @test !isempty(deps_Test) + + # Verify the test/Project.toml was restored (still has sources section) + test_project = TOML.parsefile("test/Project.toml") + @test haskey(test_project, "sources") + @test haskey(test_project["sources"], "TestPackage") + end + end + end + + @testset "merged resolution with test dependencies" begin + mktempdir() do dir + cd(dir) do + # Create main Project.toml with JSON dependency + main_toml = """ + name = "TestPackage" + uuid = "598b003f-0677-49cf-8d2a-39b1658b755a" + version = "0.1.0" + + [deps] + JSON = "682c06a0-de6a-54ab-a142-c8b1cf79cde6" + + [compat] + julia = "1.10" + JSON = "0.20, 0.21" + + [workspace] + projects = ["test"] + """ + write("Project.toml", main_toml) + + # Create src directory and module + mkdir("src") + write("src/TestPackage.jl", "module TestPackage\nend\n") + + # Create test/Project.toml with additional test dependency and local source + mkdir("test") + test_toml = """ + [deps] + TestPackage = "598b003f-0677-49cf-8d2a-39b1658b755a" + Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + DataStructures = "864edb3b-99cc-5e75-8d2d-829cb0a9cfe8" + + [compat] + DataStructures = "0.17, 0.18" + + [sources.TestPackage] + path = ".." + """ + write("test/Project.toml", test_toml) + + # Run the downgrade script with merged resolution + run(`$(Base.julia_cmd()) $downgrade_jl "" ".,test" "deps" "1.10"`) + + # Verify Manifest.toml was created + @test isfile("Manifest.toml") + + # Parse the manifest + manifest = TOML.parsefile("Manifest.toml") + deps = manifest["deps"] + + # Verify main dependency JSON is minimized + deps_JSON = get(deps, "JSON", []) + @test !isempty(deps_JSON) + @test startswith(deps_JSON[1]["version"], "0.20") + + # Verify test dependency DataStructures is minimized + deps_DataStructures = get(deps, "DataStructures", []) + @test !isempty(deps_DataStructures) + @test startswith(deps_DataStructures[1]["version"], "0.17") + + # Verify TestPackage is in the manifest as a path dependency + deps_TestPackage = get(deps, "TestPackage", []) + @test !isempty(deps_TestPackage) + @test deps_TestPackage[1]["path"] == "." + end + end + end end From 96d038b3ff77d9699d147da1ccbda2dfd26ae7f9 Mon Sep 17 00:00:00 2001 From: Christopher Rackauckas Date: Thu, 18 Dec 2025 06:21:33 -0500 Subject: [PATCH 5/8] Update downgrade.jl Co-authored-by: Seth Axen --- downgrade.jl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/downgrade.jl b/downgrade.jl index 2c041fe..1013eca 100644 --- a/downgrade.jl +++ b/downgrade.jl @@ -28,7 +28,10 @@ function get_local_source_packages(project_file::String) project = TOML.parsefile(project_file) # Check for [sources] section entries with path keys - sources = get(project, "sources", Dict()) + if !haskey(project, "sources") + return local_pkgs + end + sources = project["sources"] for (pkg_name, source_info) in sources if source_info isa Dict && haskey(source_info, "path") push!(local_pkgs, pkg_name) From 89ee53777695106eba0e33a589e6e9cda9057cd6 Mon Sep 17 00:00:00 2001 From: Christopher Rackauckas Date: Thu, 18 Dec 2025 06:22:16 -0500 Subject: [PATCH 6/8] Update downgrade.jl Co-authored-by: Seth Axen --- downgrade.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/downgrade.jl b/downgrade.jl index 1013eca..c611f54 100644 --- a/downgrade.jl +++ b/downgrade.jl @@ -43,7 +43,7 @@ function get_local_source_packages(project_file::String) end """ - remove_local_packages_from_project(project_file, local_pkgs) + remove_source_packages_from_project(project_file, source_pkgs) Create a modified version of the Project.toml with local source packages removed from [deps], [compat], [extras], and [sources] sections. From 10eed4e851afee8529da9b4497c9b73aa3f9ac56 Mon Sep 17 00:00:00 2001 From: ChrisRackauckas Date: Thu, 18 Dec 2025 06:35:43 -0500 Subject: [PATCH 7/8] Address review feedback: rename functions, handle URL sources, modularize code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit addresses the review comments on PR #37: 1. Renamed `get_local_source_packages` to `get_source_packages` since sources can be remote (URLs) not just local paths 2. Updated function to handle both `path` and `url` sources per https://pkgdocs.julialang.org/v1/toml-files/#The-[sources]-section 3. Renamed `remove_local_packages_from_project` to `remove_source_packages_from_project` for consistency 4. Simplified the section removal loop using a for loop over section names instead of duplicating code for deps, extras, compat, and sources 5. Added `resolve_directory` helper function to eliminate code duplication between the `other_dirs` loop and the `else` branch 6. Added `check_for_workspace` function to warn when non-standard workspaces are detected (nested environments, docs, etc. are not fully supported) 7. Applied JuliaFormatter with SciMLStyle All 28 tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- downgrade.jl | 293 +++++++++++++++++++++++++-------------------------- 1 file changed, 145 insertions(+), 148 deletions(-) diff --git a/downgrade.jl b/downgrade.jl index c611f54..74f22d0 100644 --- a/downgrade.jl +++ b/downgrade.jl @@ -9,51 +9,57 @@ valid_modes = ["deps", "alldeps", "weakdeps", "forcedeps"] mode in valid_modes || error("mode must be one of: $(join(valid_modes, ", "))") """ - get_local_source_packages(project_file) + get_source_packages(project_file) -Parse a Project.toml and find packages that have local path sources. +Parse a Project.toml and find packages that have custom sources (path or url). Returns a Set of package names that should be excluded from resolution -because they are sourced from local paths (e.g., the main package in test/Project.toml). +because they are sourced from local paths or URLs (e.g., the main package in test/Project.toml). In Julia 1.13+, test dependencies often use [sources.PackageName] with path=".." to reference the main package. These cannot be resolved from the registry. +Packages can also be sourced from URLs, which similarly should be excluded from resolution. """ -function get_local_source_packages(project_file::String) - local_pkgs = Set{String}() +function get_source_packages(project_file::String) + source_pkgs = Set{String}() if !isfile(project_file) - return local_pkgs + return source_pkgs end project = TOML.parsefile(project_file) - # Check for [sources] section entries with path keys + # Check for [sources] section entries with path or url keys if !haskey(project, "sources") - return local_pkgs + return source_pkgs end sources = project["sources"] for (pkg_name, source_info) in sources - if source_info isa Dict && haskey(source_info, "path") - push!(local_pkgs, pkg_name) - @info "Found local source package: $pkg_name (path=$(source_info["path"]))" + if source_info isa Dict + if haskey(source_info, "path") + push!(source_pkgs, pkg_name) + @info "Found source package: $pkg_name (path=$(source_info["path"]))" + elseif haskey(source_info, "url") + push!(source_pkgs, pkg_name) + @info "Found source package: $pkg_name (url=$(source_info["url"]))" + end end end - return local_pkgs + return source_pkgs end """ remove_source_packages_from_project(project_file, source_pkgs) -Create a modified version of the Project.toml with local source packages +Create a modified version of the Project.toml with source packages removed from [deps], [compat], [extras], and [sources] sections. Returns the original content so it can be restored later. Note: We must also remove from [sources] because Pkg validates that any package in [sources] must be in [deps] or [extras]. """ -function remove_local_packages_from_project(project_file::String, local_pkgs::Set{String}) - if isempty(local_pkgs) +function remove_source_packages_from_project(project_file::String, source_pkgs::Set{String}) + if isempty(source_pkgs) return nothing # No modification needed end @@ -61,53 +67,21 @@ function remove_local_packages_from_project(project_file::String, local_pkgs::Se project = TOML.parsefile(project_file) modified = false - # Remove from [deps] - if haskey(project, "deps") - for pkg in local_pkgs - if haskey(project["deps"], pkg) - delete!(project["deps"], pkg) - modified = true - @info "Temporarily removing $pkg from [deps] for resolution" - end - end - end - - # Remove from [extras] - if haskey(project, "extras") - for pkg in local_pkgs - if haskey(project["extras"], pkg) - delete!(project["extras"], pkg) - modified = true - @info "Temporarily removing $pkg from [extras] for resolution" - end - end - end - - # Remove from [compat] - if haskey(project, "compat") - for pkg in local_pkgs - if haskey(project["compat"], pkg) - delete!(project["compat"], pkg) - modified = true - @info "Temporarily removing $pkg from [compat] for resolution" - end + # Remove from [deps], [extras], [compat], and [sources] + for section_name in ("deps", "extras", "compat", "sources") + haskey(project, section_name) || continue + section = project[section_name] + for pkg in source_pkgs + haskey(section, pkg) || continue + delete!(section, pkg) + modified = true + @info "Temporarily removing $pkg from [$section_name] for resolution" end end - # Remove from [sources] - must do this because Pkg validates that - # packages in [sources] must be in [deps] or [extras] - if haskey(project, "sources") - for pkg in local_pkgs - if haskey(project["sources"], pkg) - delete!(project["sources"], pkg) - modified = true - @info "Temporarily removing $pkg from [sources] for resolution" - end - end - # Remove empty [sources] section - if isempty(project["sources"]) - delete!(project, "sources") - end + # Remove empty [sources] section + if haskey(project, "sources") && isempty(project["sources"]) + delete!(project, "sources") end if modified @@ -125,7 +99,8 @@ end Restore the original Project.toml content after resolution. """ -function restore_project_file(project_file::String, original_content::Union{String,Nothing}) +function restore_project_file(project_file::String, original_content::Union{ + String, Nothing}) if original_content !== nothing write(project_file, original_content) @info "Restored original Project.toml" @@ -139,14 +114,14 @@ Create a merged Project.toml that combines dependencies from both the main project and test project. This ensures that when tests run (which combine both environments), the resolved versions are compatible. -Returns a Set of local source packages that were excluded from the merge. +Returns a Set of source packages that were excluded from the merge. """ function create_merged_project(main_project_file::String, test_project_file::String, merged_dir::String) main_project = TOML.parsefile(main_project_file) test_project = TOML.parsefile(test_project_file) - # Get local source packages from test project (e.g., the main package itself) - local_pkgs = get_local_source_packages(test_project_file) + # Get source packages from test project (e.g., the main package itself) + source_pkgs = get_source_packages(test_project_file) # Start with a copy of the main project merged = deepcopy(main_project) @@ -154,13 +129,13 @@ function create_merged_project(main_project_file::String, test_project_file::Str # Remove workspace section (not needed for resolution) delete!(merged, "workspace") - # Merge deps from test project (excluding local source packages) + # Merge deps from test project (excluding source packages) test_deps = get(test_project, "deps", Dict()) if !haskey(merged, "deps") - merged["deps"] = Dict{String,Any}() + merged["deps"] = Dict{String, Any}() end for (pkg, uuid) in test_deps - if pkg ∉ local_pkgs && !haskey(merged["deps"], pkg) + if pkg ∉ source_pkgs && !haskey(merged["deps"], pkg) merged["deps"][pkg] = uuid @info "Adding test dependency to merged project: $pkg" end @@ -169,10 +144,10 @@ function create_merged_project(main_project_file::String, test_project_file::Str # Merge compat entries from test project test_compat = get(test_project, "compat", Dict()) if !haskey(merged, "compat") - merged["compat"] = Dict{String,Any}() + merged["compat"] = Dict{String, Any}() end for (pkg, compat) in test_compat - if pkg ∉ local_pkgs + if pkg ∉ source_pkgs if haskey(merged["compat"], pkg) # Both have compat - keep both constraints (Resolver.jl will find intersection) # For simplicity, we keep the main project's compat if they differ @@ -188,10 +163,10 @@ function create_merged_project(main_project_file::String, test_project_file::Str test_weakdeps = get(test_project, "weakdeps", Dict()) if !isempty(test_weakdeps) if !haskey(merged, "weakdeps") - merged["weakdeps"] = Dict{String,Any}() + merged["weakdeps"] = Dict{String, Any}() end for (pkg, uuid) in test_weakdeps - if pkg ∉ local_pkgs && !haskey(merged["weakdeps"], pkg) + if pkg ∉ source_pkgs && !haskey(merged["weakdeps"], pkg) merged["weakdeps"][pkg] = uuid @info "Adding test weakdep to merged project: $pkg" end @@ -206,7 +181,7 @@ function create_merged_project(main_project_file::String, test_project_file::Str end @info "Created merged project at $merged_file" - return local_pkgs + return source_pkgs end """ @@ -282,6 +257,85 @@ function add_main_package_to_manifest(manifest_file::String, main_project_file:: @info "Added main package $pkg_name to manifest" end +""" + resolve_directory(dir, resolver_path, resolver_mode, julia_version, mode, ignore_pkgs) + +Resolve dependencies for a single directory. Handles source packages by temporarily +removing them from the project file, running the resolver, and then restoring the original. +Returns the source packages found in the directory (for use in forcedeps checking). +""" +function resolve_directory( + dir::AbstractString, resolver_path::AbstractString, resolver_mode::AbstractString, + julia_version::AbstractString, mode::AbstractString, ignore_pkgs) + project_files = [joinpath(dir, "Project.toml"), joinpath(dir, "JuliaProject.toml")] + filter!(isfile, project_files) + isempty(project_files) && + error("could not find Project.toml or JuliaProject.toml in $dir") + + project_file = first(project_files) + manifest_file = joinpath(dir, "Manifest.toml") + + # Handle packages with [sources] entries (e.g., test/Project.toml referencing main package) + # These packages cannot be resolved from the registry, so we temporarily remove them + source_pkgs = get_source_packages(project_file) + original_content = remove_source_packages_from_project(project_file, source_pkgs) + + try + @info "Running resolver on $dir with --min=@$resolver_mode" + run(`julia --project=$resolver_path/bin $resolver_path/bin/resolve.jl $dir --min=@$resolver_mode --julia=$julia_version`) + @info "Successfully resolved minimal versions for $dir" + finally + # Always restore the original Project.toml, even if resolution fails + restore_project_file(project_file, original_content) + end + + # For forcedeps mode, verify that the resolved versions match the lower bounds + # Note: we check against the original project file (now restored), but skip source packages + if mode == "forcedeps" + @info "Checking that resolved versions match forced lower bounds for $dir..." + forcedeps_ignore = union(ignore_pkgs, source_pkgs) + if !check_forced_lower_bounds(project_file, manifest_file, forcedeps_ignore) + error(""" + forcedeps check failed for $dir: Some packages did not resolve to their lower bounds. + + This means the lowest compatible versions of your direct dependencies are + incompatible with each other. To fix this, you need to increase the lower + bounds in your compat entries to versions that are mutually compatible. + + See the errors above for which packages need their bounds adjusted. + """) + end + @info "All forcedeps checks passed for $dir" + end + + return source_pkgs +end + +""" + check_for_workspace(project_file) + +Check if a project file defines workspaces and print a warning if so. +Workspaces with nested environments are not fully supported. +""" +function check_for_workspace(project_file::String) + if !isfile(project_file) + return + end + + project = TOML.parsefile(project_file) + + if haskey(project, "workspace") + workspace = project["workspace"] + projects = get(workspace, "projects", []) + if length(projects) > 1 || (length(projects) == 1 && projects[1] != "test") + @warn """Workspace with multiple or non-standard projects detected. + This action currently only supports merging main (.) and test environments. + Nested workspaces or additional workspace projects (e.g., docs, integration tests) + are not fully supported and may not be resolved correctly.""" + end + end +end + @info "Using Resolver.jl with mode: $mode" # Clone the resolver @@ -304,7 +358,7 @@ Uses the same logic as v1 of the action: - Skips julia and ignored packages """ function get_lower_bounds(project_file::String, ignore_pkgs) - bounds = Dict{String,VersionNumber}() + bounds = Dict{String, VersionNumber}() lines = readlines(project_file) in_compat = false @@ -367,7 +421,7 @@ Parse a Manifest.toml and extract the resolved versions for each package. Returns a Dict mapping package names to their resolved VersionNumber. """ function get_resolved_versions(manifest_file::String) - versions = Dict{String,VersionNumber}() + versions = Dict{String, VersionNumber}() if !isfile(manifest_file) return versions @@ -438,6 +492,12 @@ end # For forcedeps, we use "deps" mode and then verify the results resolver_mode = mode == "forcedeps" ? "deps" : mode +# Check for workspaces in main project and warn if detected +main_project_candidates = ["./Project.toml", "./JuliaProject.toml"] +for candidate in main_project_candidates + check_for_workspace(candidate) +end + # Check if we should merge main and test projects (do_merge, main_dir, test_dir) = should_merge_projects(dirs) @@ -446,9 +506,11 @@ if do_merge @info "Merging main (.) and test projects for combined resolution" main_project_file = isfile(joinpath(main_dir, "Project.toml")) ? - joinpath(main_dir, "Project.toml") : joinpath(main_dir, "JuliaProject.toml") + joinpath(main_dir, "Project.toml") : + joinpath(main_dir, "JuliaProject.toml") test_project_file = isfile(joinpath(test_dir, "Project.toml")) ? - joinpath(test_dir, "Project.toml") : joinpath(test_dir, "JuliaProject.toml") + joinpath(test_dir, "Project.toml") : + joinpath(test_dir, "JuliaProject.toml") if !isfile(main_project_file) error("could not find Project.toml or JuliaProject.toml in $main_dir") @@ -459,7 +521,7 @@ if do_merge # Create merged project in temp directory merged_dir = mktempdir() - local_pkgs = create_merged_project(main_project_file, test_project_file, merged_dir) + source_pkgs = create_merged_project(main_project_file, test_project_file, merged_dir) # Run resolver on merged project @info "Running resolver on merged project with --min=@$resolver_mode" @@ -470,7 +532,7 @@ if do_merge merged_manifest = joinpath(merged_dir, "Manifest.toml") main_manifest = joinpath(main_dir, "Manifest.toml") if isfile(merged_manifest) - cp(merged_manifest, main_manifest; force=true) + cp(merged_manifest, main_manifest; force = true) @info "Copied merged manifest to $main_manifest" # Add the main package itself to the manifest as a path dependency @@ -481,7 +543,7 @@ if do_merge # For forcedeps mode, verify lower bounds for both projects if mode == "forcedeps" @info "Checking that resolved versions match forced lower bounds..." - forcedeps_ignore = union(ignore_pkgs, local_pkgs) + forcedeps_ignore = union(ignore_pkgs, source_pkgs) # Check main project if !check_forced_lower_bounds(main_project_file, main_manifest, forcedeps_ignore) @@ -496,7 +558,7 @@ if do_merge """) end - # Check test project (excluding local source packages) + # Check test project (excluding source packages) if !check_forced_lower_bounds(test_project_file, main_manifest, forcedeps_ignore) error(""" forcedeps check failed: Some test dependencies did not resolve to their lower bounds. @@ -511,78 +573,13 @@ if do_merge # Process any remaining directories that aren't main or test other_dirs = filter(d -> d != "." && d != "test", dirs) for dir in other_dirs - project_files = [joinpath(dir, "Project.toml"), joinpath(dir, "JuliaProject.toml")] - filter!(isfile, project_files) - isempty(project_files) && error("could not find Project.toml or JuliaProject.toml in $dir") - - project_file = first(project_files) - manifest_file = joinpath(dir, "Manifest.toml") - - dir_local_pkgs = get_local_source_packages(project_file) - original_content = remove_local_packages_from_project(project_file, dir_local_pkgs) - - try - @info "Running resolver on $dir with --min=@$resolver_mode" - run(`julia --project=$resolver_path/bin $resolver_path/bin/resolve.jl $dir --min=@$resolver_mode --julia=$julia_version`) - @info "Successfully resolved minimal versions for $dir" - finally - restore_project_file(project_file, original_content) - end - - if mode == "forcedeps" - @info "Checking that resolved versions match forced lower bounds for $dir..." - local forcedeps_ignore = union(ignore_pkgs, dir_local_pkgs) - if !check_forced_lower_bounds(project_file, manifest_file, forcedeps_ignore) - error(""" - forcedeps check failed for $dir: Some packages did not resolve to their lower bounds. - See the errors above for which packages need their bounds adjusted. - """) - end - @info "All forcedeps checks passed for $dir" - end + resolve_directory( + dir, resolver_path, resolver_mode, julia_version, mode, ignore_pkgs) end else # Independent resolution: process each directory separately for dir in dirs - project_files = [joinpath(dir, "Project.toml"), joinpath(dir, "JuliaProject.toml")] - filter!(isfile, project_files) - isempty(project_files) && error("could not find Project.toml or JuliaProject.toml in $dir") - - project_file = first(project_files) - manifest_file = joinpath(dir, "Manifest.toml") - - # Handle packages with local [sources] entries (e.g., test/Project.toml referencing main package) - # These packages cannot be resolved from the registry, so we temporarily remove them - local local_pkgs = get_local_source_packages(project_file) - original_content = remove_local_packages_from_project(project_file, local_pkgs) - - try - @info "Running resolver on $dir with --min=@$resolver_mode" - run(`julia --project=$resolver_path/bin $resolver_path/bin/resolve.jl $dir --min=@$resolver_mode --julia=$julia_version`) - @info "Successfully resolved minimal versions for $dir" - finally - # Always restore the original Project.toml, even if resolution fails - restore_project_file(project_file, original_content) - end - - # For forcedeps mode, verify that the resolved versions match the lower bounds - # Note: we check against the original project file (now restored), but skip local source packages - if mode == "forcedeps" - @info "Checking that resolved versions match forced lower bounds..." - # Add local source packages to the ignore list for forcedeps check - local forcedeps_ignore = union(ignore_pkgs, local_pkgs) - if !check_forced_lower_bounds(project_file, manifest_file, forcedeps_ignore) - error(""" - forcedeps check failed: Some packages did not resolve to their lower bounds. - - This means the lowest compatible versions of your direct dependencies are - incompatible with each other. To fix this, you need to increase the lower - bounds in your compat entries to versions that are mutually compatible. - - See the errors above for which packages need their bounds adjusted. - """) - end - @info "All forcedeps checks passed for $dir" - end + resolve_directory( + dir, resolver_path, resolver_mode, julia_version, mode, ignore_pkgs) end end From b8b4b077dcb615e517005a34bf019d72390f7e4e Mon Sep 17 00:00:00 2001 From: Joshua Lampert Date: Fri, 9 Jan 2026 18:12:24 +0100 Subject: [PATCH 8/8] Fix julia_version conversion and update command execution to use Base.julia_cmd() --- downgrade.jl | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/downgrade.jl b/downgrade.jl index 74f22d0..5a61295 100644 --- a/downgrade.jl +++ b/downgrade.jl @@ -5,6 +5,13 @@ dirs = filter(!isempty, map(strip, split(ARGS[2], ","))) mode = length(ARGS) >= 3 ? ARGS[3] : "deps" julia_version = length(ARGS) >= 4 ? ARGS[4] : "1.10" +# Convert "1" to the current running Julia version (e.g., "1.12" for Julia 1.12.3) +# This ensures the resolved manifest matches the Julia version that will read it +if julia_version == "1" + julia_version = string(VERSION.major, ".", VERSION.minor) + @info "Converted julia_version \"1\" to \"$julia_version\" (current Julia version)" +end + valid_modes = ["deps", "alldeps", "weakdeps", "forcedeps"] mode in valid_modes || error("mode must be one of: $(join(valid_modes, ", "))") @@ -282,7 +289,7 @@ function resolve_directory( try @info "Running resolver on $dir with --min=@$resolver_mode" - run(`julia --project=$resolver_path/bin $resolver_path/bin/resolve.jl $dir --min=@$resolver_mode --julia=$julia_version`) + run(`$(Base.julia_cmd()) --project=$resolver_path/bin $resolver_path/bin/resolve.jl $dir --min=@$resolver_mode --julia=$julia_version`) @info "Successfully resolved minimal versions for $dir" finally # Always restore the original Project.toml, even if resolution fails @@ -343,7 +350,7 @@ resolver_path = mktempdir() @info "Cloning Resolver.jl" run(`git clone https://github.com/StefanKarpinski/Resolver.jl.git $resolver_path`) # Install dependencies -run(`julia --project=$resolver_path/bin -e "using Pkg; Pkg.instantiate()"`) +run(`$(Base.julia_cmd()) --project=$resolver_path/bin -e "using Pkg; Pkg.instantiate()"`) """ get_lower_bounds(project_file, ignore_pkgs) @@ -525,7 +532,7 @@ if do_merge # Run resolver on merged project @info "Running resolver on merged project with --min=@$resolver_mode" - run(`julia --project=$resolver_path/bin $resolver_path/bin/resolve.jl $merged_dir --min=@$resolver_mode --julia=$julia_version`) + run(`$(Base.julia_cmd()) --project=$resolver_path/bin $resolver_path/bin/resolve.jl $merged_dir --min=@$resolver_mode --julia=$julia_version`) @info "Successfully resolved minimal versions for merged project" # Copy manifest to main project directory