From f7c4b6b9b8ddb476e7c01388ab0af8592a2f83e0 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Fri, 8 May 2026 18:57:08 -0400 Subject: [PATCH 1/3] Share version helper actions --- .github/actions/register-version/action.yml | 240 ++++++++++++ .github/actions/version-helpers/action.yml | 63 +++ .../actions/version-helpers/test/runtests.jl | 142 +++++++ .../version-helpers/version_helpers.jl | 288 ++++++++++++++ .github/workflows/Registrator.yml | 369 +----------------- .github/workflows/VersionCheck.yml | 79 +--- 6 files changed, 759 insertions(+), 422 deletions(-) create mode 100644 .github/actions/register-version/action.yml create mode 100644 .github/actions/version-helpers/action.yml create mode 100644 .github/actions/version-helpers/test/runtests.jl create mode 100644 .github/actions/version-helpers/version_helpers.jl diff --git a/.github/actions/register-version/action.yml b/.github/actions/register-version/action.yml new file mode 100644 index 0000000..64fe91e --- /dev/null +++ b/.github/actions/register-version/action.yml @@ -0,0 +1,240 @@ +name: "Dispatch package registration" +description: "Dispatches a package registration through the route selected by version-helpers." +inputs: + route: + description: "Registration route: general or local." + required: true + localregistry: + description: "Local registry repository, used for the local route." + required: false + default: "" + registrator-pat: + description: "Token used for registration writes." + required: false + default: "" + target-sha: + description: "Package commit SHA to register." + required: true + package-name: + description: "Package name." + required: true + package-uuid: + description: "Package UUID." + required: true + package-version: + description: "Package version." + required: true + is-breaking: + description: "Whether the version transition is breaking." + required: true + subject: + description: "Commit subject used in breaking release notes." + required: false + default: "" + subdir: + description: "Subdirectory containing the package Project.toml." + required: false + default: "" +outputs: + comment-id: + description: "Commit comment id for the General route." + value: ${{ steps.general-comment.outputs.comment-id }} + pull-request-url: + description: "Registry PR URL for the local route." + value: ${{ steps.local-pr.outputs.pull-request-url }} + pull-request-operation: + description: "Registry PR operation for the local route." + value: ${{ steps.local-pr.outputs.pull-request-operation }} +runs: + using: "composite" + steps: + - name: "Validate route" + shell: bash + env: + ROUTE: ${{ inputs.route }} + run: | + set -euo pipefail + case "$ROUTE" in + general|local) ;; + *) + echo "Unsupported registration route: $ROUTE" >&2 + exit 1 + ;; + esac + + - name: "Compose Registrator commit comment" + if: ${{ inputs.route == 'general' }} + shell: bash + env: + IS_BREAKING: ${{ inputs.is-breaking }} + SUBJECT: ${{ inputs.subject }} + PACKAGE_VERSION: ${{ inputs.package-version }} + SUBDIR: ${{ inputs.subdir }} + run: | + { + if [ -n "$SUBDIR" ]; then + echo "@JuliaRegistrator register subdir=$SUBDIR" + else + echo "@JuliaRegistrator register" + fi + if [ "$IS_BREAKING" = "true" ]; then + echo "" + echo "Release notes:" + echo "- breaking: $SUBJECT (v$PACKAGE_VERSION)" + fi + } > registrator-comment.md + + - name: "Trigger Registrator (General)" + if: ${{ inputs.route == 'general' }} + id: general-comment + uses: peter-evans/commit-comment@v4 + with: + token: ${{ inputs.registrator-pat || github.token }} + sha: ${{ inputs.target-sha }} + body-path: registrator-comment.md + + - name: "Summary (General)" + if: ${{ inputs.route == 'general' }} + shell: bash + env: + PACKAGE_VERSION: ${{ inputs.package-version }} + TARGET_SHA: ${{ inputs.target-sha }} + COMMENT_ID: ${{ steps.general-comment.outputs.comment-id }} + IS_BREAKING: ${{ inputs.is-breaking }} + run: | + { + echo "### Registrator" + echo "- Route: General" + echo "- Version: v$PACKAGE_VERSION" + echo "- Commit: \`$TARGET_SHA\`" + echo "- Commit comment id: $COMMENT_ID" + if [ "$IS_BREAKING" = "true" ]; then + echo "- Marked as breaking" + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: "Validate local registry input" + if: ${{ inputs.route == 'local' && inputs.localregistry == '' }} + shell: bash + run: | + echo "inputs.localregistry is required when the package is not in General." >&2 + exit 1 + + - name: "Validate REGISTRATOR_PAT (local registry)" + if: ${{ inputs.route == 'local' && inputs.registrator-pat == '' }} + shell: bash + run: | + echo "secrets.REGISTRATOR_PAT is required for the local registry path (checkout + PR)." >&2 + exit 1 + + - name: "Checkout local registry" + if: ${{ inputs.route == 'local' }} + uses: actions/checkout@v6 + with: + repository: "${{ inputs.localregistry }}" + path: registry + token: "${{ inputs.registrator-pat }}" + + - name: "Detect token owner" + if: ${{ inputs.route == 'local' }} + id: token-owner + env: + GH_TOKEN: ${{ inputs.registrator-pat }} + shell: bash + run: | + json=$(gh api user 2>/dev/null || echo '{}') + login=$(echo "$json" | jq -r '.login // ""') + id=$(echo "$json" | jq -r '.id // ""') + if [ -n "$login" ]; then + name="$login" + email="${id}+${login}@users.noreply.github.com" + else + name="github-actions[bot]" + email="github-actions[bot]@users.noreply.github.com" + fi + echo "name=$name" >> "$GITHUB_OUTPUT" + echo "email=$email" >> "$GITHUB_OUTPUT" + + - name: "Compose PR metadata (local registry)" + if: ${{ inputs.route == 'local' }} + id: local-pr-metadata + env: + PKG_NAME: ${{ inputs.package-name }} + PKG_UUID: ${{ inputs.package-uuid }} + PKG_VERSION: ${{ inputs.package-version }} + IS_BREAKING: ${{ inputs.is-breaking }} + SUBJECT: ${{ inputs.subject }} + TARGET_SHA: ${{ inputs.target-sha }} + shell: julia --color=yes {0} + run: | + using TOML + + reg = TOML.parsefile("registry/Registry.toml") + pkgs = get(reg, "packages", Dict{String,Any}()) + is_new_pkg = !haskey(pkgs, ENV["PKG_UUID"]) + + kind = is_new_pkg ? "New package" : "New version" + title = "$kind: $(ENV["PKG_NAME"]) v$(ENV["PKG_VERSION"])" + + body = "Commit: $(ENV["TARGET_SHA"])" + if ENV["IS_BREAKING"] == "true" + body *= "\n\nRelease notes:\n- breaking: $(ENV["SUBJECT"]) (v$(ENV["PKG_VERSION"]))" + end + + open(ENV["GITHUB_OUTPUT"], "a") do io + println(io, "title=$title") + println(io, "body<" + committer: "${{ steps.token-owner.outputs.name }} <${{ steps.token-owner.outputs.email }}>" + + - name: "Summary (local registry)" + if: ${{ inputs.route == 'local' }} + shell: bash + env: + LOCAL_REGISTRY: ${{ inputs.localregistry }} + PACKAGE_VERSION: ${{ inputs.package-version }} + PULL_REQUEST_URL: ${{ steps.local-pr.outputs.pull-request-url }} + PULL_REQUEST_OPERATION: ${{ steps.local-pr.outputs.pull-request-operation }} + run: | + { + echo "### Registrator" + echo "- Route: Local registry (\`$LOCAL_REGISTRY\`)" + echo "- Version: v$PACKAGE_VERSION" + if [ -n "$PULL_REQUEST_URL" ]; then + echo "- PR ($PULL_REQUEST_OPERATION): $PULL_REQUEST_URL" + else + echo "- PR: none (no changes)" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/actions/version-helpers/action.yml b/.github/actions/version-helpers/action.yml new file mode 100644 index 0000000..c9ef89c --- /dev/null +++ b/.github/actions/version-helpers/action.yml @@ -0,0 +1,63 @@ +name: "Version helper metadata" +description: "Shared version-bump, registry-route, and release-history helper logic for ITensorActions workflows." +inputs: + mode: + description: "`version-check` validates a PR version bump; `registrator-meta` computes Registrator route metadata." + required: true + package-path: + description: "Path to the checked-out package repository." + required: false + default: "." + subdir: + description: "Subdirectory containing the package Project.toml." + required: false + default: "" + base-ref: + description: "Base git ref containing the comparison Project.toml for version-check mode." + required: false + default: "" + old-ref: + description: "Prior git ref containing the comparison Project.toml for registrator-meta mode." + required: false + default: "" + force: + description: "If true, bypass version-change skip guards for manual registration." + required: false + default: "false" +outputs: + route: + description: "Registration route: general, local, or none." + value: ${{ steps.compute.outputs.route }} + pkg_name: + description: "Package name from Project.toml." + value: ${{ steps.compute.outputs.pkg_name }} + uuid: + description: "Package UUID from Project.toml." + value: ${{ steps.compute.outputs.uuid }} + new_version: + description: "Package version from the checked-out Project.toml." + value: ${{ steps.compute.outputs.new_version }} + is_breaking: + description: "Whether the version transition is breaking." + value: ${{ steps.compute.outputs.is_breaking }} + subject: + description: "Subject of the package HEAD commit." + value: ${{ steps.compute.outputs.subject }} + skip_reason: + description: "Reason registration was skipped when route is none." + value: ${{ steps.compute.outputs.skip_reason }} +runs: + using: "composite" + steps: + - id: compute + shell: julia --color=yes {0} + env: + VERSION_HELPERS_MODE: ${{ inputs.mode }} + PACKAGE_PATH: ${{ inputs.package-path }} + SUBDIR: ${{ inputs.subdir }} + BASE_REF: ${{ inputs.base-ref }} + OLD_REF: ${{ inputs.old-ref }} + FORCE: ${{ inputs.force }} + run: | + include(joinpath(ENV["GITHUB_ACTION_PATH"], "version_helpers.jl")) + main() diff --git a/.github/actions/version-helpers/test/runtests.jl b/.github/actions/version-helpers/test/runtests.jl new file mode 100644 index 0000000..a94b050 --- /dev/null +++ b/.github/actions/version-helpers/test/runtests.jl @@ -0,0 +1,142 @@ +using Test + +const ACTION_DIR = normpath(joinpath(@__DIR__, "..")) +include(joinpath(ACTION_DIR, "version_helpers.jl")) + +@testset "valid_bump" begin + @test valid_bump(v"0.1.0", v"0.1.1") == (true, "expected patch bump by 1") + @test valid_bump(v"0.1.0", v"0.2.0-DEV") == + (true, "expected minor bump by 1 with patch reset to 0") + @test valid_bump(v"1.2.3", v"2.0.0") == + (true, "expected major bump by 1 with minor/patch reset to 0") + @test valid_bump(v"0.2.0-DEV", v"0.2.0") == (true, "stripping pre-release suffix") + @test valid_bump(v"0.1.0", v"0.3.0-DEV") == + (false, "expected minor bump by 1 with patch reset to 0") +end + +@testset "register_route" begin + mktempdir() do dir + registry_toml = joinpath(dir, "Registry.toml") + write( + registry_toml, + """ + [packages] + "11111111-1111-1111-1111-111111111111" = { name = "InGeneral", path = "I/InGeneral" } + """ + ) + + @test register_route( + "11111111-1111-1111-1111-111111111111"; general_registry_toml = registry_toml + ) == "general" + @test register_route( + "22222222-2222-2222-2222-222222222222"; general_registry_toml = registry_toml + ) == "local" + end +end + +@testset "find_last_released_version" begin + mktempdir() do repo + project_path = "Project.toml" + run(`git -C $repo init --quiet`) + run(`git -C $repo config user.email test@example.com`) + run(`git -C $repo config user.name "Test User"`) + + write( + joinpath(repo, project_path), + """ + name = "Example" + uuid = "33333333-3333-3333-3333-333333333333" + version = "0.1.0" + """ + ) + run(`git -C $repo add $project_path`) + run(`git -C $repo commit --quiet -m "release 0.1.0"`) + + write( + joinpath(repo, project_path), + """ + name = "Example" + uuid = "33333333-3333-3333-3333-333333333333" + version = "0.2.0-DEV" + """ + ) + run(`git -C $repo commit --quiet -am "start 0.2.0 development"`) + + write( + joinpath(repo, project_path), + """ + name = "Example" + uuid = "33333333-3333-3333-3333-333333333333" + version = "0.2.0" + """ + ) + run(`git -C $repo commit --quiet -am "release 0.2.0"`) + + @test find_last_released_version(repo, project_path; before_ref = "HEAD") == + v"0.1.0" + end +end + +@testset "registrator_metadata strip-suffix release" begin + mktempdir() do dir + repo = joinpath(dir, "package") + mkdir(repo) + registry_toml = joinpath(dir, "Registry.toml") + write(registry_toml, "[packages]\n") + + project_path = "Project.toml" + run(`git -C $repo init --quiet`) + run(`git -C $repo config user.email test@example.com`) + run(`git -C $repo config user.name "Test User"`) + + write( + joinpath(repo, project_path), + """ + name = "Example" + uuid = "33333333-3333-3333-3333-333333333333" + version = "0.1.0" + """ + ) + run(`git -C $repo add $project_path`) + run(`git -C $repo commit --quiet -m "release 0.1.0"`) + + write( + joinpath(repo, project_path), + """ + name = "Example" + uuid = "33333333-3333-3333-3333-333333333333" + version = "0.2.0-DEV" + """ + ) + run(`git -C $repo commit --quiet -am "start 0.2.0 development"`) + old_ref = readchomp(`git -C $repo rev-parse HEAD`) + + write( + joinpath(repo, project_path), + """ + name = "Example" + uuid = "33333333-3333-3333-3333-333333333333" + version = "0.2.0" + """ + ) + run(`git -C $repo commit --quiet -am "release 0.2.0"`) + + old_registry = get(ENV, "GENERAL_REGISTRY_TOML", nothing) + ENV["GENERAL_REGISTRY_TOML"] = registry_toml + try + metadata = registrator_metadata(; + package_path = repo, subdir = "", old_ref = old_ref, force = false + ) + @test metadata["route"] == "local" + @test metadata["new_version"] == "0.2.0" + @test metadata["is_breaking"] == "true" + @test metadata["skip_reason"] == "" + finally + if old_registry === nothing + delete!(ENV, "GENERAL_REGISTRY_TOML") + else + ENV["GENERAL_REGISTRY_TOML"] = old_registry + end + end + end +end diff --git a/.github/actions/version-helpers/version_helpers.jl b/.github/actions/version-helpers/version_helpers.jl new file mode 100644 index 0000000..f61c955 --- /dev/null +++ b/.github/actions/version-helpers/version_helpers.jl @@ -0,0 +1,288 @@ +import Pkg +using TOML + +function package_project_path(subdir::AbstractString) + return isempty(subdir) ? "Project.toml" : joinpath(subdir, "Project.toml") +end + +function parse_version(s::AbstractString, label::AbstractString) + try + return VersionNumber(s) + catch err + error("Invalid $label version '$s' in Project.toml: $err") + end +end + +function valid_bump(o::VersionNumber, n::VersionNumber) + if n.major == o.major && + n.minor == o.minor && + n.patch == o.patch && + !isempty(o.prerelease) && + isempty(n.prerelease) + return true, "stripping pre-release suffix" + end + if n.major == o.major && n.minor == o.minor + return (n.patch == o.patch + 1), "expected patch bump by 1" + elseif n.major == o.major + return (n.minor == o.minor + 1 && n.patch == 0), + "expected minor bump by 1 with patch reset to 0" + else + return (n.major == o.major + 1 && n.minor == 0 && n.patch == 0), + "expected major bump by 1 with minor/patch reset to 0" + end +end + +function register_route( + uuid::AbstractString; + general_registry_toml::AbstractString = get(ENV, "GENERAL_REGISTRY_TOML", "") + ) + if isempty(general_registry_toml) + ENV["JULIA_PKG_SERVER"] = "" + Pkg.Registry.add("General") + general_registry_toml = + joinpath(first(DEPOT_PATH), "registries", "General", "Registry.toml") + end + general = TOML.parsefile(general_registry_toml) + return haskey(get(general, "packages", Dict{String, Any}()), uuid) ? "general" : "local" +end + +function find_last_released_version( + repo_path::AbstractString, project_path::AbstractString; + before_ref::AbstractString = "HEAD" + ) + history_ref = isempty(before_ref) ? "HEAD" : string(before_ref, "^") + log_output = try + readlines(`git -C $repo_path log --pretty=%H $history_ref -- $project_path`) + catch + return nothing + end + for sha in log_output + try + toml_text = read(`git -C $repo_path show $sha:$project_path`, String) + v_str = get(TOML.parse(toml_text), "version", "") + isempty(v_str) && continue + v = VersionNumber(v_str) + isempty(v.prerelease) && return v + catch + continue + end + end + return nothing +end + +function output_value!(outputs::Dict{String, String}, key::AbstractString, value) + outputs[String(key)] = string(value) + return outputs +end + +function write_outputs(outputs::Dict{String, String}) + output_file = get(ENV, "GITHUB_OUTPUT", "") + isempty(output_file) && return nothing + open(output_file, "a") do io + for (key, value) in sort(collect(outputs)) + println(io, "$key=$value") + end + end + return nothing +end + +function check_version_bump(; + package_path::AbstractString, + subdir::AbstractString, + base_ref::AbstractString + ) + isempty(base_ref) && error("base-ref input is required in version-check mode") + + project_path = package_project_path(subdir) + project_file = joinpath(package_path, project_path) + + base_project_text = try + read(`git -C $package_path show $base_ref:$project_path`, String) + catch err + println("Could not read $project_path on $base_ref ($err); skipping check.") + return Dict( + "base_ref" => base_ref, + "project_path" => project_path, + "skip_reason" => "could not read $project_path on $base_ref" + ) + end + base_project = TOML.parse(base_project_text) + current_project = TOML.parsefile(project_file) + + if !haskey(base_project, "version") + println("Base branch $project_path has no version field; skipping check.") + return Dict( + "base_ref" => base_ref, + "project_path" => project_path, + "skip_reason" => "base branch $project_path has no version field" + ) + end + if !haskey(current_project, "version") + error( + "$project_path on this branch has no version field, but the base branch ($base_ref) does. " * + "Restore the version field and bump it." + ) + end + + base_version = VersionNumber(base_project["version"]) + current_version = VersionNumber(current_project["version"]) + + outputs = Dict( + "base_ref" => base_ref, + "base_version" => string(base_version), + "project_path" => project_path, + "new_version" => string(current_version) + ) + + if !isempty(current_version.prerelease) && + !isempty(base_version.prerelease) && + current_version == base_version + println( + "OK: $project_path version $current_version unchanged from base " * + "(both carry pre-release suffix; accumulating breaking changes)." + ) + elseif current_version > base_version + ok, why = valid_bump(base_version, current_version) + ok || error( + "Invalid version bump in $project_path: $base_version ($base_ref) -> " * + "$current_version (PR head): $why." + ) + println( + "OK: $project_path version bumped from $base_version ($base_ref) to " * + "$current_version (PR head)." + ) + output_value!(outputs, "bump_reason", why) + else + error( + "$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_path." + ) + end + + return outputs +end + +function registrator_metadata(; + package_path::AbstractString, subdir::AbstractString, old_ref::AbstractString, + force::Bool + ) + project_path = package_project_path(subdir) + new = TOML.parsefile(joinpath(package_path, project_path)) + name = get(new, "name", "") + uuid = get(new, "uuid", "") + newv_str = get(new, "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_path log -1 --pretty=%s HEAD`), + ['\n', '\r'] => ' ' + ) + + if isempty(old_ref) || old_ref == "0000000000000000000000000000000000000000" + old_ref = try + readchomp(`git -C $package_path rev-parse HEAD^`) + catch + "" + end + end + + oldv_str = "" + if !isempty(old_ref) + try + old_toml = read(`git -C $package_path show $old_ref:$project_path`, String) + oldv_str = get(TOML.parse(old_toml), "version", "") + catch err + println( + stderr, + "Warning: could not read $project_path at ref '$old_ref' " * + "(treating as no prior version): $err" + ) + oldv_str = "" + end + end + + oldv = isempty(oldv_str) ? nothing : parse_version(oldv_str, "old") + route = "none" + is_breaking = false + skip_reason = "" + + if !isempty(newv.prerelease) && !force + if oldv !== nothing && newv > oldv + ok, why = valid_bump(oldv, newv) + ok || error("Invalid version bump: $oldv_str -> $newv_str ($why)") + end + skip_reason = "pre-release version $newv_str; not registering during accumulation" + elseif oldv !== nothing + if newv == oldv && !force + skip_reason = "Project.toml version unchanged ($newv_str)" + elseif newv < oldv && !force + skip_reason = "Project.toml version decreased ($oldv_str -> $newv_str); skipping registration" + else + if newv > oldv + ok, why = valid_bump(oldv, newv) + force || ok || error("Invalid version bump: $oldv_str -> $newv_str ($why)") + + oldv_compare = if !isempty(oldv.prerelease) && isempty(newv.prerelease) + something( + find_last_released_version( + package_path, + project_path; + before_ref = "HEAD" + ), + oldv + ) + else + oldv + end + is_breaking = + (newv.major > oldv_compare.major) || + ( + oldv_compare.major == 0 && newv.major == 0 && + newv.minor > oldv_compare.minor + ) + end + + route = register_route(uuid) + end + else + route = register_route(uuid) + end + + return Dict( + "route" => route, + "pkg_name" => name, + "uuid" => uuid, + "new_version" => newv_str, + "is_breaking" => string(is_breaking), + "subject" => subject, + "skip_reason" => skip_reason + ) +end + +function main() + mode = get(ENV, "VERSION_HELPERS_MODE", "") + package_path = get(ENV, "PACKAGE_PATH", ".") + subdir = get(ENV, "SUBDIR", "") + outputs = if mode == "version-check" + check_version_bump(; + package_path = package_path, subdir = subdir, + base_ref = get(ENV, "BASE_REF", "") + ) + elseif mode == "registrator-meta" + registrator_metadata(; + package_path = package_path, + subdir = subdir, + old_ref = get(ENV, "OLD_REF", ""), + force = lowercase(get(ENV, "FORCE", "false")) == "true" + ) + else + error("Unknown version-helpers mode '$mode'") + end + write_outputs(outputs) + return outputs +end diff --git a/.github/workflows/Registrator.yml b/.github/workflows/Registrator.yml index 682c92c..417b4b1 100644 --- a/.github/workflows/Registrator.yml +++ b/.github/workflows/Registrator.yml @@ -86,179 +86,13 @@ jobs: - name: "Decide route (General vs local) + validate version bump" id: meta - env: - OLD_REF: ${{ github.event.before }} - SUBDIR: ${{ inputs.subdir }} - shell: julia --color=yes {0} - run: | - using TOML - import Pkg - - function parse_version(s::AbstractString, label::AbstractString) - try - return VersionNumber(s) - catch err - error("Invalid $label version '$s' in Project.toml: $err") - end - end - - # Keep `valid_bump` in sync with the copy in VersionCheck.yml — the two - # workflows share the same bump-shape rules but cannot share Julia code - # across reusable workflow YAML without a heavier composite-action setup. - function valid_bump(o::VersionNumber, n::VersionNumber) - # Strip-suffix release transition: same M.m.p, old had pre-release, new doesn't. - if n.major == o.major && n.minor == o.minor && n.patch == o.patch && - !isempty(o.prerelease) && isempty(n.prerelease) - return true, "stripping pre-release suffix" - end - # Only called when n > o - if n.major == o.major && n.minor == o.minor - return (n.patch == o.patch + 1), "expected patch bump by 1" - elseif n.major == o.major - return (n.minor == o.minor + 1 && n.patch == 0), "expected minor bump by 1 with patch reset to 0" - else - return (n.major == o.major + 1 && n.minor == 0 && n.patch == 0), "expected major bump by 1 with minor/patch reset to 0" - end - end - - # Resolve the registration route ("general" if the package's UUID is - # already in the General registry, "local" otherwise). - function register_route(uuid) - ENV["JULIA_PKG_SERVER"] = "" - Pkg.Registry.add("General") - general_toml = joinpath(first(DEPOT_PATH), "registries", "General", "Registry.toml") - general = TOML.parsefile(general_toml) - return haskey(get(general, "packages", Dict{String,Any}()), uuid) ? "general" : "local" - end - - # Walk the git history of `project_path` on the current ref and return - # the most recent VersionNumber whose `prerelease` is empty (i.e., the - # most recent actually-released version), or `nothing` if there is none. - function find_last_released_version(repo_path, project_path) - log_output = try - readlines(`git -C $repo_path log --pretty=%H HEAD -- $project_path`) - catch - return nothing - end - for sha in log_output - try - toml_text = read(`git -C $repo_path show $sha:$project_path`, String) - v_str = get(TOML.parse(toml_text), "version", "") - isempty(v_str) && continue - v = VersionNumber(v_str) - if isempty(v.prerelease) - return v - end - catch - continue - end - end - return nothing - end - - let - 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_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'] => ' ') - - # Determine previous ref - old_ref = get(ENV, "OLD_REF", "") - if isempty(old_ref) || old_ref == "0000000000000000000000000000000000000000" - try - old_ref = readchomp(`git -C package rev-parse HEAD^`) - catch - old_ref = "" - end - end - - oldv_str = "" - if !isempty(old_ref) - try - 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_path at ref '$old_ref' (treating as no prior version): $err") - oldv_str = "" - end - end - - # For issue_comment events, always attempt registration (skip version-change guards). - is_force = get(ENV, "GITHUB_EVENT_NAME", "") == "issue_comment" - - # Parse old version once up front; used by both the pre-release skip - # branch (for shape validation) and the normal registration branch. - oldv = isempty(oldv_str) ? nothing : parse_version(oldv_str, "old") - - route = "none" - is_breaking = false - skip_reason = "" - - # Skip registration entirely while the version carries a pre-release - # suffix (e.g., "0.22.0-DEV"): the package is accumulating breaking - # changes toward an eventual release. The release PR strips the - # suffix and is registered normally. is_force (manual /register) - # bypasses this and registers the pre-release version. Even on the - # skip path, validate the bump shape when entering pre-release mode - # so a malformed transition (e.g., 0.21.5 -> 0.99.0-DEV) is caught. - if !isempty(newv.prerelease) && !is_force - if oldv !== nothing && newv > oldv - ok, why = valid_bump(oldv, newv) - ok || error("Invalid version bump: $oldv_str -> $newv_str ($why)") - end - skip_reason = "pre-release version $newv_str; not registering during accumulation" - elseif oldv !== nothing - if newv == oldv && !is_force - skip_reason = "Project.toml version unchanged ($newv_str)" - elseif newv < oldv && !is_force - skip_reason = "Project.toml version decreased ($oldv_str -> $newv_str); skipping registration" - else - if newv > oldv - ok, why = valid_bump(oldv, newv) - is_force || ok || error("Invalid version bump: $oldv_str -> $newv_str ($why)") - - # When transitioning out of pre-release (strip-suffix release PR), - # compare against the last actually-released version on this - # branch's history rather than the previous (pre-release) commit - # which would mis-classify the release as non-breaking. - oldv_compare = if !isempty(oldv.prerelease) && isempty(newv.prerelease) - something(find_last_released_version("package", project_path), oldv) - else - oldv - end - # Breaking if: major bump OR (0.x) minor bump - is_breaking = (newv.major > oldv_compare.major) || - (oldv_compare.major == 0 && newv.major == 0 && newv.minor > oldv_compare.minor) - end - - route = register_route(uuid) - end - else - # No prior version found to compare against; proceed to route decision. - route = register_route(uuid) - end - - open(ENV["GITHUB_OUTPUT"], "a") do io - println(io, "route=$route") - println(io, "pkg_name=$name") - println(io, "uuid=$uuid") - println(io, "new_version=$newv_str") - println(io, "is_breaking=$is_breaking") - println(io, "subject=$subject") - println(io, "skip_reason=$skip_reason") - end - end + uses: ITensor/ITensorActions/.github/actions/version-helpers@mf/share-version-helpers + with: + mode: registrator-meta + package-path: package + subdir: ${{ inputs.subdir }} + old-ref: ${{ github.event.before }} + force: ${{ github.event_name == 'issue_comment' }} - name: "Summary (skipped)" if: steps.meta.outputs.route == 'none' @@ -268,182 +102,21 @@ jobs: echo "- Skipped: ${{ steps.meta.outputs.skip_reason }}" } >> "$GITHUB_STEP_SUMMARY" - # ---- General registry path ---- - - - name: "Compose Registrator commit comment" - if: steps.meta.outputs.route == 'general' - shell: bash - env: - IS_BREAKING: ${{ steps.meta.outputs.is_breaking }} - SUBJECT: ${{ steps.meta.outputs.subject }} - NEW_VERSION: ${{ steps.meta.outputs.new_version }} - SUBDIR: ${{ inputs.subdir }} - run: | - { - if [ -n "$SUBDIR" ]; then - echo "@JuliaRegistrator register subdir=$SUBDIR" - else - echo "@JuliaRegistrator register" - fi - if [ "$IS_BREAKING" = "true" ]; then - echo "" - echo "Release notes:" - echo "- breaking: $SUBJECT (v$NEW_VERSION)" - fi - } > registrator-comment.md - - - name: "Trigger Registrator (General)" - if: steps.meta.outputs.route == 'general' - id: cc - uses: peter-evans/commit-comment@v4 + - name: "Dispatch registration" + if: steps.meta.outputs.route != 'none' + id: dispatch + uses: ITensor/ITensorActions/.github/actions/register-version@mf/share-version-helpers with: - token: ${{ secrets.REGISTRATOR_PAT || github.token }} - sha: ${{ env.TARGET_SHA }} - body-path: registrator-comment.md - - - name: "Summary (General)" - if: steps.meta.outputs.route == 'general' - run: | - { - echo "### Registrator" - echo "- Route: General" - echo "- Version: v${{ steps.meta.outputs.new_version }}" - echo "- Commit: \`${{ env.TARGET_SHA }}\`" - echo "- Commit comment id: ${{ steps.cc.outputs.comment-id }}" - if [ "${{ steps.meta.outputs.is_breaking }}" = "true" ]; then - echo "- Marked as breaking" - fi - } >> "$GITHUB_STEP_SUMMARY" - - # ---- Local registry path ---- - - - name: "Validate local registry input" - if: steps.meta.outputs.route == 'local' && inputs.localregistry == '' - run: | - echo "inputs.localregistry is required when the package is not in General." >&2 - exit 1 - - - name: "Validate REGISTRATOR_PAT (local registry)" - if: steps.meta.outputs.route == 'local' - env: - REGISTRATOR_PAT: ${{ secrets.REGISTRATOR_PAT }} - shell: bash - run: | - if [ -z "$REGISTRATOR_PAT" ]; then - echo "secrets.REGISTRATOR_PAT is required for the local registry path (checkout + PR)." >&2 - exit 1 - fi - - - name: "Checkout local registry" - if: steps.meta.outputs.route == 'local' - uses: actions/checkout@v6 - with: - repository: "${{ inputs.localregistry }}" - path: registry - token: "${{ secrets.REGISTRATOR_PAT }}" - - # Resolve the PAT owner so the registry-PR commit author and committer - # both match the authenticating identity. Without this, peter-evans - # below defaults the author to the workflow-trigger commit author and - # the committer to github-actions[bot]. - - name: "Detect token owner" - if: steps.meta.outputs.route == 'local' - id: token-owner - env: - GH_TOKEN: ${{ secrets.REGISTRATOR_PAT }} - shell: bash - run: | - json=$(gh api user 2>/dev/null || echo '{}') - login=$(echo "$json" | jq -r '.login // ""') - id=$(echo "$json" | jq -r '.id // ""') - if [ -n "$login" ]; then - name="$login" - email="${id}+${login}@users.noreply.github.com" - else - name="github-actions[bot]" - email="github-actions[bot]@users.noreply.github.com" - fi - echo "name=$name" >> "$GITHUB_OUTPUT" - echo "email=$email" >> "$GITHUB_OUTPUT" - - - name: "Compose PR metadata (local registry)" - if: steps.meta.outputs.route == 'local' - id: local_pr - env: - PKG_NAME: ${{ steps.meta.outputs.pkg_name }} - PKG_UUID: ${{ steps.meta.outputs.uuid }} - PKG_VERSION: ${{ steps.meta.outputs.new_version }} - IS_BREAKING: ${{ steps.meta.outputs.is_breaking }} - SUBJECT: ${{ steps.meta.outputs.subject }} - shell: julia --color=yes {0} - run: | - using TOML - - reg = TOML.parsefile("registry/Registry.toml") - pkgs = get(reg, "packages", Dict{String,Any}()) - is_new_pkg = !haskey(pkgs, ENV["PKG_UUID"]) - - kind = is_new_pkg ? "New package" : "New version" - title = "$kind: $(ENV["PKG_NAME"]) v$(ENV["PKG_VERSION"])" - - body = "Commit: $(ENV["TARGET_SHA"])" - if ENV["IS_BREAKING"] == "true" - body *= "\n\nRelease notes:\n- breaking: $(ENV["SUBJECT"]) (v$(ENV["PKG_VERSION"]))" - end - - open(ENV["GITHUB_OUTPUT"], "a") do io - println(io, "title=$title") - println(io, "body<" - committer: "${{ steps.token-owner.outputs.name }} <${{ steps.token-owner.outputs.email }}>" - - - name: "Summary (local registry)" - if: steps.meta.outputs.route == 'local' - run: | - { - echo "### Registrator" - echo "- Route: Local registry (\`${{ inputs.localregistry }}\`)" - echo "- Version: v${{ steps.meta.outputs.new_version }}" - if [ -n "${{ steps.cpr.outputs.pull-request-url }}" ]; then - echo "- PR (${{ - steps.cpr.outputs.pull-request-operation - }}): ${{ steps.cpr.outputs.pull-request-url }}" - else - echo "- PR: none (no changes)" - fi - } >> "$GITHUB_STEP_SUMMARY" + route: ${{ steps.meta.outputs.route }} + localregistry: ${{ inputs.localregistry }} + registrator-pat: ${{ secrets.REGISTRATOR_PAT }} + target-sha: ${{ env.TARGET_SHA }} + package-name: ${{ steps.meta.outputs.pkg_name }} + package-uuid: ${{ steps.meta.outputs.uuid }} + package-version: ${{ steps.meta.outputs.new_version }} + is-breaking: ${{ steps.meta.outputs.is_breaking }} + subject: ${{ steps.meta.outputs.subject }} + subdir: ${{ inputs.subdir }} - name: "React to comment" if: github.event_name == 'issue_comment' diff --git a/.github/workflows/VersionCheck.yml b/.github/workflows/VersionCheck.yml index 7d06bd4..3a820da 100644 --- a/.github/workflows/VersionCheck.yml +++ b/.github/workflows/VersionCheck.yml @@ -70,77 +70,8 @@ jobs: - name: "Check version was bumped" if: steps.classify.outputs.triggers == 'true' - shell: julia --color=yes {0} - env: - SUBDIR: ${{ inputs.subdir }} - run: | - using TOML - - # Keep `valid_bump` in sync with the copy in Registrator.yml — the two - # workflows share the same bump-shape rules but cannot share Julia code - # across reusable workflow YAML without a heavier composite-action setup. - function valid_bump(o::VersionNumber, n::VersionNumber) - # Strip-suffix release transition: same M.m.p, old had pre-release, new doesn't. - if n.major == o.major && n.minor == o.minor && n.patch == o.patch && - !isempty(o.prerelease) && isempty(n.prerelease) - return true, "stripping pre-release suffix" - end - # Only called when n > o - if n.major == o.major && n.minor == o.minor - return (n.patch == o.patch + 1), "expected patch bump by 1" - elseif n.major == o.major - return (n.minor == o.minor + 1 && n.patch == 0), "expected minor bump by 1 with patch reset to 0" - else - return (n.major == o.major + 1 && n.minor == 0 && n.patch == 0), "expected major bump by 1 with minor/patch reset to 0" - end - end - - 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_path"`, String) - catch err - 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_path, String)) - - if !haskey(base_project, "version") - println("Base branch $project_path has no version field; skipping check.") - exit(0) - end - if !haskey(current_project, "version") - error( - "$project_path on this branch has no version field, but the base " * - "branch ($base_ref) does. Restore the version field and bump it." - ) - end - - base_version = VersionNumber(base_project["version"]) - current_version = VersionNumber(current_project["version"]) - - if !isempty(current_version.prerelease) && !isempty(base_version.prerelease) && current_version == base_version - println( - "OK: $project_path version $current_version unchanged from base " * - "(both carry pre-release suffix; accumulating breaking changes)." - ) - elseif current_version > base_version - ok, why = valid_bump(base_version, current_version) - ok || error( - "Invalid version bump in $project_path: $base_version (origin/$base_ref) " * - "-> $current_version (PR head): $why." - ) - println( - "OK: $project_path version bumped from $base_version " * - "(origin/$base_ref) to $current_version (PR head)." - ) - else - error( - "$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_path." - ) - end + uses: ITensor/ITensorActions/.github/actions/version-helpers@mf/share-version-helpers + with: + mode: version-check + subdir: ${{ inputs.subdir }} + base-ref: origin/${{ github.base_ref }} From 840f7d1f0d4971cd4aca1c4c5f9baa2a1416678d Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Fri, 8 May 2026 19:37:38 -0400 Subject: [PATCH 2/3] Run compat bounds on draft PRs by default --- .github/workflows/CheckCompatBounds.yml | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CheckCompatBounds.yml b/.github/workflows/CheckCompatBounds.yml index 02b9b48..fc8125b 100644 --- a/.github/workflows/CheckCompatBounds.yml +++ b/.github/workflows/CheckCompatBounds.yml @@ -40,7 +40,7 @@ on: type: number run-on-draft: description: "When true, run the check even on draft PRs." - default: false + default: true required: false type: boolean mode: diff --git a/README.md b/README.md index b5c12d1..89f62f7 100644 --- a/README.md +++ b/README.md @@ -704,7 +704,7 @@ jobs: | `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`). | | `timeout-minutes` | number | `30` | Maximum job runtime. | -| `run-on-draft` | bool | `false` | Run the check on draft PRs. | +| `run-on-draft` | bool | `true` | Run the check on draft PRs. Set to `false` to skip this workflow while a PR is draft. | | `mode` | string | `"always"` | `"always"` runs on every invocation. `"never"` skips the check. `"auto"` runs only when `$GITHUB_ACTOR` is a known dependency-update bot (`github-actions[bot]`, `dependabot[bot]`). | | `workspace-root` | string | `"."` | Path to the workspace root (containing `Project.toml` and `Manifest.toml`). | From a7121f07ebedf083bf84628d2bb0f20491aa4b5b Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Fri, 8 May 2026 19:55:42 -0400 Subject: [PATCH 3/3] Restore release-ready internal action refs --- .github/workflows/Registrator.yml | 4 ++-- .github/workflows/VersionCheck.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/Registrator.yml b/.github/workflows/Registrator.yml index 417b4b1..ebb99b7 100644 --- a/.github/workflows/Registrator.yml +++ b/.github/workflows/Registrator.yml @@ -86,7 +86,7 @@ jobs: - name: "Decide route (General vs local) + validate version bump" id: meta - uses: ITensor/ITensorActions/.github/actions/version-helpers@mf/share-version-helpers + uses: ITensor/ITensorActions/.github/actions/version-helpers@main with: mode: registrator-meta package-path: package @@ -105,7 +105,7 @@ jobs: - name: "Dispatch registration" if: steps.meta.outputs.route != 'none' id: dispatch - uses: ITensor/ITensorActions/.github/actions/register-version@mf/share-version-helpers + uses: ITensor/ITensorActions/.github/actions/register-version@main with: route: ${{ steps.meta.outputs.route }} localregistry: ${{ inputs.localregistry }} diff --git a/.github/workflows/VersionCheck.yml b/.github/workflows/VersionCheck.yml index 3a820da..3b8a24a 100644 --- a/.github/workflows/VersionCheck.yml +++ b/.github/workflows/VersionCheck.yml @@ -70,7 +70,7 @@ jobs: - name: "Check version was bumped" if: steps.classify.outputs.triggers == 'true' - uses: ITensor/ITensorActions/.github/actions/version-helpers@mf/share-version-helpers + uses: ITensor/ITensorActions/.github/actions/version-helpers@main with: mode: version-check subdir: ${{ inputs.subdir }}