From 86d78a54cfec442a8d07ebfb6125a77f31acd4d8 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Mon, 4 May 2026 22:28:50 -0400 Subject: [PATCH 01/29] Add subdir-aware inputs to CompatHelper, Documentation, and Tests Three small additive inputs that let consumers with in-tree subpackages (notably ITensors.jl + NDTensors) drop their custom workflow files in favor of the standard reusables: - CompatHelper.yml `subdirs`: newline-separated list of subdirectories whose Project.toml should be checked for compat bumps. Default preserves the historical hardcoded list (root + docs + examples + test). - Documentation.yml `extra-dev-paths`: newline-separated list of additional local package paths to Pkg.develop() before the docs build. Mirrors the existing input on IntegrationTest.yml. - Tests.yml `test-args`: surfaces julia-actions/julia-runtest's `test_args` input for callers whose runtests.jl reads ARGS instead of the GROUP env-var. All three have defaults that preserve current behavior; existing consumers see no change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/CompatHelper.yml | 8 +++++++- .github/workflows/Documentation.yml | 10 +++++++++- .github/workflows/Tests.yml | 6 ++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CompatHelper.yml b/.github/workflows/CompatHelper.yml index 9ab7dfe..3cdd155 100644 --- a/.github/workflows/CompatHelper.yml +++ b/.github/workflows/CompatHelper.yml @@ -14,6 +14,11 @@ on: default: "" required: false type: string + subdirs: + description: "Newline-separated list of subdirectories (relative to the repo root) whose Project.toml files should also be checked for compat updates. An empty entry represents the repo root itself. Default covers root + docs + examples + test." + default: "\ndocs\nexamples\ntest" + required: false + type: string jobs: compathelper: @@ -73,6 +78,7 @@ jobs: # (see https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication#using-the-github_token-in-a-workflow). GITHUB_TOKEN: ${{ secrets.COMPATHELPER_PAT || secrets.GITHUB_TOKEN }} LOCALREGISTRY: ${{ inputs.localregistry }} + SUBDIRS: ${{ inputs.subdirs }} TOKEN_OWNER: ${{ steps.token-owner.outputs.login }} run: | import CompatHelper @@ -106,7 +112,7 @@ jobs: push!(registries, Pkg.RegistrySpec(; url=registry_url, name=registry_name)) end end - subdirs = ["", "docs", "examples", "test"] + subdirs = string.(split(get(ENV, "SUBDIRS", ""), "\n")) token_owner = get(ENV, "TOKEN_OWNER", "") # If TOKEN_OWNER contains a JSON error body (happens when only GITHUB_TOKEN # is available, e.g. in private repos without COMPATHELPER_PAT), treat it as diff --git a/.github/workflows/Documentation.yml b/.github/workflows/Documentation.yml index c7f1cd6..07ea8fd 100644 --- a/.github/workflows/Documentation.yml +++ b/.github/workflows/Documentation.yml @@ -22,6 +22,11 @@ on: required: false description: 'Add local registries hosted on GitHub. Specified by providing the url (https/ssh) to the repositories as a newline (\n) seperated list. User is responsible for setting up the necessary SSH-Keys to access the repositories if necessary.' default: '' + extra-dev-paths: + description: "Additional local package paths (relative to the repo root) to develop alongside the repository root before the docs build. Newline-separated list. Useful for repos with in-tree subpackages (e.g. ITensors.jl + NDTensors)." + default: "" + required: false + type: string self-hosted: description: "Run the job needs on a self hosted machine" default: false @@ -111,6 +116,7 @@ jobs: shell: "bash" env: LOCALREGISTRY: ${{ inputs.localregistry }} + EXTRA_DEV_PATHS: ${{ inputs.extra-dev-paths }} # `doc-prefix` is rendered directly into the script body so that any # shell quoting in the caller's value (e.g. the single-quoted server # args of `xvfb-run -a -s '-screen 0 1024x768x24'`) is parsed by the @@ -137,7 +143,9 @@ jobs: Pkg.Registry.add(Pkg.RegistrySpec(; url=repo_url)) end end - Pkg.develop(Pkg.PackageSpec(path=pwd())) + dev_paths = [pwd(), split(get(ENV, "EXTRA_DEV_PATHS", ""), "\n")...] + filter!(!isempty, dev_paths) + Pkg.develop([Pkg.PackageSpec(; path) for path in dev_paths]) # Explicit instantiate so docs/Manifest.toml resolution failures # (e.g. compat conflicts that would leave packages like Documenter # missing from the depot) surface here rather than silently diff --git a/.github/workflows/Tests.yml b/.github/workflows/Tests.yml index 4b8e92a..ad2ea11 100644 --- a/.github/workflows/Tests.yml +++ b/.github/workflows/Tests.yml @@ -67,6 +67,11 @@ on: default: "yes" required: false type: string + test-args: + description: "Newline-separated list of arguments passed to `Pkg.test(; test_args=...)`. Surfaces julia-actions/julia-runtest's `test_args` input. Useful when `runtests.jl` selects test groups via ARGS rather than the GROUP env-var." + default: "" + required: false + type: string continue-on-error: description: "Prevent the workflow run from failing if/when the job fails" required: false @@ -162,6 +167,7 @@ jobs: depwarn: "${{ inputs.julia-runtest-depwarn }}" coverage: "${{ inputs.coverage }}" prefix: "${{ inputs.test-prefix }}" + test_args: "${{ inputs.test-args }}" # On LTS, accept that newer dep versions may not be reachable — # let Pkg resolve to whatever satisfies both the workspace compat # and transitive constraints. `auto` (the default) would otherwise From 8070e12c694e9b74a173d9a7c58acc80c4936964 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Mon, 4 May 2026 22:39:01 -0400 Subject: [PATCH 02/29] Tests.yml: add extra-dev-paths input for in-tree subpackages Mirrors the extra-dev-paths inputs on Documentation.yml and IntegrationTest.yml. Needed for repos where the package's in-tree subpackage must shadow any registered version during testing (notably ITensors.jl + ./NDTensors). A new pre-buildpkg step runs `Pkg.develop` on each path into the package's active project, so subsequent buildpkg + julia-runtest see the in-tree subpackage instead of resolving the registered one. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/Tests.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/Tests.yml b/.github/workflows/Tests.yml index ad2ea11..f4fd723 100644 --- a/.github/workflows/Tests.yml +++ b/.github/workflows/Tests.yml @@ -72,6 +72,11 @@ on: default: "" required: false type: string + extra-dev-paths: + description: "Additional local package paths (relative to the repo root) to `Pkg.develop()` into the active project before running tests. Newline-separated list. Useful for repos with in-tree subpackages (e.g. ITensors.jl + NDTensors) where the in-tree dep must shadow any registered version during testing." + default: "" + required: false + type: string continue-on-error: description: "Prevent the workflow run from failing if/when the job fails" required: false @@ -147,6 +152,17 @@ jobs: with: token: "${{ secrets.GITHUB_TOKEN }}" + - name: "Pkg.develop extra paths" + if: "${{ inputs.extra-dev-paths != '' }}" + shell: julia --color=yes {0} + env: + EXTRA_DEV_PATHS: ${{ inputs.extra-dev-paths }} + run: | + using Pkg + dev_paths = filter(!isempty, string.(split(get(ENV, "EXTRA_DEV_PATHS", ""), "\n"))) + Pkg.activate(".") + Pkg.develop([Pkg.PackageSpec(; path) for path in dev_paths]) + - uses: julia-actions/julia-buildpkg@v1 if: "${{ inputs.buildpkg }}" with: From f3c9cb7c9df439eec9c0260f10322eff98ad0069 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Mon, 4 May 2026 22:53:01 -0400 Subject: [PATCH 03/29] Registrator.yml: add subdir input for in-tree subpackage registration Lets multi-package repos (notably ITensors.jl + NDTensors) auto-register each in-tree package by invoking the Registrator reusable once per subdir, typically via a matrix. The subdir is prepended to all package-relative paths and surfaces in the JuliaRegistrator commit comment as `@JuliaRegistrator register subdir=` so the registrar picks up the right Project.toml. Default empty preserves the existing single-package root behavior; no behavior change for current consumers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/Registrator.yml | 34 +++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/.github/workflows/Registrator.yml b/.github/workflows/Registrator.yml index cee6e20..3d5c6a0 100644 --- a/.github/workflows/Registrator.yml +++ b/.github/workflows/Registrator.yml @@ -18,6 +18,11 @@ on: default: "/register" required: false type: string + subdir: + description: "Subdirectory containing the package's Project.toml, relative to the repo root. Default empty means register the root package. Set to e.g. 'NDTensors' to register an in-tree subpackage; the workflow then reads `package//Project.toml` and posts `@JuliaRegistrator register subdir=` so JuliaRegistrator picks up the sub-package correctly." + default: "" + required: false + type: string secrets: # Only needed for the local-registry path (checkout + PR). Not needed for General. REGISTRATOR_PAT: @@ -83,6 +88,7 @@ jobs: id: meta env: OLD_REF: ${{ github.event.before }} + SUBDIR: ${{ inputs.subdir }} shell: julia --color=yes {0} run: | using TOML @@ -108,14 +114,17 @@ jobs: end let - new = TOML.parsefile("package/Project.toml") + subdir = get(ENV, "SUBDIR", "") + project_path = isempty(subdir) ? "Project.toml" : joinpath(subdir, "Project.toml") + + new = TOML.parsefile(joinpath("package", project_path)) name = get(new, "name", "") uuid = get(new, "uuid", "") newv_str = get(new, "version", "") - isempty(name) && error("Project.toml is missing name") - isempty(uuid) && error("Project.toml is missing uuid") - isempty(newv_str) && error("Project.toml is missing version") + isempty(name) && error("$project_path is missing name") + isempty(uuid) && error("$project_path is missing uuid") + isempty(newv_str) && error("$project_path is missing version") newv = parse_version(newv_str, "new") subject = replace(readchomp(`git -C package log -1 --pretty=%s HEAD`), ['\n','\r'] => ' ') @@ -133,11 +142,11 @@ jobs: oldv_str = "" if !isempty(old_ref) try - old_toml = readchomp(`git -C package show $old_ref:Project.toml`) + old_toml = readchomp(`git -C package show $old_ref:$project_path`) oldv_str = get(TOML.parse(old_toml), "version", "") catch err # Don't hard-fail on missing old Project.toml; just skip strict validation. - println(stderr, "Warning: could not read Project.toml at ref '$old_ref' (treating as no prior version): $err") + println(stderr, "Warning: could not read $project_path at ref '$old_ref' (treating as no prior version): $err") oldv_str = "" end end @@ -217,9 +226,14 @@ jobs: IS_BREAKING: ${{ steps.meta.outputs.is_breaking }} SUBJECT: ${{ steps.meta.outputs.subject }} NEW_VERSION: ${{ steps.meta.outputs.new_version }} + SUBDIR: ${{ inputs.subdir }} run: | { - echo "@JuliaRegistrator register" + if [ -n "$SUBDIR" ]; then + echo "@JuliaRegistrator register subdir=$SUBDIR" + else + echo "@JuliaRegistrator register" + fi if [ "$IS_BREAKING" = "true" ]; then echo "" echo "Release notes:" @@ -336,6 +350,8 @@ jobs: - name: "Update local registry" if: steps.meta.outputs.route == 'local' + env: + SUBDIR: ${{ inputs.subdir }} shell: julia --color=yes {0} run: | import Pkg @@ -344,7 +360,9 @@ jobs: uuid="89398ba2-070a-4b16-a995-9893c55d93cf", version="0.5.7")) using LocalRegistry - register("./package"; registry="./registry", commit=false, push=false) + subdir = get(ENV, "SUBDIR", "") + pkg_path = isempty(subdir) ? "./package" : joinpath("./package", subdir) + register(pkg_path; registry="./registry", commit=false, push=false) - name: "Create PR to registry" if: steps.meta.outputs.route == 'local' From ce3cc685daa0ddcac564b97e966efc0a123c8184 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Mon, 4 May 2026 23:00:27 -0400 Subject: [PATCH 04/29] Tests.yml: pre-step respects inputs.project; VersionCheck: subdir support - Tests.yml: the Pkg.develop pre-step previously hardcoded Pkg.activate(".") which would shadow any caller-supplied project input. Now reads inputs.project so the dev paths are added to the active project being tested (matters when project=NDTensors). - VersionCheck.yml: add `subdirs` input. With it set, the workflow classifies changed files into per-project scopes (root + each subdir) and validates that each substantively-changed project's Project.toml was bumped. Backward-compatible: empty subdirs reduces to the existing single-project root check, and the metadata-only PR case still passes without bump (root scope is empty). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/Tests.yml | 4 +- .github/workflows/VersionCheck.yml | 146 ++++++++++++++++++++--------- 2 files changed, 106 insertions(+), 44 deletions(-) diff --git a/.github/workflows/Tests.yml b/.github/workflows/Tests.yml index f4fd723..ca794d9 100644 --- a/.github/workflows/Tests.yml +++ b/.github/workflows/Tests.yml @@ -157,10 +157,12 @@ jobs: shell: julia --color=yes {0} env: EXTRA_DEV_PATHS: ${{ inputs.extra-dev-paths }} + PROJECT: ${{ inputs.project }} run: | using Pkg dev_paths = filter(!isempty, string.(split(get(ENV, "EXTRA_DEV_PATHS", ""), "\n"))) - Pkg.activate(".") + project = get(ENV, "PROJECT", "@.") + Pkg.activate(project) Pkg.develop([Pkg.PackageSpec(; path) for path in dev_paths]) - uses: julia-actions/julia-buildpkg@v1 diff --git a/.github/workflows/VersionCheck.yml b/.github/workflows/VersionCheck.yml index 19934f5..dd4f35c 100644 --- a/.github/workflows/VersionCheck.yml +++ b/.github/workflows/VersionCheck.yml @@ -13,6 +13,11 @@ on: default: "" required: false type: string + subdirs: + description: "Newline-separated list of subdirectories that contain in-tree subpackages with their own Project.toml. When set, each subdir's Project.toml is checked for a version bump independently of the root Project.toml. Each project's check fires only if the PR has substantive changes inside that project's scope: a file in `/**` for a named subdir, or a file outside (`.github/**`, `.pre-commit-config.yaml`, `.gitignore`, `LICENSE`, and any named subdir) for the root." + default: "" + required: false + type: string jobs: version-check: @@ -25,63 +30,118 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: ITensor/ITensorActions/.github/actions/classify-pr@main - id: classify - - - name: "Skip version check on non-substantive PR" - if: steps.classify.outputs.substantive != 'true' - run: | - echo "PR classified as non-substantive (only .github/**, .pre-commit-config.yaml, .gitignore, LICENSE)." - echo "No version bump is required. Reporting success without running the real check." - - name: "Fetch base branch" - if: steps.classify.outputs.substantive == 'true' + if: github.event_name == 'pull_request' run: git fetch --depth=1 origin "${{ github.base_ref }}" - uses: julia-actions/setup-julia@v3 - if: steps.classify.outputs.substantive == 'true' + if: github.event_name == 'pull_request' with: version: ${{ inputs.julia-version }} - - name: "Check version was bumped" - if: steps.classify.outputs.substantive == 'true' + - name: "Check version bumps per project" + if: github.event_name == 'pull_request' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_REF: ${{ github.base_ref }} + SUBDIRS: ${{ inputs.subdirs }} shell: julia --color=yes {0} run: | using TOML - base_ref = ENV["GITHUB_BASE_REF"] - base_project_text = try - read(`git show "origin/$base_ref:Project.toml"`, String) - catch err - println("Could not read Project.toml on origin/$base_ref ($err); skipping check.") - exit(0) + # Substantive-file rules: at the repo root, ignore CI/config + # metadata files. Inside a named subdir, every change counts. + # Per-subdir classification means each project (root + each + # named subdir) gets its own scope and is checked + # independently — root bump required only when files outside + # the metadata set AND outside any named subdir change; named + # subdir bump required only when files inside that subdir + # change. + const IGNORE_AT_ROOT = (".github/", ".pre-commit-config.yaml", ".gitignore", "LICENSE") + + subdirs = filter(!isempty, string.(split(get(ENV, "SUBDIRS", ""), "\n"))) + projects = vcat([""], subdirs) + + base_ref = ENV["BASE_REF"] + repo = ENV["REPO"] + pr_number = ENV["PR_NUMBER"] + + files_text = read(`gh api --paginate "repos/$repo/pulls/$pr_number/files" --jq ".[].filename"`, String) + changed_files = filter(!isempty, string.(split(strip(files_text), '\n'))) + println("Changed files in PR #$pr_number:") + for f in changed_files + println(" $f") + end + + function in_scope(file, project, all_subdirs) + if isempty(project) + any(startswith(file, p) || file == p for p in IGNORE_AT_ROOT) && return false + any(startswith(file, sd * "/") for sd in all_subdirs) && return false + return true + else + return startswith(file, project * "/") + end end - base_project = TOML.parse(base_project_text) - current_project = TOML.parse(read("Project.toml", String)) - if !haskey(base_project, "version") - println("Base branch Project.toml has no version field; skipping check.") - exit(0) + function project_path(project) + isempty(project) ? "Project.toml" : joinpath(project, "Project.toml") end - if !haskey(current_project, "version") - error( - "Project.toml on this branch has no version field, but the base " * - "branch ($base_ref) does. Restore the version field and bump it." - ) + + function check_project(project, all_subdirs, base_ref, files) + path = project_path(project) + label = isempty(project) ? "root" : project + + substantive = filter(f -> in_scope(f, project, all_subdirs), files) + if isempty(substantive) + println("$label: no substantive changes; skipping version-bump check.") + return true + end + + println("$label: substantive files changed:") + for f in substantive + println(" $f") + end + + base_text = try + read(`git show "origin/$base_ref:$path"`, String) + catch err + println("$label: could not read $path on origin/$base_ref ($err); skipping check.") + return true + end + + if !isfile(path) + println("$label: $path is missing in PR head; skipping check.") + return true + end + + base_proj = TOML.parse(base_text) + current_proj = TOML.parse(read(path, String)) + + if !haskey(base_proj, "version") + println("$label: base $path has no version field; skipping check.") + return true + end + if !haskey(current_proj, "version") + println(stderr, "ERROR: $label: $path on PR head has no version field, but base does. Restore the version and bump it.") + return false + end + + base_v = VersionNumber(base_proj["version"]) + current_v = VersionNumber(current_proj["version"]) + + if current_v > base_v + println("$label: $path bumped from $base_v to $current_v. OK.") + return true + else + println(stderr, "ERROR: $label has substantive changes but $path version was not bumped (current $current_v not > base $base_v).") + return false + end end - base_version = VersionNumber(base_project["version"]) - current_version = VersionNumber(current_project["version"]) - - if current_version > base_version - println( - "OK: Project.toml version bumped from $base_version " * - "(origin/$base_ref) to $current_version (PR head)." - ) - else - error( - "Project.toml version was not bumped. Current version $current_version " * - "is not greater than the version $base_version on the base branch " * - "($base_ref). Bump the version in Project.toml." - ) + all_pass = true + for p in projects + all_pass &= check_project(p, subdirs, base_ref, changed_files) end + all_pass || error("VersionCheck failed; see errors above.") From f5f5b3dfb2eed1fd65f7e969998d64b9289a8b04 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 10:33:08 -0400 Subject: [PATCH 05/29] VersionCheck.yml: fix Julia hard-scope bug in per-project results loop The previous version did `all_pass = true` then `all_pass &= ...` inside a `for` loop. Julia's hard-scope rule for top-level `for` loops in a script context shadows outer variables on assignment, so the `&=` tried to write to a fresh local `all_pass` without first reading it, producing `UndefVarError: \`all_pass\` not defined in local scope`. Replace with a comprehension that evaluates every project (so all errors surface before failing) plus an `all(results)` check. Caught by the new VersionCheck running on ITensors.jl#1743. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/VersionCheck.yml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.github/workflows/VersionCheck.yml b/.github/workflows/VersionCheck.yml index dd4f35c..c00a78c 100644 --- a/.github/workflows/VersionCheck.yml +++ b/.github/workflows/VersionCheck.yml @@ -140,8 +140,6 @@ jobs: end end - all_pass = true - for p in projects - all_pass &= check_project(p, subdirs, base_ref, changed_files) - end - all_pass || error("VersionCheck failed; see errors above.") + # Evaluate every project so all errors surface before failing. + results = [check_project(p, subdirs, base_ref, changed_files) for p in projects] + all(results) || error("VersionCheck failed; see errors above.") From d915e7491dd0153d6206705a0ecc72205471f61d Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 13:15:48 -0400 Subject: [PATCH 06/29] Revert Tests.yml extra-dev-paths input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropped from #107's scope after redesign. Multi-package consumers (ITensors.jl + NDTensors) now use one workflow file per package rather than a single Tests.yml that cross-`Pkg.develop`s a sibling subpackage. With the per-package shape, no Tests.yml caller in the ecosystem needs `extra-dev-paths` — each package runs its own tests against registered deps for the others. `Tests.test-args` is kept (NDTensors's runtests reads ARGS for group selection). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/Tests.yml | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/.github/workflows/Tests.yml b/.github/workflows/Tests.yml index ca794d9..ad2ea11 100644 --- a/.github/workflows/Tests.yml +++ b/.github/workflows/Tests.yml @@ -72,11 +72,6 @@ on: default: "" required: false type: string - extra-dev-paths: - description: "Additional local package paths (relative to the repo root) to `Pkg.develop()` into the active project before running tests. Newline-separated list. Useful for repos with in-tree subpackages (e.g. ITensors.jl + NDTensors) where the in-tree dep must shadow any registered version during testing." - default: "" - required: false - type: string continue-on-error: description: "Prevent the workflow run from failing if/when the job fails" required: false @@ -152,19 +147,6 @@ jobs: with: token: "${{ secrets.GITHUB_TOKEN }}" - - name: "Pkg.develop extra paths" - if: "${{ inputs.extra-dev-paths != '' }}" - shell: julia --color=yes {0} - env: - EXTRA_DEV_PATHS: ${{ inputs.extra-dev-paths }} - PROJECT: ${{ inputs.project }} - run: | - using Pkg - dev_paths = filter(!isempty, string.(split(get(ENV, "EXTRA_DEV_PATHS", ""), "\n"))) - project = get(ENV, "PROJECT", "@.") - Pkg.activate(project) - Pkg.develop([Pkg.PackageSpec(; path) for path in dev_paths]) - - uses: julia-actions/julia-buildpkg@v1 if: "${{ inputs.buildpkg }}" with: From 6d8fa9edbd1cff63489ce9fc2d3abc8b1afc60f8 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 13:17:01 -0400 Subject: [PATCH 07/29] Revert Documentation.yml extra-dev-paths input Dropped from #107's scope after redesign. Under the per-package workflow shape (Model 2), each package's docs build runs against its own deps; no caller in the ecosystem needs to develop a sibling subpackage before the docs build. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/Documentation.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/Documentation.yml b/.github/workflows/Documentation.yml index 07ea8fd..c7f1cd6 100644 --- a/.github/workflows/Documentation.yml +++ b/.github/workflows/Documentation.yml @@ -22,11 +22,6 @@ on: required: false description: 'Add local registries hosted on GitHub. Specified by providing the url (https/ssh) to the repositories as a newline (\n) seperated list. User is responsible for setting up the necessary SSH-Keys to access the repositories if necessary.' default: '' - extra-dev-paths: - description: "Additional local package paths (relative to the repo root) to develop alongside the repository root before the docs build. Newline-separated list. Useful for repos with in-tree subpackages (e.g. ITensors.jl + NDTensors)." - default: "" - required: false - type: string self-hosted: description: "Run the job needs on a self hosted machine" default: false @@ -116,7 +111,6 @@ jobs: shell: "bash" env: LOCALREGISTRY: ${{ inputs.localregistry }} - EXTRA_DEV_PATHS: ${{ inputs.extra-dev-paths }} # `doc-prefix` is rendered directly into the script body so that any # shell quoting in the caller's value (e.g. the single-quoted server # args of `xvfb-run -a -s '-screen 0 1024x768x24'`) is parsed by the @@ -143,9 +137,7 @@ jobs: Pkg.Registry.add(Pkg.RegistrySpec(; url=repo_url)) end end - dev_paths = [pwd(), split(get(ENV, "EXTRA_DEV_PATHS", ""), "\n")...] - filter!(!isempty, dev_paths) - Pkg.develop([Pkg.PackageSpec(; path) for path in dev_paths]) + Pkg.develop(Pkg.PackageSpec(path=pwd())) # Explicit instantiate so docs/Manifest.toml resolution failures # (e.g. compat conflicts that would leave packages like Documenter # missing from the depot) surface here rather than silently From 67f5f2738ceedaa1b266da74991a58f25348743d Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 13:18:54 -0400 Subject: [PATCH 08/29] Revert CompatHelper.yml subdirs input Dropped from #107's scope after redesign. Under the per-package workflow shape (Model 2), each package gets its own CompatHelper.yml caller and runs the standard single-subpackage default. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/CompatHelper.yml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.github/workflows/CompatHelper.yml b/.github/workflows/CompatHelper.yml index 3cdd155..9ab7dfe 100644 --- a/.github/workflows/CompatHelper.yml +++ b/.github/workflows/CompatHelper.yml @@ -14,11 +14,6 @@ on: default: "" required: false type: string - subdirs: - description: "Newline-separated list of subdirectories (relative to the repo root) whose Project.toml files should also be checked for compat updates. An empty entry represents the repo root itself. Default covers root + docs + examples + test." - default: "\ndocs\nexamples\ntest" - required: false - type: string jobs: compathelper: @@ -78,7 +73,6 @@ jobs: # (see https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication#using-the-github_token-in-a-workflow). GITHUB_TOKEN: ${{ secrets.COMPATHELPER_PAT || secrets.GITHUB_TOKEN }} LOCALREGISTRY: ${{ inputs.localregistry }} - SUBDIRS: ${{ inputs.subdirs }} TOKEN_OWNER: ${{ steps.token-owner.outputs.login }} run: | import CompatHelper @@ -112,7 +106,7 @@ jobs: push!(registries, Pkg.RegistrySpec(; url=registry_url, name=registry_name)) end end - subdirs = string.(split(get(ENV, "SUBDIRS", ""), "\n")) + subdirs = ["", "docs", "examples", "test"] token_owner = get(ENV, "TOKEN_OWNER", "") # If TOKEN_OWNER contains a JSON error body (happens when only GITHUB_TOKEN # is available, e.g. in private repos without COMPATHELPER_PAT), treat it as From 032dcdba1703a994905f6b8c5f7cb120c6fbf497 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 13:19:50 -0400 Subject: [PATCH 09/29] Revert VersionCheck.yml to single-project classify-pr shape Dropped per-project subdir-aware classification from #107's scope after redesign. Under the per-package workflow shape (Model 2), each package gets its own VersionCheck.yml caller and runs the standard single-project check (using the existing `classify-pr` composite action to skip non-substantive PRs). Also reverts the Julia hard-scope bug fix from f5f5b3df, which existed only in the per-project results loop being removed here. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/VersionCheck.yml | 144 +++++++++-------------------- 1 file changed, 43 insertions(+), 101 deletions(-) diff --git a/.github/workflows/VersionCheck.yml b/.github/workflows/VersionCheck.yml index c00a78c..19934f5 100644 --- a/.github/workflows/VersionCheck.yml +++ b/.github/workflows/VersionCheck.yml @@ -13,11 +13,6 @@ on: default: "" required: false type: string - subdirs: - description: "Newline-separated list of subdirectories that contain in-tree subpackages with their own Project.toml. When set, each subdir's Project.toml is checked for a version bump independently of the root Project.toml. Each project's check fires only if the PR has substantive changes inside that project's scope: a file in `/**` for a named subdir, or a file outside (`.github/**`, `.pre-commit-config.yaml`, `.gitignore`, `LICENSE`, and any named subdir) for the root." - default: "" - required: false - type: string jobs: version-check: @@ -30,116 +25,63 @@ jobs: steps: - uses: actions/checkout@v6 + - uses: ITensor/ITensorActions/.github/actions/classify-pr@main + id: classify + + - name: "Skip version check on non-substantive PR" + if: steps.classify.outputs.substantive != 'true' + run: | + echo "PR classified as non-substantive (only .github/**, .pre-commit-config.yaml, .gitignore, LICENSE)." + echo "No version bump is required. Reporting success without running the real check." + - name: "Fetch base branch" - if: github.event_name == 'pull_request' + if: steps.classify.outputs.substantive == 'true' run: git fetch --depth=1 origin "${{ github.base_ref }}" - uses: julia-actions/setup-julia@v3 - if: github.event_name == 'pull_request' + if: steps.classify.outputs.substantive == 'true' with: version: ${{ inputs.julia-version }} - - name: "Check version bumps per project" - if: github.event_name == 'pull_request' - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - PR_NUMBER: ${{ github.event.pull_request.number }} - BASE_REF: ${{ github.base_ref }} - SUBDIRS: ${{ inputs.subdirs }} + - name: "Check version was bumped" + if: steps.classify.outputs.substantive == 'true' shell: julia --color=yes {0} run: | using TOML - # Substantive-file rules: at the repo root, ignore CI/config - # metadata files. Inside a named subdir, every change counts. - # Per-subdir classification means each project (root + each - # named subdir) gets its own scope and is checked - # independently — root bump required only when files outside - # the metadata set AND outside any named subdir change; named - # subdir bump required only when files inside that subdir - # change. - const IGNORE_AT_ROOT = (".github/", ".pre-commit-config.yaml", ".gitignore", "LICENSE") - - subdirs = filter(!isempty, string.(split(get(ENV, "SUBDIRS", ""), "\n"))) - projects = vcat([""], subdirs) - - base_ref = ENV["BASE_REF"] - repo = ENV["REPO"] - pr_number = ENV["PR_NUMBER"] - - files_text = read(`gh api --paginate "repos/$repo/pulls/$pr_number/files" --jq ".[].filename"`, String) - changed_files = filter(!isempty, string.(split(strip(files_text), '\n'))) - println("Changed files in PR #$pr_number:") - for f in changed_files - println(" $f") + base_ref = ENV["GITHUB_BASE_REF"] + base_project_text = try + read(`git show "origin/$base_ref:Project.toml"`, String) + catch err + println("Could not read Project.toml on origin/$base_ref ($err); skipping check.") + exit(0) end + base_project = TOML.parse(base_project_text) + current_project = TOML.parse(read("Project.toml", String)) - function in_scope(file, project, all_subdirs) - if isempty(project) - any(startswith(file, p) || file == p for p in IGNORE_AT_ROOT) && return false - any(startswith(file, sd * "/") for sd in all_subdirs) && return false - return true - else - return startswith(file, project * "/") - end + if !haskey(base_project, "version") + println("Base branch Project.toml has no version field; skipping check.") + exit(0) end - - function project_path(project) - isempty(project) ? "Project.toml" : joinpath(project, "Project.toml") + if !haskey(current_project, "version") + error( + "Project.toml on this branch has no version field, but the base " * + "branch ($base_ref) does. Restore the version field and bump it." + ) end - function check_project(project, all_subdirs, base_ref, files) - path = project_path(project) - label = isempty(project) ? "root" : project - - substantive = filter(f -> in_scope(f, project, all_subdirs), files) - if isempty(substantive) - println("$label: no substantive changes; skipping version-bump check.") - return true - end - - println("$label: substantive files changed:") - for f in substantive - println(" $f") - end - - base_text = try - read(`git show "origin/$base_ref:$path"`, String) - catch err - println("$label: could not read $path on origin/$base_ref ($err); skipping check.") - return true - end - - if !isfile(path) - println("$label: $path is missing in PR head; skipping check.") - return true - end - - base_proj = TOML.parse(base_text) - current_proj = TOML.parse(read(path, String)) - - if !haskey(base_proj, "version") - println("$label: base $path has no version field; skipping check.") - return true - end - if !haskey(current_proj, "version") - println(stderr, "ERROR: $label: $path on PR head has no version field, but base does. Restore the version and bump it.") - return false - end - - base_v = VersionNumber(base_proj["version"]) - current_v = VersionNumber(current_proj["version"]) - - if current_v > base_v - println("$label: $path bumped from $base_v to $current_v. OK.") - return true - else - println(stderr, "ERROR: $label has substantive changes but $path version was not bumped (current $current_v not > base $base_v).") - return false - end + base_version = VersionNumber(base_project["version"]) + current_version = VersionNumber(current_project["version"]) + + if current_version > base_version + println( + "OK: Project.toml version bumped from $base_version " * + "(origin/$base_ref) to $current_version (PR head)." + ) + else + error( + "Project.toml version was not bumped. Current version $current_version " * + "is not greater than the version $base_version on the base branch " * + "($base_ref). Bump the version in Project.toml." + ) end - - # Evaluate every project so all errors surface before failing. - results = [check_project(p, subdirs, base_ref, changed_files) for p in projects] - all(results) || error("VersionCheck failed; see errors above.") From e391c4b1feaed1898923247173500c22a5322b1b Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 13:25:01 -0400 Subject: [PATCH 10/29] IntegrationTest.yml: add subdir input for testing in-tree subpackages Lets multi-package repos run an IntegrationTest matrix with an in-tree subpackage as the package-under-test. Empty default preserves the existing root-of-repo behavior; setting `subdir: "NDTensors"` for example makes the workflow `Pkg.develop` from `./NDTensors` instead of `.`, so the matrix tests downstream consumers against the in-tree subpackage. Used in ITensors.jl's NDTensors-IntegrationTest caller (#1743) so NDTensors changes get tested against ITensors and its downstream consumers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/IntegrationTest.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/IntegrationTest.yml b/.github/workflows/IntegrationTest.yml index 77e7051..5640308 100644 --- a/.github/workflows/IntegrationTest.yml +++ b/.github/workflows/IntegrationTest.yml @@ -22,6 +22,11 @@ on: default: "" required: false type: string + subdir: + description: "Subdirectory containing the package-under-test's Project.toml, relative to the repo root. Empty (default) means use the repo root. When set (e.g. `NDTensors`), the workflow `Pkg.develop`s from that subdirectory instead, so multi-package repos can run an IntegrationTest matrix against an in-tree subpackage as the package under test." + default: "" + required: false + type: string run-on-draft: description: "When true, run integration tests even on draft PRs" default: false @@ -135,6 +140,7 @@ jobs: PKG: ${{ matrix.pkg }} LOCALREGISTRY: ${{ inputs.localregistry }} EXTRA_DEV_PATHS: ${{ inputs.extra-dev-paths }} + PKG_DIR: ${{ inputs.subdir || '.' }} JULIA_PKG_SERVER_REGISTRY_PREFERENCE: eager run: | using Pkg @@ -148,7 +154,7 @@ jobs: Pkg.Registry.add(Pkg.RegistrySpec(; url=registry_url)) end end - dev_paths = [".", split(get(ENV, "EXTRA_DEV_PATHS", ""), "\n")...] + dev_paths = [get(ENV, "PKG_DIR", "."), split(get(ENV, "EXTRA_DEV_PATHS", ""), "\n")...] filter!(!isempty, dev_paths) dev_specs = [PackageSpec(; path) for path in dev_paths] pkg = get(ENV, "PKG", "") From ff43a34521d62fcc4ce63f98828ff8a1970bb8c3 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 13:27:16 -0400 Subject: [PATCH 11/29] IntegrationTest.yml: skip non-substantive PRs by default Adds a `classify` job that runs the existing classify-pr composite action up front. The matrix `integration-test` job now needs `classify` and runs only when `substantive == 'true'` (or the new `run-on-nonsubstantive` opt-out is set). The `gate` job short-circuits to success when the PR is non-substantive, so the required `IntegrationTest` check still reports green. Default-on saves CI compute on metadata-only PRs (`.github/**`, `.pre-commit-config.yaml`, `.gitignore`, `LICENSE`) where downstream tests can't be affected. Callers wanting the previous always-run behavior set `run-on-nonsubstantive: true`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/IntegrationTest.yml | 35 ++++++++++++++++++++++++--- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/.github/workflows/IntegrationTest.yml b/.github/workflows/IntegrationTest.yml index 5640308..5322f60 100644 --- a/.github/workflows/IntegrationTest.yml +++ b/.github/workflows/IntegrationTest.yml @@ -32,14 +32,36 @@ on: default: false required: false type: boolean + run-on-nonsubstantive: + description: "When true, run IntegrationTest even on non-substantive PRs (those touching only `.github/**`, `.pre-commit-config.yaml`, `.gitignore`, `LICENSE`). Defaults to false so metadata-only changes don't burn CI on downstream tests they can't possibly affect." + default: false + required: false + type: boolean secrets: INTEGRATIONTEST_PAT: description: "GitHub PAT for cloning private repositories via HTTPS." required: false jobs: + # Classify PR substance up front so the matrix and gate jobs can both + # gate on it. Non-substantive PRs (touching only `.github/**`, + # `.pre-commit-config.yaml`, `.gitignore`, `LICENSE`) skip the matrix + # entirely by default; the gate then short-circuits to success. + classify: + name: "Classify PR substance" + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + substantive: ${{ steps.classify.outputs.substantive }} + steps: + - uses: ITensor/ITensorActions/.github/actions/classify-pr@main + id: classify + integration-test: name: "${{ matrix.pkg }}" + needs: classify # Skip the matrix-driven leg entirely when `pkgs` is empty. Without this # guard, GitHub Actions reports an empty-matrix job as `failure`, which # propagates to the run's overall conclusion even though the aggregating @@ -48,7 +70,8 @@ jobs: # the check is robust against whitespace / block-scalar style. if: | fromJSON(inputs.pkgs)[0] != null && - (!github.event.pull_request.draft || inputs.run-on-draft) + (!github.event.pull_request.draft || inputs.run-on-draft) && + (needs.classify.outputs.substantive == 'true' || inputs.run-on-nonsubstantive) runs-on: ubuntu-latest permissions: contents: read @@ -200,7 +223,7 @@ jobs: # posts a passing check with the same name to satisfy branch protection. gate: name: "IntegrationTest" - needs: integration-test + needs: [classify, integration-test] if: ${{ always() }} runs-on: ubuntu-latest permissions: @@ -220,8 +243,14 @@ jobs: EVENT_NAME: ${{ github.event_name }} HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} BASE_REPO: ${{ github.repository }} + SUBSTANTIVE: ${{ needs.classify.outputs.substantive }} + RUN_ON_NONSUBSTANTIVE: ${{ inputs.run-on-nonsubstantive }} run: | - echo "test.result=$RESULT pkgs=$PKGS event=$EVENT_NAME" + echo "test.result=$RESULT pkgs=$PKGS event=$EVENT_NAME substantive=$SUBSTANTIVE" + if [ "$SUBSTANTIVE" != "true" ] && [ "$RUN_ON_NONSUBSTANTIVE" != "true" ]; then + echo "PR is non-substantive (only .github/**, .pre-commit-config.yaml, .gitignore, LICENSE); IntegrationTest skipped without running. Set inputs.run-on-nonsubstantive=true on the caller to override." + exit 0 + fi pkg_count=$(printf '%s' "$PKGS" | jq 'length') if [ "$pkg_count" -eq 0 ]; then echo "No downstream integration tests configured; passing." From 0ea3ea1fee9db24692b31e36821cb7330e1b5317 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 13:34:08 -0400 Subject: [PATCH 12/29] IntegrationTest.yml: tighten input descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the multi-sentence rationale paragraphs from `subdir` and `run-on-nonsubstantive` — match the terse style of the other inputs in this file (e.g. `run-on-draft`, `extra-dev-paths`). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/IntegrationTest.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/IntegrationTest.yml b/.github/workflows/IntegrationTest.yml index 5322f60..cde85be 100644 --- a/.github/workflows/IntegrationTest.yml +++ b/.github/workflows/IntegrationTest.yml @@ -23,7 +23,7 @@ on: required: false type: string subdir: - description: "Subdirectory containing the package-under-test's Project.toml, relative to the repo root. Empty (default) means use the repo root. When set (e.g. `NDTensors`), the workflow `Pkg.develop`s from that subdirectory instead, so multi-package repos can run an IntegrationTest matrix against an in-tree subpackage as the package under test." + description: "Run the downstream tests against the package in this subdirectory." default: "" required: false type: string @@ -33,7 +33,7 @@ on: required: false type: boolean run-on-nonsubstantive: - description: "When true, run IntegrationTest even on non-substantive PRs (those touching only `.github/**`, `.pre-commit-config.yaml`, `.gitignore`, `LICENSE`). Defaults to false so metadata-only changes don't burn CI on downstream tests they can't possibly affect." + description: "When true, run IntegrationTest even on PRs that only touch `.github/**`, `.pre-commit-config.yaml`, `.gitignore`, or `LICENSE`." default: false required: false type: boolean From c68e186df60b5f8248ff5106fcbd37fc2217ec06 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 13:34:57 -0400 Subject: [PATCH 13/29] IntegrationTest.yml: refine run-on-nonsubstantive description Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/IntegrationTest.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/IntegrationTest.yml b/.github/workflows/IntegrationTest.yml index cde85be..9b1012b 100644 --- a/.github/workflows/IntegrationTest.yml +++ b/.github/workflows/IntegrationTest.yml @@ -33,7 +33,7 @@ on: required: false type: boolean run-on-nonsubstantive: - description: "When true, run IntegrationTest even on PRs that only touch `.github/**`, `.pre-commit-config.yaml`, `.gitignore`, or `LICENSE`." + description: "Defaults to false so metadata-only changes don't trigger downstream tests." default: false required: false type: boolean From fc12fe8c2b1163037330f9dbf0d7dfc8d17d6a5c Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 13:56:38 -0400 Subject: [PATCH 14/29] classify-pr: add subdir + exclude-subdirs inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two mutually-exclusive optional inputs to scope the substantive-detection per package in multi-package repos: - `subdir` — positive scope. A file is substantive iff it lies under `/`. Used by callers that target a single in-tree subpackage (e.g. NDTensors-IntegrationTest with `subdir: NDTensors`). - `exclude-subdirs` — negative scope at root. Newline-separated list of sibling subdirectories to exclude. A file is substantive iff it lies outside the standard root-metadata set (`.github/**`, `.pre-commit-config.yaml`, `.gitignore`, `LICENSE`) AND outside any listed sibling. Used by callers that target the root package and need to ignore changes confined to a sibling subpackage (e.g. ITensors-IntegrationTest with `exclude-subdirs: NDTensors`). Both empty preserves the current global behavior — single-package consumers see no change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/actions/classify-pr/action.yml | 57 ++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/.github/actions/classify-pr/action.yml b/.github/actions/classify-pr/action.yml index 6dfe906..88bfb84 100644 --- a/.github/actions/classify-pr/action.yml +++ b/.github/actions/classify-pr/action.yml @@ -1,8 +1,17 @@ name: "Classify PR substance" description: "Determines whether a PR touches substantive files or only CI/config (workflow-only) files. Outputs substantive=true|false." +inputs: + subdir: + description: "Positive scope: when set, a file is substantive iff it lies under `/`. Mutually exclusive with `exclude-subdirs`." + required: false + default: "" + exclude-subdirs: + description: "Negative scope at root: newline-separated list of sibling subdirectories to exclude. When set (and `subdir` empty), a file is substantive iff it lies outside `.github/**`, `.pre-commit-config.yaml`, `.gitignore`, `LICENSE`, AND outside any listed `/`." + required: false + default: "" outputs: substantive: - description: "true if the PR touches substantive files; false if all changes are confined to .github/**, .pre-commit-config.yaml, .gitignore, or LICENSE." + description: "true if the PR touches substantive files; false otherwise." value: ${{ steps.classify.outputs.substantive }} runs: using: "composite" @@ -13,8 +22,14 @@ runs: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} + SUBDIR: ${{ inputs.subdir }} + EXCLUDE_SUBDIRS: ${{ inputs.exclude-subdirs }} run: | set -euo pipefail + if [ -n "$SUBDIR" ] && [ -n "$EXCLUDE_SUBDIRS" ]; then + echo "::error::classify-pr: 'subdir' and 'exclude-subdirs' are mutually exclusive." + exit 1 + fi if [ -z "${PR_NUMBER:-}" ]; then echo "Not a pull_request event; defaulting to substantive=true." echo "substantive=true" >> "$GITHUB_OUTPUT" @@ -29,11 +44,39 @@ runs: echo "Changed files:" printf ' %s\n' $files substantive=false - while IFS= read -r file; do - case "$file" in - .github/*|.pre-commit-config.yaml|.gitignore|LICENSE) ;; - *) substantive=true; break ;; - esac - done <<< "$files" + if [ -n "$SUBDIR" ]; then + echo "Positive scope: ${SUBDIR}/" + while IFS= read -r file; do + case "$file" in + "$SUBDIR"/*) substantive=true; break ;; + esac + done <<< "$files" + else + # Build sibling-exclusion list (one prefix per non-empty line). + siblings=() + if [ -n "$EXCLUDE_SUBDIRS" ]; then + while IFS= read -r s; do + [ -z "$s" ] && continue + siblings+=("$s") + done <<< "$EXCLUDE_SUBDIRS" + echo "Root scope, excluding siblings: ${siblings[*]}" + fi + while IFS= read -r file; do + # Always-ignored metadata at root. + case "$file" in + .github/*|.pre-commit-config.yaml|.gitignore|LICENSE) continue ;; + esac + # Sibling-subdir exclusion (only when EXCLUDE_SUBDIRS set). + in_sibling=false + for sib in "${siblings[@]}"; do + case "$file" in + "$sib"/*) in_sibling=true; break ;; + esac + done + $in_sibling && continue + substantive=true + break + done <<< "$files" + fi echo "substantive=${substantive}" echo "substantive=${substantive}" >> "$GITHUB_OUTPUT" From 7f540db6e84fd7edb77d0afcfecb46ab57b0bf8f Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 14:01:45 -0400 Subject: [PATCH 15/29] =?UTF-8?q?Tests.yml:=20rename=20project=20=E2=86=92?= =?UTF-8?q?=20subdir,=20add=20per-package=20classify-pr=20scoping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the activation knob from `project` to `subdir` to match the naming used by other reusables (Registrator, IntegrationTest, forthcoming VersionCheck). When `subdir` is set: - Julia activates `` for both buildpkg and runtest. - classify-pr scopes the substantive-PR check to files under `/`. Adds `exclude-subdirs` for the root-scope case (newline-separated sibling subpackages to ignore in the substantive-PR check). Both inputs default empty. When both are empty, classify-pr still runs (in its global mode) but Tests ignores the result and runs on every PR — same behavior single-package consumers see today. Drops the legacy `project` input. ITensors.jl is the only known ecosystem consumer setting `project` to a non-default value, and that caller is being updated as part of this work. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/Tests.yml | 53 ++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/.github/workflows/Tests.yml b/.github/workflows/Tests.yml index ad2ea11..a2bcdc9 100644 --- a/.github/workflows/Tests.yml +++ b/.github/workflows/Tests.yml @@ -12,11 +12,6 @@ on: description: "Architecture of Julia to be used" required: false type: string - project: - description: "The value is passed to Julia's `--project` flag" - default: '@.' - required: false - type: string group: description: "The 'GROUP' of tests that need to be run. This will requires an ENV `GROUP` to be (conditionally) defined within your package tests as well to selectively run groups of tests" default: "" @@ -72,6 +67,16 @@ on: default: "" required: false type: string + subdir: + description: "Subdirectory containing the package's Project.toml. When set, Julia activates `` and classify-pr scopes the substantive-PR check to files under `/`. Empty (default) activates the repo root." + default: "" + required: false + type: string + exclude-subdirs: + description: "Negative scope for the substantive-PR check at root: newline-separated siblings to ignore. Skip Tests when changes are confined to `.github/**`/`.pre-commit-config.yaml`/`.gitignore`/`LICENSE` plus any of the listed siblings." + default: "" + required: false + type: string continue-on-error: description: "Prevent the workflow run from failing if/when the job fails" required: false @@ -111,14 +116,41 @@ on: required: true jobs: + # Classify PR substance up front when per-package scoping is requested + # (`subdir` or `exclude-subdirs` set). Tests reads the result to skip + # PRs whose substantive changes are outside this package's scope. When + # neither input is set the classify-pr action runs in its global mode + # but Tests ignores its result, preserving today's "run on every PR" + # behavior for existing single-package consumers. + classify: + name: "Classify PR substance" + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + substantive: ${{ steps.classify.outputs.substantive }} + steps: + - uses: ITensor/ITensorActions/.github/actions/classify-pr@main + id: classify + with: + subdir: ${{ inputs.subdir }} + exclude-subdirs: ${{ inputs.exclude-subdirs }} + tests: name: "Julia ${{ inputs.julia-version }} - ${{ inputs.self-hosted && 'self-hosted' || inputs.os }} ${{ inputs.group != '' && format(' - {0}', inputs.group) || '' }}" + needs: classify permissions: contents: read - if: >- - !github.event.pull_request.draft - || inputs.run-all-on-draft - || (inputs.os == 'ubuntu-latest' && inputs.julia-version == '1') + if: | + ( + !github.event.pull_request.draft + || inputs.run-all-on-draft + || (inputs.os == 'ubuntu-latest' && inputs.julia-version == '1') + ) && ( + (inputs.subdir == '' && inputs.exclude-subdirs == '') + || needs.classify.outputs.substantive == 'true' + ) continue-on-error: ${{ inputs.continue-on-error || inputs.julia-version == 'nightly' }} runs-on: "${{ inputs.self-hosted && 'self-hosted' || inputs.os }}" timeout-minutes: ${{ inputs.timeout-minutes }} @@ -151,6 +183,7 @@ jobs: if: "${{ inputs.buildpkg }}" with: localregistry: "${{ inputs.localregistry }}" + project: "${{ inputs.subdir != '' && inputs.subdir || '@.' }}" - name: "Export extra environment variables" if: "${{ inputs.extra-env != '' }}" @@ -163,7 +196,7 @@ jobs: - name: "Run tests ${{ inputs.self-hosted && '' || format('on {0}', inputs.os) }} with Julia v${{ inputs.julia-version }}" uses: julia-actions/julia-runtest@v1 with: - project: "${{ inputs.project }}" + project: "${{ inputs.subdir != '' && inputs.subdir || '@.' }}" depwarn: "${{ inputs.julia-runtest-depwarn }}" coverage: "${{ inputs.coverage }}" prefix: "${{ inputs.test-prefix }}" From a9f4eb0cd0b41e84ab0d3627d479a901d1cd2e36 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 14:03:58 -0400 Subject: [PATCH 16/29] Tests.yml: restore project as a deprecated backward-compat alias Keeps `project` available so existing single-package consumers pinned to `@v2` see no behavior change after v2.2.0 ships. New multi-package shape uses `subdir`; `subdir` takes precedence when non-empty. Plan to remove `project` in a future major release. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/Tests.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/Tests.yml b/.github/workflows/Tests.yml index a2bcdc9..1c6f3e0 100644 --- a/.github/workflows/Tests.yml +++ b/.github/workflows/Tests.yml @@ -12,6 +12,11 @@ on: description: "Architecture of Julia to be used" required: false type: string + project: + description: "Deprecated alias for `subdir`. Value passed to Julia's `--project` flag. Set `subdir` instead. Will be removed in a future major release." + default: "@." + required: false + type: string group: description: "The 'GROUP' of tests that need to be run. This will requires an ENV `GROUP` to be (conditionally) defined within your package tests as well to selectively run groups of tests" default: "" @@ -183,7 +188,7 @@ jobs: if: "${{ inputs.buildpkg }}" with: localregistry: "${{ inputs.localregistry }}" - project: "${{ inputs.subdir != '' && inputs.subdir || '@.' }}" + project: "${{ inputs.subdir != '' && inputs.subdir || inputs.project }}" - name: "Export extra environment variables" if: "${{ inputs.extra-env != '' }}" @@ -196,7 +201,7 @@ jobs: - name: "Run tests ${{ inputs.self-hosted && '' || format('on {0}', inputs.os) }} with Julia v${{ inputs.julia-version }}" uses: julia-actions/julia-runtest@v1 with: - project: "${{ inputs.subdir != '' && inputs.subdir || '@.' }}" + project: "${{ inputs.subdir != '' && inputs.subdir || inputs.project }}" depwarn: "${{ inputs.julia-runtest-depwarn }}" coverage: "${{ inputs.coverage }}" prefix: "${{ inputs.test-prefix }}" From c77299a6c1ac6dfcb0ac63f0cca3b3032fff48bb Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 14:04:32 -0400 Subject: [PATCH 17/29] IntegrationTest.yml: forward subdir + exclude-subdirs to classify-pr Adds `exclude-subdirs` input and wires both `subdir` and `exclude-subdirs` through to the `classify-pr` step. With this, NDTensors-IntegrationTest (`subdir: NDTensors`) classifies ITensors-only PRs as non-substantive within scope and skips, and ITensors-IntegrationTest (`exclude-subdirs: NDTensors`) classifies NDTensors-only PRs the same way. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/IntegrationTest.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/IntegrationTest.yml b/.github/workflows/IntegrationTest.yml index 9b1012b..239700b 100644 --- a/.github/workflows/IntegrationTest.yml +++ b/.github/workflows/IntegrationTest.yml @@ -23,7 +23,12 @@ on: required: false type: string subdir: - description: "Run the downstream tests against the package in this subdirectory." + description: "Run the downstream tests against the package in this subdirectory. Also scopes the substantive-PR check to files under `/`." + default: "" + required: false + type: string + exclude-subdirs: + description: "Negative scope for the substantive-PR check at root: newline-separated siblings to ignore." default: "" required: false type: string @@ -58,6 +63,9 @@ jobs: steps: - uses: ITensor/ITensorActions/.github/actions/classify-pr@main id: classify + with: + subdir: ${{ inputs.subdir }} + exclude-subdirs: ${{ inputs.exclude-subdirs }} integration-test: name: "${{ matrix.pkg }}" From 8754fcce9985d441e3fefa8c359561feb013756f Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 14:07:11 -0400 Subject: [PATCH 18/29] VersionCheck.yml: add subdir + exclude-subdirs `subdir` controls which Project.toml the version-bump check reads (empty = root, "NDTensors" = NDTensors/Project.toml, etc.). Both `subdir` and `exclude-subdirs` forward to classify-pr so the substantive-PR check is per-package-aware. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/VersionCheck.yml | 37 ++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/.github/workflows/VersionCheck.yml b/.github/workflows/VersionCheck.yml index 19934f5..4b3b8d8 100644 --- a/.github/workflows/VersionCheck.yml +++ b/.github/workflows/VersionCheck.yml @@ -13,6 +13,16 @@ on: default: "" required: false type: string + subdir: + description: "Subdirectory containing the package's Project.toml. When set, VersionCheck reads `/Project.toml` and classify-pr scopes the substantive-PR check to files under `/`. Empty (default) reads root Project.toml." + default: "" + required: false + type: string + exclude-subdirs: + description: "Negative scope for the substantive-PR check at root: newline-separated siblings to ignore." + default: "" + required: false + type: string jobs: version-check: @@ -27,12 +37,14 @@ jobs: - uses: ITensor/ITensorActions/.github/actions/classify-pr@main id: classify + with: + subdir: ${{ inputs.subdir }} + exclude-subdirs: ${{ inputs.exclude-subdirs }} - name: "Skip version check on non-substantive PR" if: steps.classify.outputs.substantive != 'true' run: | - echo "PR classified as non-substantive (only .github/**, .pre-commit-config.yaml, .gitignore, LICENSE)." - echo "No version bump is required. Reporting success without running the real check." + echo "PR has no substantive changes in scope; no version bump required. Reporting success." - name: "Fetch base branch" if: steps.classify.outputs.substantive == 'true' @@ -46,26 +58,31 @@ jobs: - name: "Check version was bumped" if: steps.classify.outputs.substantive == 'true' shell: julia --color=yes {0} + env: + SUBDIR: ${{ inputs.subdir }} run: | using TOML + subdir = get(ENV, "SUBDIR", "") + project_path = isempty(subdir) ? "Project.toml" : joinpath(subdir, "Project.toml") + base_ref = ENV["GITHUB_BASE_REF"] base_project_text = try - read(`git show "origin/$base_ref:Project.toml"`, String) + read(`git show "origin/$base_ref:$project_path"`, String) catch err - println("Could not read Project.toml on origin/$base_ref ($err); skipping check.") + println("Could not read $project_path on origin/$base_ref ($err); skipping check.") exit(0) end base_project = TOML.parse(base_project_text) - current_project = TOML.parse(read("Project.toml", String)) + current_project = TOML.parse(read(project_path, String)) if !haskey(base_project, "version") - println("Base branch Project.toml has no version field; skipping check.") + println("Base branch $project_path has no version field; skipping check.") exit(0) end if !haskey(current_project, "version") error( - "Project.toml on this branch has no version field, but the base " * + "$project_path on this branch has no version field, but the base " * "branch ($base_ref) does. Restore the version field and bump it." ) end @@ -75,13 +92,13 @@ jobs: if current_version > base_version println( - "OK: Project.toml version bumped from $base_version " * + "OK: $project_path version bumped from $base_version " * "(origin/$base_ref) to $current_version (PR head)." ) else error( - "Project.toml version was not bumped. Current version $current_version " * + "$project_path version was not bumped. Current version $current_version " * "is not greater than the version $base_version on the base branch " * - "($base_ref). Bump the version in Project.toml." + "($base_ref). Bump the version in $project_path." ) end From eb9a78df748af269a59daaf3aa6d506061e7954e Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 14:08:12 -0400 Subject: [PATCH 19/29] CheckCompatBounds.yml: add subdir, deprecate project `subdir` becomes the canonical multi-package input. When set, it overrides both `project` (buildpkg --project) and `workspace-root` (check-compat-bounds workspace root) to point at the subpackage. `project` is marked deprecated; both remain functional for backward compat with single-package consumers. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/CheckCompatBounds.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/CheckCompatBounds.yml b/.github/workflows/CheckCompatBounds.yml index 02af007..d9de634 100644 --- a/.github/workflows/CheckCompatBounds.yml +++ b/.github/workflows/CheckCompatBounds.yml @@ -9,10 +9,15 @@ on: required: false type: string project: - description: "The value is passed to Julia's `--project` flag during buildpkg." + description: "Deprecated alias for `subdir`. Value passed to Julia's `--project` flag during buildpkg. When `subdir` is set, it takes precedence." default: '@.' required: false type: string + subdir: + description: "Subdirectory containing the package's Project.toml. When set, both buildpkg's `--project` and the check-compat-bounds workspace-root resolve to ``. Empty (default) uses repo root." + default: "" + required: false + type: string cache: description: "Use the julia-actions/cache action for caching." default: true @@ -74,10 +79,10 @@ jobs: if: "${{ inputs.buildpkg }}" with: localregistry: "${{ inputs.localregistry }}" - project: "${{ inputs.project }}" + project: "${{ inputs.subdir != '' && inputs.subdir || inputs.project }}" - name: "Check compat upper bounds" uses: ITensor/ITensorActions/.github/actions/check-compat-bounds@main with: - workspace-root: "${{ inputs.workspace-root }}" + workspace-root: "${{ inputs.subdir != '' && inputs.subdir || inputs.workspace-root }}" mode: "${{ inputs.mode }}" From 19214b849b0f6081a32bafe1e6aa42c36388e189 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 14:09:09 -0400 Subject: [PATCH 20/29] CompatHelper.yml: add subdir input When set, CompatHelper bumps the subpackage's Project.toml plus its docs/examples/test variants if present (e.g. with `subdir: NDTensors`, the subdirs list becomes ["NDTensors", "NDTensors/docs", "NDTensors/examples", "NDTensors/test"]). Empty default preserves the existing single-package behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/CompatHelper.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/CompatHelper.yml b/.github/workflows/CompatHelper.yml index 9ab7dfe..9dddd7e 100644 --- a/.github/workflows/CompatHelper.yml +++ b/.github/workflows/CompatHelper.yml @@ -14,6 +14,11 @@ on: default: "" required: false type: string + subdir: + description: "Subdirectory containing the package's Project.toml. When set, CompatHelper bumps `/Project.toml` plus `/{docs,examples,test}/Project.toml` if present. Empty (default) uses repo root." + default: "" + required: false + type: string jobs: compathelper: @@ -73,6 +78,7 @@ jobs: # (see https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication#using-the-github_token-in-a-workflow). GITHUB_TOKEN: ${{ secrets.COMPATHELPER_PAT || secrets.GITHUB_TOKEN }} LOCALREGISTRY: ${{ inputs.localregistry }} + SUBDIR: ${{ inputs.subdir }} TOKEN_OWNER: ${{ steps.token-owner.outputs.login }} run: | import CompatHelper @@ -106,7 +112,10 @@ jobs: push!(registries, Pkg.RegistrySpec(; url=registry_url, name=registry_name)) end end - subdirs = ["", "docs", "examples", "test"] + subdir = get(ENV, "SUBDIR", "") + subdirs = isempty(subdir) ? + ["", "docs", "examples", "test"] : + [subdir, joinpath(subdir, "docs"), joinpath(subdir, "examples"), joinpath(subdir, "test")] token_owner = get(ENV, "TOKEN_OWNER", "") # If TOKEN_OWNER contains a JSON error body (happens when only GITHUB_TOKEN # is available, e.g. in private repos without COMPATHELPER_PAT), treat it as From 8624d698904fe5d4fb6a45f4911e3f6bf6efa9a3 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 14:15:40 -0400 Subject: [PATCH 21/29] classify-pr: apply metadata-file exclusions inside subdir scope; rewrite descriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In subdir-positive scope, files under `/` matching the same metadata-only patterns as the repo root (`.github/`, `.gitignore`, `LICENSE`, `.pre-commit-config.yaml`) now correctly count as non-substantive. Previously any change inside the subdirectory was treated as substantive — including hypothetical `/.github/foo.yml`-style metadata changes that shouldn't trigger downstream tests. Rewrites both input descriptions to be self-contained and avoid the misleading "iff it lies under `/`" framing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/actions/classify-pr/action.yml | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/actions/classify-pr/action.yml b/.github/actions/classify-pr/action.yml index 88bfb84..23379fe 100644 --- a/.github/actions/classify-pr/action.yml +++ b/.github/actions/classify-pr/action.yml @@ -1,12 +1,12 @@ name: "Classify PR substance" -description: "Determines whether a PR touches substantive files or only CI/config (workflow-only) files. Outputs substantive=true|false." +description: "Reports whether a PR makes substantive changes — anything beyond CI/config metadata files (`.github/**`, `.pre-commit-config.yaml`, `.gitignore`, `LICENSE`). Outputs substantive=true|false." inputs: subdir: - description: "Positive scope: when set, a file is substantive iff it lies under `/`. Mutually exclusive with `exclude-subdirs`." + description: "Restrict the check to changes inside this subdirectory. Files outside `/` don't count, and within `/` the same metadata files that don't count at the repo root (`.github/`, `.pre-commit-config.yaml`, `.gitignore`, `LICENSE`) also don't count. Use this for a workflow that's scoped to one in-tree subpackage. Mutually exclusive with `exclude-subdirs`." required: false default: "" exclude-subdirs: - description: "Negative scope at root: newline-separated list of sibling subdirectories to exclude. When set (and `subdir` empty), a file is substantive iff it lies outside `.github/**`, `.pre-commit-config.yaml`, `.gitignore`, `LICENSE`, AND outside any listed `/`." + description: "Newline-separated list of sibling subdirectories whose changes don't count. Use this for a root-package workflow in a multi-package repo so that PRs confined to a sibling subpackage are reported non-substantive. The repo-root metadata files (`.github/`, `.pre-commit-config.yaml`, `.gitignore`, `LICENSE`) also don't count, as usual." required: false default: "" outputs: @@ -45,11 +45,21 @@ runs: printf ' %s\n' $files substantive=false if [ -n "$SUBDIR" ]; then - echo "Positive scope: ${SUBDIR}/" + echo "Positive scope: ${SUBDIR}/ (with the usual metadata-file exclusions applied inside)" while IFS= read -r file; do + # Ignore files outside the scoped subdirectory. case "$file" in - "$SUBDIR"/*) substantive=true; break ;; + "$SUBDIR"/*) ;; + *) continue ;; esac + # Within the subdirectory, apply the same metadata-file + # exclusions as at the repo root. + rel="${file#"$SUBDIR"/}" + case "$rel" in + .github/*|.pre-commit-config.yaml|.gitignore|LICENSE) continue ;; + esac + substantive=true + break done <<< "$files" else # Build sibling-exclusion list (one prefix per non-empty line). From 1a0698626a26bbd21e7662fce5ffa99b03385070 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 14:50:57 -0400 Subject: [PATCH 22/29] Tests, IntegrationTest: drop inner classify-job permissions block Reusables that declare a job-level `permissions:` block can only narrow the consumer's workflow-level ceiling, never raise it. Single-package consumers (e.g. SparseArraysBase) grant only `contents: read` for these workflows, so the classify job's inner request for `pull-requests: read` exceeded the ceiling and GitHub rejected the workflow at startup. Dropping the inner block lets the classify job inherit the caller's permissions. The `gh api pulls/N/files` call inside classify-pr works on public repos with just `contents: read`. For private repos, callers can still grant `pull-requests: read` themselves. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/IntegrationTest.yml | 3 --- .github/workflows/Tests.yml | 3 --- 2 files changed, 6 deletions(-) diff --git a/.github/workflows/IntegrationTest.yml b/.github/workflows/IntegrationTest.yml index 239700b..fa4fc8e 100644 --- a/.github/workflows/IntegrationTest.yml +++ b/.github/workflows/IntegrationTest.yml @@ -55,9 +55,6 @@ jobs: classify: name: "Classify PR substance" runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read outputs: substantive: ${{ steps.classify.outputs.substantive }} steps: diff --git a/.github/workflows/Tests.yml b/.github/workflows/Tests.yml index 1c6f3e0..228ea01 100644 --- a/.github/workflows/Tests.yml +++ b/.github/workflows/Tests.yml @@ -130,9 +130,6 @@ jobs: classify: name: "Classify PR substance" runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: read outputs: substantive: ${{ steps.classify.outputs.substantive }} steps: From a87a8463103d7c4bbaeabd0ef800fbbc078a2d05 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 14:59:54 -0400 Subject: [PATCH 23/29] Tests, IntegrationTest, VersionCheck: pin classify-pr action to the mf/subdir-inputs branch The classify-pr action's subdir/exclude-subdirs inputs only exist on this branch. References pointing to the `main` branch silently ignored those inputs (returning global classification), causing per-package scoping to fail. Pinning to the `mf/subdir-inputs` branch makes the new inputs take effect. After v2.2.0 ships and the `main` branch has the updated classify-pr, a follow-up will flip these references back. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/IntegrationTest.yml | 2 +- .github/workflows/Tests.yml | 2 +- .github/workflows/VersionCheck.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/IntegrationTest.yml b/.github/workflows/IntegrationTest.yml index fa4fc8e..d120b55 100644 --- a/.github/workflows/IntegrationTest.yml +++ b/.github/workflows/IntegrationTest.yml @@ -58,7 +58,7 @@ jobs: outputs: substantive: ${{ steps.classify.outputs.substantive }} steps: - - uses: ITensor/ITensorActions/.github/actions/classify-pr@main + - uses: ITensor/ITensorActions/.github/actions/classify-pr@mf/subdir-inputs id: classify with: subdir: ${{ inputs.subdir }} diff --git a/.github/workflows/Tests.yml b/.github/workflows/Tests.yml index 228ea01..14e7f17 100644 --- a/.github/workflows/Tests.yml +++ b/.github/workflows/Tests.yml @@ -133,7 +133,7 @@ jobs: outputs: substantive: ${{ steps.classify.outputs.substantive }} steps: - - uses: ITensor/ITensorActions/.github/actions/classify-pr@main + - uses: ITensor/ITensorActions/.github/actions/classify-pr@mf/subdir-inputs id: classify with: subdir: ${{ inputs.subdir }} diff --git a/.github/workflows/VersionCheck.yml b/.github/workflows/VersionCheck.yml index 4b3b8d8..2cc9ca7 100644 --- a/.github/workflows/VersionCheck.yml +++ b/.github/workflows/VersionCheck.yml @@ -35,7 +35,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: ITensor/ITensorActions/.github/actions/classify-pr@main + - uses: ITensor/ITensorActions/.github/actions/classify-pr@mf/subdir-inputs id: classify with: subdir: ${{ inputs.subdir }} From ebb04b24ee2f8875f5890f794ec616ed7076b956 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 15:14:41 -0400 Subject: [PATCH 24/29] Tests.yml: remove inner classify job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The classify-pr scoping doesn't belong inside the Tests reusable. The reusable's matrix is provided by the consumer (each leg expands to one reusable invocation), so an inner classify job runs once per matrix leg — wasteful and visually noisy in CI. Move classify-pr to the consumer side: each consumer that wants per-package skip-on-non-substantive adds its own top-level classify job and gates its matrix on the result. Single-package consumers don't need any of this. `subdir` input stays for activation. `exclude-subdirs` had no purpose without the inner classify and was dropped in the previous commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/Tests.yml | 39 +++++-------------------------------- 1 file changed, 5 insertions(+), 34 deletions(-) diff --git a/.github/workflows/Tests.yml b/.github/workflows/Tests.yml index 14e7f17..f0cf3f0 100644 --- a/.github/workflows/Tests.yml +++ b/.github/workflows/Tests.yml @@ -73,12 +73,7 @@ on: required: false type: string subdir: - description: "Subdirectory containing the package's Project.toml. When set, Julia activates `` and classify-pr scopes the substantive-PR check to files under `/`. Empty (default) activates the repo root." - default: "" - required: false - type: string - exclude-subdirs: - description: "Negative scope for the substantive-PR check at root: newline-separated siblings to ignore. Skip Tests when changes are confined to `.github/**`/`.pre-commit-config.yaml`/`.gitignore`/`LICENSE` plus any of the listed siblings." + description: "Subdirectory containing the package's Project.toml. When set, Julia activates `` for buildpkg and runtest. Empty (default) activates the repo root." default: "" required: false type: string @@ -121,38 +116,14 @@ on: required: true jobs: - # Classify PR substance up front when per-package scoping is requested - # (`subdir` or `exclude-subdirs` set). Tests reads the result to skip - # PRs whose substantive changes are outside this package's scope. When - # neither input is set the classify-pr action runs in its global mode - # but Tests ignores its result, preserving today's "run on every PR" - # behavior for existing single-package consumers. - classify: - name: "Classify PR substance" - runs-on: ubuntu-latest - outputs: - substantive: ${{ steps.classify.outputs.substantive }} - steps: - - uses: ITensor/ITensorActions/.github/actions/classify-pr@mf/subdir-inputs - id: classify - with: - subdir: ${{ inputs.subdir }} - exclude-subdirs: ${{ inputs.exclude-subdirs }} - tests: name: "Julia ${{ inputs.julia-version }} - ${{ inputs.self-hosted && 'self-hosted' || inputs.os }} ${{ inputs.group != '' && format(' - {0}', inputs.group) || '' }}" - needs: classify permissions: contents: read - if: | - ( - !github.event.pull_request.draft - || inputs.run-all-on-draft - || (inputs.os == 'ubuntu-latest' && inputs.julia-version == '1') - ) && ( - (inputs.subdir == '' && inputs.exclude-subdirs == '') - || needs.classify.outputs.substantive == 'true' - ) + if: >- + !github.event.pull_request.draft + || inputs.run-all-on-draft + || (inputs.os == 'ubuntu-latest' && inputs.julia-version == '1') continue-on-error: ${{ inputs.continue-on-error || inputs.julia-version == 'nightly' }} runs-on: "${{ inputs.self-hosted && 'self-hosted' || inputs.os }}" timeout-minutes: ${{ inputs.timeout-minutes }} From cad8a6a24ec460c5fa29524259f794c3195ed87f Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 20:25:15 -0400 Subject: [PATCH 25/29] Redesign classify-pr: paths/exclude-paths, two-output split for tests vs. version-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `classify-pr` action becomes a generic scope check: - `paths` (positive scope): file triggers iff it matches one of these paths (equals or starts-with). - `exclude-paths` (negative scope): file does NOT trigger if it matches one of these. Default ignores `.gitignore` and `.pre-commit-config.yaml` (overridable). - Mutually exclusive; positive wins with a warning if both set. - Output: `triggers` (bool). Reusables: - `Tests.yml`: classify-pr step inside the matrix-leg job; all test work gated on `steps.classify.outputs.triggers`. Per-leg classify duplication is intentional — single-package consumers' matrix is at the caller side, so classify must live inside each leg to avoid a separate consumer-level job. - `IntegrationTest.yml`: same shape — classify-pr step inside the matrix leg. Drops the previous separate `classify` job, the `subdir`/`exclude-subdirs` inputs (replaced by `paths`/ `exclude-paths`), the `run-on-nonsubstantive` opt-out, and the substantive-only-by-default behavior. `subdir` is kept for its "develop the package in this subdirectory" semantic. - `VersionCheck.yml`: same shape; default `exclude-paths` adds `.github` so workflow-only PRs don't require a version bump. Net behavior: - Single-package consumers: defaults give "any change except pure metadata triggers tests" and "any change except metadata + `.github` triggers version check" — matches pre-#107 behavior. - Multi-package consumers (e.g. ITensors.jl): pass `paths` or `exclude-paths` per package scope. Workflow-as-part-of-package is expressed by listing `.github` in the per-package paths (e.g. `paths: ["NDTensors", ".github"]`). Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/actions/classify-pr/action.yml | 127 ++++++++++++------------- .github/workflows/IntegrationTest.yml | 72 ++++++-------- .github/workflows/Tests.yml | 40 ++++++-- .github/workflows/VersionCheck.yml | 30 +++--- 4 files changed, 146 insertions(+), 123 deletions(-) diff --git a/.github/actions/classify-pr/action.yml b/.github/actions/classify-pr/action.yml index 23379fe..94eee0e 100644 --- a/.github/actions/classify-pr/action.yml +++ b/.github/actions/classify-pr/action.yml @@ -1,18 +1,20 @@ -name: "Classify PR substance" -description: "Reports whether a PR makes substantive changes — anything beyond CI/config metadata files (`.github/**`, `.pre-commit-config.yaml`, `.gitignore`, `LICENSE`). Outputs substantive=true|false." +name: "Classify PR scope" +description: "Reports whether a PR has changes that should trigger this caller's work, given a path scope. A file 'triggers' iff it matches the caller's scope and is not in the exclude list. Outputs triggers=true|false." inputs: - subdir: - description: "Restrict the check to changes inside this subdirectory. Files outside `/` don't count, and within `/` the same metadata files that don't count at the repo root (`.github/`, `.pre-commit-config.yaml`, `.gitignore`, `LICENSE`) also don't count. Use this for a workflow that's scoped to one in-tree subpackage. Mutually exclusive with `exclude-subdirs`." + paths: + description: "Newline-separated list of path prefixes / files defining positive scope. A file triggers iff it equals one of these paths or is inside one (i.e., starts with `/`). Empty means everything is in scope. Mutually exclusive with `exclude-paths`." required: false default: "" - exclude-subdirs: - description: "Newline-separated list of sibling subdirectories whose changes don't count. Use this for a root-package workflow in a multi-package repo so that PRs confined to a sibling subpackage are reported non-substantive. The repo-root metadata files (`.github/`, `.pre-commit-config.yaml`, `.gitignore`, `LICENSE`) also don't count, as usual." + exclude-paths: + description: "Newline-separated list of path prefixes / files defining negative scope. A file does NOT trigger if it equals one of these or is inside one. Default ignores `.gitignore` and `.pre-commit-config.yaml`. Override entirely (e.g. include the package's sibling subpackage and re-list defaults if you want them kept)." required: false - default: "" + default: | + .gitignore + .pre-commit-config.yaml outputs: - substantive: - description: "true if the PR touches substantive files; false otherwise." - value: ${{ steps.classify.outputs.substantive }} + triggers: + description: "true if the PR has at least one changed file that matches the configured scope; false otherwise." + value: ${{ steps.classify.outputs.triggers }} runs: using: "composite" steps: @@ -22,71 +24,68 @@ runs: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} - SUBDIR: ${{ inputs.subdir }} - EXCLUDE_SUBDIRS: ${{ inputs.exclude-subdirs }} + PATHS: ${{ inputs.paths }} + EXCLUDE_PATHS: ${{ inputs.exclude-paths }} run: | set -euo pipefail - if [ -n "$SUBDIR" ] && [ -n "$EXCLUDE_SUBDIRS" ]; then - echo "::error::classify-pr: 'subdir' and 'exclude-subdirs' are mutually exclusive." - exit 1 + if [ -n "$PATHS" ] && [ -n "$EXCLUDE_PATHS" ]; then + # If paths is set, exclude-paths is ignored (caller is using positive scope). + # Surface a warning rather than failing so the user notices. + echo "::warning::classify-pr: 'paths' and 'exclude-paths' are mutually exclusive; using 'paths'." + EXCLUDE_PATHS="" fi if [ -z "${PR_NUMBER:-}" ]; then - echo "Not a pull_request event; defaulting to substantive=true." - echo "substantive=true" >> "$GITHUB_OUTPUT" + echo "Not a pull_request event; defaulting to triggers=true." + echo "triggers=true" >> "$GITHUB_OUTPUT" exit 0 fi files=$(gh api --paginate "repos/${REPO}/pulls/${PR_NUMBER}/files" --jq '.[].filename') if [ -z "$files" ]; then - echo "No files reported for PR #${PR_NUMBER}; defaulting to substantive=true." - echo "substantive=true" >> "$GITHUB_OUTPUT" + echo "No files reported for PR #${PR_NUMBER}; defaulting to triggers=true." + echo "triggers=true" >> "$GITHUB_OUTPUT" exit 0 fi echo "Changed files:" printf ' %s\n' $files - substantive=false - if [ -n "$SUBDIR" ]; then - echo "Positive scope: ${SUBDIR}/ (with the usual metadata-file exclusions applied inside)" - while IFS= read -r file; do - # Ignore files outside the scoped subdirectory. - case "$file" in - "$SUBDIR"/*) ;; - *) continue ;; - esac - # Within the subdirectory, apply the same metadata-file - # exclusions as at the repo root. - rel="${file#"$SUBDIR"/}" - case "$rel" in - .github/*|.pre-commit-config.yaml|.gitignore|LICENSE) continue ;; - esac - substantive=true - break - done <<< "$files" + + # Helper: check if a file matches any entry in a newline-separated list. + # An entry matches iff the file equals the entry, or the file starts with `/`. + matches_list() { + local file=$1 + local list=$2 + while IFS= read -r entry; do + [ -z "$entry" ] && continue + if [ "$file" = "$entry" ] || [[ "$file" == "$entry"/* ]]; then + return 0 + fi + done <<< "$list" + return 1 + } + + if [ -n "$PATHS" ]; then + echo "Positive scope: any file matching one of the paths." + printf ' %s\n' $PATHS else - # Build sibling-exclusion list (one prefix per non-empty line). - siblings=() - if [ -n "$EXCLUDE_SUBDIRS" ]; then - while IFS= read -r s; do - [ -z "$s" ] && continue - siblings+=("$s") - done <<< "$EXCLUDE_SUBDIRS" - echo "Root scope, excluding siblings: ${siblings[*]}" - fi - while IFS= read -r file; do - # Always-ignored metadata at root. - case "$file" in - .github/*|.pre-commit-config.yaml|.gitignore|LICENSE) continue ;; - esac - # Sibling-subdir exclusion (only when EXCLUDE_SUBDIRS set). - in_sibling=false - for sib in "${siblings[@]}"; do - case "$file" in - "$sib"/*) in_sibling=true; break ;; - esac - done - $in_sibling && continue - substantive=true - break - done <<< "$files" + echo "Default scope: any file outside the exclude list." + printf ' exclude: %s\n' $EXCLUDE_PATHS fi - echo "substantive=${substantive}" - echo "substantive=${substantive}" >> "$GITHUB_OUTPUT" + + triggers=false + while IFS= read -r file; do + if [ -n "$PATHS" ]; then + # Positive scope: file triggers iff it matches one of the listed paths. + if matches_list "$file" "$PATHS"; then + triggers=true + break + fi + else + # Negative scope: file triggers iff it is NOT in the exclude list. + if ! matches_list "$file" "$EXCLUDE_PATHS"; then + triggers=true + break + fi + fi + done <<< "$files" + + echo "triggers=${triggers}" + echo "triggers=${triggers}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/IntegrationTest.yml b/.github/workflows/IntegrationTest.yml index d120b55..111e6cf 100644 --- a/.github/workflows/IntegrationTest.yml +++ b/.github/workflows/IntegrationTest.yml @@ -23,50 +23,35 @@ on: required: false type: string subdir: - description: "Run the downstream tests against the package in this subdirectory. Also scopes the substantive-PR check to files under `/`." + description: "Run the downstream tests against the package in this subdirectory." default: "" required: false type: string - exclude-subdirs: - description: "Negative scope for the substantive-PR check at root: newline-separated siblings to ignore." + paths: + description: "Newline-separated list of path prefixes / files defining positive scope for the substantive-PR check. A file triggers IntegrationTest iff it equals one of these paths or starts with `/`. Empty means no positive-scope filter; the check falls back to `exclude-paths`. Mutually exclusive with `exclude-paths`." default: "" required: false type: string + exclude-paths: + description: "Newline-separated list of path prefixes / files defining negative scope for the substantive-PR check. A file does NOT trigger IntegrationTest if it matches one of these. Default ignores `.gitignore` and `.pre-commit-config.yaml`." + default: | + .gitignore + .pre-commit-config.yaml + required: false + type: string run-on-draft: description: "When true, run integration tests even on draft PRs" default: false required: false type: boolean - run-on-nonsubstantive: - description: "Defaults to false so metadata-only changes don't trigger downstream tests." - default: false - required: false - type: boolean secrets: INTEGRATIONTEST_PAT: description: "GitHub PAT for cloning private repositories via HTTPS." required: false jobs: - # Classify PR substance up front so the matrix and gate jobs can both - # gate on it. Non-substantive PRs (touching only `.github/**`, - # `.pre-commit-config.yaml`, `.gitignore`, `LICENSE`) skip the matrix - # entirely by default; the gate then short-circuits to success. - classify: - name: "Classify PR substance" - runs-on: ubuntu-latest - outputs: - substantive: ${{ steps.classify.outputs.substantive }} - steps: - - uses: ITensor/ITensorActions/.github/actions/classify-pr@mf/subdir-inputs - id: classify - with: - subdir: ${{ inputs.subdir }} - exclude-subdirs: ${{ inputs.exclude-subdirs }} - integration-test: name: "${{ matrix.pkg }}" - needs: classify # Skip the matrix-driven leg entirely when `pkgs` is empty. Without this # guard, GitHub Actions reports an empty-matrix job as `failure`, which # propagates to the run's overall conclusion even though the aggregating @@ -75,8 +60,7 @@ jobs: # the check is robust against whitespace / block-scalar style. if: | fromJSON(inputs.pkgs)[0] != null && - (!github.event.pull_request.draft || inputs.run-on-draft) && - (needs.classify.outputs.substantive == 'true' || inputs.run-on-nonsubstantive) + (!github.event.pull_request.draft || inputs.run-on-draft) runs-on: ubuntu-latest permissions: contents: read @@ -86,6 +70,17 @@ jobs: pkg: ${{ fromJSON(inputs.pkgs) }} steps: + - uses: ITensor/ITensorActions/.github/actions/classify-pr@mf/subdir-inputs + id: classify + with: + paths: ${{ inputs.paths }} + exclude-paths: ${{ inputs.exclude-paths }} + + - name: "Skip on out-of-scope PR" + if: "${{ steps.classify.outputs.triggers != 'true' }}" + run: | + echo "PR has no changes in this caller's scope; integration test skipped." + # Decide whether this matrix leg should run, and emit `skip=true|false`. # Skips when the entry is a URL that needs auth and the PR is from a # fork (no secrets are exposed to fork PRs under `pull_request:`). @@ -95,6 +90,7 @@ jobs: # `workflow_dispatch` (where secrets are in scope). - name: "Decide whether to run" id: gate + if: "${{ steps.classify.outputs.triggers == 'true' }}" env: PKG: ${{ matrix.pkg }} HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} @@ -126,7 +122,7 @@ jobs: echo "skip=false" >> "$GITHUB_OUTPUT" - name: "Record skipped dependency" id: skip_artifact - if: steps.gate.outputs.skip == 'true' + if: "${{ steps.classify.outputs.triggers == 'true' && steps.gate.outputs.skip == 'true' }}" env: PKG: ${{ matrix.pkg }} run: | @@ -136,22 +132,22 @@ jobs: echo "key=$key" >> "$GITHUB_OUTPUT" echo "path=$path" >> "$GITHUB_OUTPUT" - name: "Upload skipped-dependency marker" - if: steps.gate.outputs.skip == 'true' + if: "${{ steps.classify.outputs.triggers == 'true' && steps.gate.outputs.skip == 'true' }}" uses: actions/upload-artifact@v7 with: name: "integration-test-skip-${{ steps.skip_artifact.outputs.key }}" path: ${{ steps.skip_artifact.outputs.path }} - uses: actions/checkout@v6 - if: steps.gate.outputs.skip != 'true' + if: "${{ steps.classify.outputs.triggers == 'true' && steps.gate.outputs.skip != 'true' }}" with: ref: ${{ github.event.pull_request.head.sha || github.sha }} - uses: julia-actions/setup-julia@v3 - if: steps.gate.outputs.skip != 'true' + if: "${{ steps.classify.outputs.triggers == 'true' && steps.gate.outputs.skip != 'true' }}" with: version: ${{ inputs.julia-version }} arch: x64 - name: "Configure git authentication for private repository" - if: steps.gate.outputs.skip != 'true' + if: "${{ steps.classify.outputs.triggers == 'true' && steps.gate.outputs.skip != 'true' }}" env: TOKEN: ${{ secrets.INTEGRATIONTEST_PAT }} PKG: ${{ matrix.pkg }} @@ -162,7 +158,7 @@ jobs: fi fi - name: "Run the downstream tests" - if: steps.gate.outputs.skip != 'true' + if: "${{ steps.classify.outputs.triggers == 'true' && steps.gate.outputs.skip != 'true' }}" shell: julia --color=yes --project=downstream {0} env: PKG: ${{ matrix.pkg }} @@ -228,7 +224,7 @@ jobs: # posts a passing check with the same name to satisfy branch protection. gate: name: "IntegrationTest" - needs: [classify, integration-test] + needs: integration-test if: ${{ always() }} runs-on: ubuntu-latest permissions: @@ -248,14 +244,8 @@ jobs: EVENT_NAME: ${{ github.event_name }} HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} BASE_REPO: ${{ github.repository }} - SUBSTANTIVE: ${{ needs.classify.outputs.substantive }} - RUN_ON_NONSUBSTANTIVE: ${{ inputs.run-on-nonsubstantive }} run: | - echo "test.result=$RESULT pkgs=$PKGS event=$EVENT_NAME substantive=$SUBSTANTIVE" - if [ "$SUBSTANTIVE" != "true" ] && [ "$RUN_ON_NONSUBSTANTIVE" != "true" ]; then - echo "PR is non-substantive (only .github/**, .pre-commit-config.yaml, .gitignore, LICENSE); IntegrationTest skipped without running. Set inputs.run-on-nonsubstantive=true on the caller to override." - exit 0 - fi + echo "test.result=$RESULT pkgs=$PKGS event=$EVENT_NAME" pkg_count=$(printf '%s' "$PKGS" | jq 'length') if [ "$pkg_count" -eq 0 ]; then echo "No downstream integration tests configured; passing." diff --git a/.github/workflows/Tests.yml b/.github/workflows/Tests.yml index f0cf3f0..6ee1123 100644 --- a/.github/workflows/Tests.yml +++ b/.github/workflows/Tests.yml @@ -77,6 +77,18 @@ on: default: "" required: false type: string + paths: + description: "Newline-separated list of path prefixes / files defining positive scope for the substantive-PR check. A file triggers tests iff it equals one of these paths or starts with `/`. Empty means no positive-scope filter; the check falls back to `exclude-paths`. Mutually exclusive with `exclude-paths`." + default: "" + required: false + type: string + exclude-paths: + description: "Newline-separated list of path prefixes / files defining negative scope for the substantive-PR check. A file does NOT trigger if it matches one of these. Default ignores `.gitignore` and `.pre-commit-config.yaml`. Override entirely (e.g. add a sibling subpackage and re-list defaults if you want them kept)." + default: | + .gitignore + .pre-commit-config.yaml + required: false + type: string continue-on-error: description: "Prevent the workflow run from failing if/when the job fails" required: false @@ -128,10 +140,22 @@ jobs: runs-on: "${{ inputs.self-hosted && 'self-hosted' || inputs.os }}" timeout-minutes: ${{ inputs.timeout-minutes }} steps: + - uses: ITensor/ITensorActions/.github/actions/classify-pr@mf/subdir-inputs + id: classify + with: + paths: ${{ inputs.paths }} + exclude-paths: ${{ inputs.exclude-paths }} + + - name: "Skip on out-of-scope PR" + if: "${{ steps.classify.outputs.triggers != 'true' }}" + run: | + echo "PR has no changes in this caller's scope; tests skipped." + - uses: actions/checkout@v6 + if: "${{ steps.classify.outputs.triggers == 'true' }}" - name: "Install apt packages" - if: "${{ inputs.apt-packages != '' && runner.os == 'Linux' }}" + if: "${{ steps.classify.outputs.triggers == 'true' && inputs.apt-packages != '' && runner.os == 'Linux' }}" env: APT_PACKAGES: ${{ inputs.apt-packages }} run: | @@ -142,24 +166,25 @@ jobs: sudo apt-get install -y $APT_PACKAGES - name: "Setup Julia ${{ inputs.julia-version }}" + if: "${{ steps.classify.outputs.triggers == 'true' }}" uses: julia-actions/setup-julia@v3 with: version: "${{ inputs.julia-version }}" arch: "${{ inputs.julia-arch || runner.arch }}" - uses: julia-actions/cache@v2 - if: "${{ inputs.cache }}" + if: "${{ steps.classify.outputs.triggers == 'true' && inputs.cache }}" with: token: "${{ secrets.GITHUB_TOKEN }}" - uses: julia-actions/julia-buildpkg@v1 - if: "${{ inputs.buildpkg }}" + if: "${{ steps.classify.outputs.triggers == 'true' && inputs.buildpkg }}" with: localregistry: "${{ inputs.localregistry }}" project: "${{ inputs.subdir != '' && inputs.subdir || inputs.project }}" - name: "Export extra environment variables" - if: "${{ inputs.extra-env != '' }}" + if: "${{ steps.classify.outputs.triggers == 'true' && inputs.extra-env != '' }}" shell: "bash" env: EXTRA_ENV: ${{ inputs.extra-env }} @@ -167,6 +192,7 @@ jobs: printf '%s\n' "$EXTRA_ENV" >> "$GITHUB_ENV" - name: "Run tests ${{ inputs.self-hosted && '' || format('on {0}', inputs.os) }} with Julia v${{ inputs.julia-version }}" + if: "${{ steps.classify.outputs.triggers == 'true' }}" uses: julia-actions/julia-runtest@v1 with: project: "${{ inputs.subdir != '' && inputs.subdir || inputs.project }}" @@ -186,20 +212,20 @@ jobs: JULIA_NUM_THREADS: "${{ inputs.nthreads }}" - name: "Upload artifacts" - if: "${{ always() && inputs.upload-artifacts-path != '' }}" + if: "${{ always() && steps.classify.outputs.triggers == 'true' && inputs.upload-artifacts-path != '' }}" uses: actions/upload-artifact@v7 with: name: "artifacts-${{ inputs.os }}-julia-${{ inputs.julia-version }}${{ inputs.group != '' && format('-{0}', inputs.group) || '' }}" path: "${{ inputs.upload-artifacts-path }}" - uses: julia-actions/julia-processcoverage@v1 - if: "${{ inputs.coverage }}" + if: "${{ steps.classify.outputs.triggers == 'true' && inputs.coverage }}" with: directories: "${{ inputs.coverage-directories }}" - name: "Report coverage with Codecov" uses: codecov/codecov-action@v6 - if: "${{ inputs.coverage }}" + if: "${{ steps.classify.outputs.triggers == 'true' && inputs.coverage }}" with: files: lcov.info token: "${{ secrets.CODECOV_TOKEN }}" diff --git a/.github/workflows/VersionCheck.yml b/.github/workflows/VersionCheck.yml index 2cc9ca7..e2a8bd0 100644 --- a/.github/workflows/VersionCheck.yml +++ b/.github/workflows/VersionCheck.yml @@ -14,15 +14,23 @@ on: required: false type: string subdir: - description: "Subdirectory containing the package's Project.toml. When set, VersionCheck reads `/Project.toml` and classify-pr scopes the substantive-PR check to files under `/`. Empty (default) reads root Project.toml." + description: "Subdirectory containing the package's Project.toml. When set, VersionCheck reads `/Project.toml` for the version-bump check. Empty (default) reads root Project.toml." default: "" required: false type: string - exclude-subdirs: - description: "Negative scope for the substantive-PR check at root: newline-separated siblings to ignore." + paths: + description: "Newline-separated list of path prefixes / files defining positive scope for the substantive-PR check. A file triggers VersionCheck iff it equals one of these paths or starts with `/`. Empty means no positive-scope filter; the check falls back to `exclude-paths`. Mutually exclusive with `exclude-paths`." default: "" required: false type: string + exclude-paths: + description: "Newline-separated list of path prefixes / files defining negative scope for the substantive-PR check. A file does NOT trigger VersionCheck if it matches one of these. Default ignores `.gitignore`, `.pre-commit-config.yaml`, and `.github` (workflow / config metadata changes don't require a version bump)." + default: | + .gitignore + .pre-commit-config.yaml + .github + required: false + type: string jobs: version-check: @@ -38,25 +46,25 @@ jobs: - uses: ITensor/ITensorActions/.github/actions/classify-pr@mf/subdir-inputs id: classify with: - subdir: ${{ inputs.subdir }} - exclude-subdirs: ${{ inputs.exclude-subdirs }} + paths: ${{ inputs.paths }} + exclude-paths: ${{ inputs.exclude-paths }} - - name: "Skip version check on non-substantive PR" - if: steps.classify.outputs.substantive != 'true' + - name: "Skip version check on out-of-scope PR" + if: steps.classify.outputs.triggers != 'true' run: | - echo "PR has no substantive changes in scope; no version bump required. Reporting success." + echo "PR has no changes in this caller's scope; no version bump required. Reporting success." - name: "Fetch base branch" - if: steps.classify.outputs.substantive == 'true' + if: steps.classify.outputs.triggers == 'true' run: git fetch --depth=1 origin "${{ github.base_ref }}" - uses: julia-actions/setup-julia@v3 - if: steps.classify.outputs.substantive == 'true' + if: steps.classify.outputs.triggers == 'true' with: version: ${{ inputs.julia-version }} - name: "Check version was bumped" - if: steps.classify.outputs.substantive == 'true' + if: steps.classify.outputs.triggers == 'true' shell: julia --color=yes {0} env: SUBDIR: ${{ inputs.subdir }} From 7817f523ba8d457ec2c4ab5e908ad26e0099bbb8 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 20:32:55 -0400 Subject: [PATCH 26/29] Tests/IntegrationTest/VersionCheck: auto-append subdir to classify-pr paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a reusable has a `subdir` input set, the package is by definition in scope for that workflow's classify-pr check (you're testing / version-checking that subdir). Auto-appending `subdir` to `paths` saves callers from repeating it. Concretely on TestsNDTensors / IntegrationTestNDTensors callers, the caller now passes `subdir: "NDTensors"` + `paths: ".github"` instead of having to spell out `paths: "NDTensors\n.github"`. On VersionCheckNDTensors the caller can drop `paths` entirely — `subdir` alone gives the right scope. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/IntegrationTest.yml | 6 +++++- .github/workflows/Tests.yml | 6 +++++- .github/workflows/VersionCheck.yml | 7 ++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.github/workflows/IntegrationTest.yml b/.github/workflows/IntegrationTest.yml index 111e6cf..2553a49 100644 --- a/.github/workflows/IntegrationTest.yml +++ b/.github/workflows/IntegrationTest.yml @@ -73,7 +73,11 @@ jobs: - uses: ITensor/ITensorActions/.github/actions/classify-pr@mf/subdir-inputs id: classify with: - paths: ${{ inputs.paths }} + # `subdir` (the in-tree package under test) is auto-appended + # to the positive scope so callers don't have to repeat it. + paths: | + ${{ inputs.subdir }} + ${{ inputs.paths }} exclude-paths: ${{ inputs.exclude-paths }} - name: "Skip on out-of-scope PR" diff --git a/.github/workflows/Tests.yml b/.github/workflows/Tests.yml index 6ee1123..c169a94 100644 --- a/.github/workflows/Tests.yml +++ b/.github/workflows/Tests.yml @@ -143,7 +143,11 @@ jobs: - uses: ITensor/ITensorActions/.github/actions/classify-pr@mf/subdir-inputs id: classify with: - paths: ${{ inputs.paths }} + # `subdir` (the package being tested) is auto-appended to + # the positive scope so callers don't have to repeat it. + paths: | + ${{ inputs.subdir }} + ${{ inputs.paths }} exclude-paths: ${{ inputs.exclude-paths }} - name: "Skip on out-of-scope PR" diff --git a/.github/workflows/VersionCheck.yml b/.github/workflows/VersionCheck.yml index e2a8bd0..12c1551 100644 --- a/.github/workflows/VersionCheck.yml +++ b/.github/workflows/VersionCheck.yml @@ -46,7 +46,12 @@ jobs: - uses: ITensor/ITensorActions/.github/actions/classify-pr@mf/subdir-inputs id: classify with: - paths: ${{ inputs.paths }} + # `subdir` (the in-tree package whose Project.toml is checked) is + # auto-appended to the positive scope so callers don't have to + # repeat it. + paths: | + ${{ inputs.subdir }} + ${{ inputs.paths }} exclude-paths: ${{ inputs.exclude-paths }} - name: "Skip version check on out-of-scope PR" From ce4c352f5bc6e3ae809175e23438b1ce8825eeb7 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 20:53:30 -0400 Subject: [PATCH 27/29] CompatHelper: simplify subdirs construction via broadcasting Replaces the ternary listing two parallel arrays with a single base list and a broadcasted joinpath when subdir is non-empty. Note the empty-string entry becomes `/` (with trailing slash) under broadcasting; CompatHelper treats that the same as `` when reading `/Project.toml`, so no functional change. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/CompatHelper.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/CompatHelper.yml b/.github/workflows/CompatHelper.yml index 9dddd7e..cfa7809 100644 --- a/.github/workflows/CompatHelper.yml +++ b/.github/workflows/CompatHelper.yml @@ -113,9 +113,8 @@ jobs: end end subdir = get(ENV, "SUBDIR", "") - subdirs = isempty(subdir) ? - ["", "docs", "examples", "test"] : - [subdir, joinpath(subdir, "docs"), joinpath(subdir, "examples"), joinpath(subdir, "test")] + subdirs = ["", "docs", "examples", "test"] + isempty(subdir) || (subdirs = joinpath.(subdir, subdirs)) token_owner = get(ENV, "TOKEN_OWNER", "") # If TOKEN_OWNER contains a JSON error body (happens when only GITHUB_TOKEN # is available, e.g. in private repos without COMPATHELPER_PAT), treat it as From 9150ec7fd45ccb7c7de4072d5f7b881c822ed385 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 21:15:50 -0400 Subject: [PATCH 28/29] classify-pr: treat whitespace-only paths/exclude-paths inputs as empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reusables that auto-append `subdir` into `paths` (Tests.yml, IntegrationTest.yml, VersionCheck.yml) produce a list of all-empty lines when both `subdir` and the caller's `paths` are empty. Bash's [ -n ] would otherwise read that whitespace-only string as "positive scope set", flipping the action into positive-scope mode with an empty list — so no files match, triggers=false, and the caller's exclude-paths default is silently bypassed. Normalize at the top of the script: any input that contains only empty lines is treated as empty. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/actions/classify-pr/action.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/actions/classify-pr/action.yml b/.github/actions/classify-pr/action.yml index 94eee0e..68d1eb3 100644 --- a/.github/actions/classify-pr/action.yml +++ b/.github/actions/classify-pr/action.yml @@ -28,6 +28,20 @@ runs: EXCLUDE_PATHS: ${{ inputs.exclude-paths }} run: | set -euo pipefail + # Normalize: treat whitespace-only inputs as empty. This matters + # because reusables that auto-append a value (e.g. `subdir`) into + # `paths` produce a list of all-empty lines when the appended + # value is also empty — bash's [ -n ] would otherwise misread that + # as "positive scope set", flipping the action into the wrong mode. + has_content() { + while IFS= read -r line; do + [ -n "$line" ] && return 0 + done <<< "$1" + return 1 + } + has_content "$PATHS" || PATHS="" + has_content "$EXCLUDE_PATHS" || EXCLUDE_PATHS="" + if [ -n "$PATHS" ] && [ -n "$EXCLUDE_PATHS" ]; then # If paths is set, exclude-paths is ignored (caller is using positive scope). # Surface a warning rather than failing so the user notices. From d99f9d8a8e3cbe30324f77c1de20e9987a26eb66 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Tue, 5 May 2026 22:27:43 -0400 Subject: [PATCH 29/29] Document new inputs in README; pin classify-pr back to main - README: add subdir / paths / exclude-paths rows to the input tables for Tests, IntegrationTest, VersionCheck, CheckCompatBounds, CompatHelper, and Registrator. Mark `project` as a deprecated alias for `subdir` on Tests and CheckCompatBounds. Rewrite the VersionCheck "non-substantive PRs" prose to describe the new paths / exclude-paths model. - Tests / IntegrationTest / VersionCheck reusables: flip the inner `classify-pr` reference from the development branch self-reference back to the main branch for the post-merge state. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/IntegrationTest.yml | 2 +- .github/workflows/Tests.yml | 2 +- .github/workflows/VersionCheck.yml | 2 +- README.md | 21 +++++++++++++++++---- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/.github/workflows/IntegrationTest.yml b/.github/workflows/IntegrationTest.yml index 2553a49..a513ec1 100644 --- a/.github/workflows/IntegrationTest.yml +++ b/.github/workflows/IntegrationTest.yml @@ -70,7 +70,7 @@ jobs: pkg: ${{ fromJSON(inputs.pkgs) }} steps: - - uses: ITensor/ITensorActions/.github/actions/classify-pr@mf/subdir-inputs + - uses: ITensor/ITensorActions/.github/actions/classify-pr@main id: classify with: # `subdir` (the in-tree package under test) is auto-appended diff --git a/.github/workflows/Tests.yml b/.github/workflows/Tests.yml index c169a94..d78f1fb 100644 --- a/.github/workflows/Tests.yml +++ b/.github/workflows/Tests.yml @@ -140,7 +140,7 @@ jobs: runs-on: "${{ inputs.self-hosted && 'self-hosted' || inputs.os }}" timeout-minutes: ${{ inputs.timeout-minutes }} steps: - - uses: ITensor/ITensorActions/.github/actions/classify-pr@mf/subdir-inputs + - uses: ITensor/ITensorActions/.github/actions/classify-pr@main id: classify with: # `subdir` (the package being tested) is auto-appended to diff --git a/.github/workflows/VersionCheck.yml b/.github/workflows/VersionCheck.yml index 12c1551..5819a92 100644 --- a/.github/workflows/VersionCheck.yml +++ b/.github/workflows/VersionCheck.yml @@ -43,7 +43,7 @@ jobs: steps: - uses: actions/checkout@v6 - - uses: ITensor/ITensorActions/.github/actions/classify-pr@mf/subdir-inputs + - uses: ITensor/ITensorActions/.github/actions/classify-pr@main id: classify with: # `subdir` (the in-tree package whose Project.toml is checked) is diff --git a/README.md b/README.md index c036082..f1c46fc 100644 --- a/README.md +++ b/README.md @@ -148,8 +148,12 @@ the full matrix even on draft PRs, set it to `true`: |---|---|---|---| | `julia-version` | string | `"1"` | Julia version passed to `julia-actions/setup-julia`. | | `julia-arch` | string | runner arch | Architecture of Julia to be used. | -| `project` | string | `"@."` | Value passed to Julia's `--project` flag. | +| `subdir` | string | `""` | Subdirectory containing the package's Project.toml (for an in-tree subpackage). When set, Julia activates `` for buildpkg and runtest, and `` is auto-appended to `paths` so the per-package classify-pr step considers files inside it as in-scope. | +| `project` | string | `"@."` | Deprecated alias for `subdir`. Value passed to Julia's `--project` flag. Set `subdir` instead; will be removed in a future major release. | | `group` | string | `""` | Test group selector. Exposed to tests via the `GROUP` environment variable so a `runtests.jl` can selectively run a subset. | +| `test-args` | string | `""` | Newline-separated arguments forwarded to `Pkg.test(; test_args=...)` (via `julia-actions/julia-runtest`'s `test_args` input). Useful when `runtests.jl` selects test groups via `ARGS` rather than the `GROUP` env-var. | +| `paths` | string | `""` | Positive scope for the substantive-PR check: a file triggers tests iff it equals one of these paths or starts with `/`. Newline-separated. Mutually exclusive with `exclude-paths`. The `subdir` input (if set) is auto-appended. | +| `exclude-paths` | string | `.gitignore`
`.pre-commit-config.yaml` | Negative scope: a file does NOT trigger tests if it matches one of these paths. Default ignores pure metadata files only — workflow file edits (`.github/workflows/**`) do count, by design. Override entirely (defaults are not implicitly preserved). | | `self-hosted` | bool | `false` | Run on a self-hosted runner instead of `os`. | | `os` | string | `"ubuntu-latest"` | Runner image used when `self-hosted` is `false`. | | `nthreads` | number | `1` | Value of `JULIA_NUM_THREADS`. | @@ -430,6 +434,7 @@ jobs: |---|---|---|---| | `julia-version` | string | `"1"` | Julia version passed to `julia-actions/setup-julia`. | | `localregistry` | string | `""` | Newline-separated list of extra registry URLs (besides General) to scan for dependency updates. | +| `subdir` | string | `""` | Subdirectory containing the package's Project.toml (for an in-tree subpackage). When set, CompatHelper bumps `/Project.toml` plus its `/{docs,examples,test}/Project.toml` siblings if present. Empty (default) bumps the standard root + docs + examples + test set. | ### Secrets @@ -600,7 +605,10 @@ jobs: | `julia-version` | string | `"1"` | Julia version passed to `julia-actions/setup-julia`. | | `pkgs` | string | **required** | JSON-array string of registered package names and/or git URLs. Use `'[]'` to configure no downstream tests. | | `localregistry` | string | `""` | Newline-separated list of extra registry URLs to add before resolving. | +| `subdir` | string | `""` | Run the downstream tests against the package located in this subdirectory (rather than the repo root). When set, `` is also auto-appended to `paths` so the per-package classify-pr considers files inside it as in-scope. | | `extra-dev-paths` | string | `""` | Newline-separated list of additional local package paths to develop alongside the repository root before testing downstream packages. | +| `paths` | string | `""` | Positive scope for the substantive-PR check: a file triggers IntegrationTest iff it equals one of these paths or starts with `/`. Newline-separated. Mutually exclusive with `exclude-paths`. The `subdir` input (if set) is auto-appended. | +| `exclude-paths` | string | `.gitignore`
`.pre-commit-config.yaml` | Negative scope: a file does NOT trigger IntegrationTest if it matches one of these. Default ignores pure metadata files only. Override entirely. | | `run-on-draft` | bool | `false` | Run integration tests on draft PRs. When `false`, draft PRs skip integration tests entirely. | The companion `IntegrationTestRequest.yml` workflow (used for the `/integrationtest ...` comment trigger shown above) has its own inputs: @@ -641,9 +649,12 @@ jobs: | Input | Type | Default | Description | |---|---|---|---| | `julia-version` | string | `"1"` | Julia version passed to `julia-actions/setup-julia`. | -| `localregistry` | string | `""` | Newline-separated list of extra registry URLs to consult when determining the previously registered version. | +| `localregistry` | string | `""` | Unused, kept for backward compat with existing callers. | +| `subdir` | string | `""` | Subdirectory containing the package's Project.toml (for an in-tree subpackage). When set, the version-bump check reads `/Project.toml` instead of root, and `` is auto-appended to `paths` so the substantive-PR check is scoped to that subpackage. | +| `paths` | string | `""` | Positive scope for the substantive-PR check: a file triggers VersionCheck iff it matches one of these paths. Newline-separated. Mutually exclusive with `exclude-paths`. | +| `exclude-paths` | string | `.gitignore`
`.pre-commit-config.yaml`
`.github` | Negative scope: a file does NOT trigger VersionCheck if it matches one of these. Default ignores pure metadata plus `.github` (workflow-only PRs don't require a version bump). Override entirely. | -The check is automatically skipped on PRs classified as non-substantive (changes limited to `.github/**`, `.pre-commit-config.yaml`, `.gitignore`, or `LICENSE`); those PRs pass without requiring a version bump. +The version-bump check is skipped (reported as success) when classify-pr says no in-scope files changed — for example a workflow-only PR on a single-package consumer (default `exclude-paths` includes `.github`), or an NDTensors-only PR on a multi-package caller scoped to root via `exclude-paths: NDTensors`. ## Check Compat Bounds @@ -685,7 +696,8 @@ jobs: | Input | Type | Default | Description | |---|---|---|---| | `julia-version` | string | `"1"` | Julia version passed to `julia-actions/setup-julia`. | -| `project` | string | `"@."` | Value passed to Julia's `--project` flag during `julia-actions/julia-buildpkg`. | +| `subdir` | string | `""` | Subdirectory containing the package's Project.toml (for an in-tree subpackage). When set, both `project` (buildpkg's `--project`) and `workspace-root` (the check-compat-bounds script's workspace root) resolve to ``. | +| `project` | string | `"@."` | Deprecated alias for `subdir`. Value passed to Julia's `--project` flag during `julia-actions/julia-buildpkg`. When `subdir` is set, it takes precedence. | | `cache` | bool | `true` | Use `julia-actions/cache`. | | `buildpkg` | bool | `true` | Run `julia-actions/julia-buildpkg` before the check. Disable only if the workspace is instantiated some other way. | | `localregistry` | string | `""` | Newline-separated list of extra registry URLs to add before resolving (forwarded to `julia-actions/julia-buildpkg`). | @@ -851,6 +863,7 @@ jobs: | `localregistry` | Local registry repo (`owner/name`) for packages not in General | `""` | | `trigger` | Comment trigger phrase for on-demand registration | `/register` | | `julia-version` | Julia version used by the workflow | `1` | +| `subdir` | Subdirectory containing the package's Project.toml (for an in-tree subpackage). When set, the workflow reads `package//Project.toml` instead of root and posts `@JuliaRegistrator register subdir=`, so multi-package repos can auto-register each in-tree package via a matrix invocation. | `""` | ### Secrets