Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
86d78a5
Add subdir-aware inputs to CompatHelper, Documentation, and Tests
mtfishman May 5, 2026
8070e12
Tests.yml: add extra-dev-paths input for in-tree subpackages
mtfishman May 5, 2026
f3c9cb7
Registrator.yml: add subdir input for in-tree subpackage registration
mtfishman May 5, 2026
ce3cc68
Tests.yml: pre-step respects inputs.project; VersionCheck: subdir sup…
mtfishman May 5, 2026
f5f5b3d
VersionCheck.yml: fix Julia hard-scope bug in per-project results loop
mtfishman May 5, 2026
d915e74
Revert Tests.yml extra-dev-paths input
mtfishman May 5, 2026
6d8fa9e
Revert Documentation.yml extra-dev-paths input
mtfishman May 5, 2026
67f5f27
Revert CompatHelper.yml subdirs input
mtfishman May 5, 2026
032dcdb
Revert VersionCheck.yml to single-project classify-pr shape
mtfishman May 5, 2026
e391c4b
IntegrationTest.yml: add subdir input for testing in-tree subpackages
mtfishman May 5, 2026
ff43a34
IntegrationTest.yml: skip non-substantive PRs by default
mtfishman May 5, 2026
0ea3ea1
IntegrationTest.yml: tighten input descriptions
mtfishman May 5, 2026
c68e186
IntegrationTest.yml: refine run-on-nonsubstantive description
mtfishman May 5, 2026
fc12fe8
classify-pr: add subdir + exclude-subdirs inputs
mtfishman May 5, 2026
7f540db
Tests.yml: rename project → subdir, add per-package classify-pr scoping
mtfishman May 5, 2026
a9f4eb0
Tests.yml: restore project as a deprecated backward-compat alias
mtfishman May 5, 2026
c77299a
IntegrationTest.yml: forward subdir + exclude-subdirs to classify-pr
mtfishman May 5, 2026
8754fcc
VersionCheck.yml: add subdir + exclude-subdirs
mtfishman May 5, 2026
eb9a78d
CheckCompatBounds.yml: add subdir, deprecate project
mtfishman May 5, 2026
19214b8
CompatHelper.yml: add subdir input
mtfishman May 5, 2026
8624d69
classify-pr: apply metadata-file exclusions inside subdir scope; rewr…
mtfishman May 5, 2026
1a06986
Tests, IntegrationTest: drop inner classify-job permissions block
mtfishman May 5, 2026
a87a846
Tests, IntegrationTest, VersionCheck: pin classify-pr action to the m…
mtfishman May 5, 2026
ebb04b2
Tests.yml: remove inner classify job
mtfishman May 5, 2026
cad8a6a
Redesign classify-pr: paths/exclude-paths, two-output split for tests…
mtfishman May 6, 2026
7817f52
Tests/IntegrationTest/VersionCheck: auto-append subdir to classify-pr…
mtfishman May 6, 2026
ce4c352
CompatHelper: simplify subdirs construction via broadcasting
mtfishman May 6, 2026
9150ec7
classify-pr: treat whitespace-only paths/exclude-paths inputs as empty
mtfishman May 6, 2026
d99f9d8
Document new inputs in README; pin classify-pr back to main
mtfishman May 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 82 additions & 16 deletions .github/actions/classify-pr/action.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
name: "Classify PR substance"
description: "Determines whether a PR touches substantive files or only CI/config (workflow-only) files. 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:
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 `<path>/`). Empty means everything is in scope. Mutually exclusive with `exclude-paths`."
required: false
default: ""
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: |
.gitignore
.pre-commit-config.yaml
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."
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:
Expand All @@ -13,27 +24,82 @@ runs:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PATHS: ${{ inputs.paths }}
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.
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

# 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 `<entry>/`.
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
echo "Default scope: any file outside the exclude list."
printf ' exclude: %s\n' $EXCLUDE_PATHS
fi

triggers=false
while IFS= read -r file; do
case "$file" in
.github/*|.pre-commit-config.yaml|.gitignore|LICENSE) ;;
*) substantive=true; break ;;
esac
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 "substantive=${substantive}"
echo "substantive=${substantive}" >> "$GITHUB_OUTPUT"

echo "triggers=${triggers}"
echo "triggers=${triggers}" >> "$GITHUB_OUTPUT"
11 changes: 8 additions & 3 deletions .github/workflows/CheckCompatBounds.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<subdir>`. Empty (default) uses repo root."
default: ""
required: false
type: string
cache:
description: "Use the julia-actions/cache action for caching."
default: true
Expand Down Expand Up @@ -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 }}"
8 changes: 8 additions & 0 deletions .github/workflows/CompatHelper.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ on:
default: ""
required: false
type: string
subdir:
description: "Subdirectory containing the package's Project.toml. When set, CompatHelper bumps `<subdir>/Project.toml` plus `<subdir>/{docs,examples,test}/Project.toml` if present. Empty (default) uses repo root."
default: ""
required: false
type: string

jobs:
compathelper:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -106,7 +112,9 @@ jobs:
push!(registries, Pkg.RegistrySpec(; url=registry_url, name=registry_name))
end
end
subdir = get(ENV, "SUBDIR", "")
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
Expand Down
48 changes: 41 additions & 7 deletions .github/workflows/IntegrationTest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,23 @@ on:
default: ""
required: false
type: string
subdir:
description: "Run the downstream tests against the package in this subdirectory."
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 IntegrationTest iff it equals one of these paths or starts with `<path>/`. 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
Expand Down Expand Up @@ -53,6 +70,21 @@ jobs:
pkg: ${{ fromJSON(inputs.pkgs) }}

steps:
- uses: ITensor/ITensorActions/.github/actions/classify-pr@main
id: classify
with:
# `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"
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:`).
Expand All @@ -62,6 +94,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 }}
Expand Down Expand Up @@ -93,7 +126,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: |
Expand All @@ -103,22 +136,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 }}
Expand All @@ -129,12 +162,13 @@ 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 }}
LOCALREGISTRY: ${{ inputs.localregistry }}
EXTRA_DEV_PATHS: ${{ inputs.extra-dev-paths }}
PKG_DIR: ${{ inputs.subdir || '.' }}
JULIA_PKG_SERVER_REGISTRY_PREFERENCE: eager
run: |
using Pkg
Expand All @@ -148,7 +182,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", "")
Expand Down
34 changes: 26 additions & 8 deletions .github/workflows/Registrator.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<subdir>/Project.toml` and posts `@JuliaRegistrator register subdir=<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:
Expand Down Expand Up @@ -83,6 +88,7 @@ jobs:
id: meta
env:
OLD_REF: ${{ github.event.before }}
SUBDIR: ${{ inputs.subdir }}
shell: julia --color=yes {0}
run: |
using TOML
Expand All @@ -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'] => ' ')
Expand All @@ -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
Expand Down Expand Up @@ -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:"
Expand Down Expand Up @@ -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
Expand All @@ -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'
Expand Down
Loading
Loading