diff --git a/.github/workflows/registry-validate.yml b/.github/workflows/registry-validate.yml new file mode 100644 index 0000000..d814181 --- /dev/null +++ b/.github/workflows/registry-validate.yml @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# Canonical registry validation (RegistryCI.jl) + belt-and-braces custom +# checks (name/UUID consistency, repo slug, Compat.toml upper bounds). +# See ci/validate.jl and docs/REGISTRY-POLICY.adoc. +# +# NOTE: this is intended to be a *required* status check, so it deliberately +# has no `on.push.paths` / `on.pull_request.paths` filter -- a path-filtered +# required check that never triggers on an unrelated PR gets stuck as +# permanently "Expected" and strands the PR in a blocked mergeable_state. +# Always trigger; the job itself is cheap (single-package registry, no build). +name: Registry Validate + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Set up Julia + uses: julia-actions/setup-julia@fa02766e078afaaf09b14210362cee14137e6a32 # v3.0.2 + with: + version: '1.10' + + - name: Install RegistryCI in a throwaway depot + env: + JULIA_DEPOT_PATH: ${{ runner.temp }}/registry-validate-depot + run: | + julia -e 'using Pkg; Pkg.add("RegistryCI")' + + - name: Run registry validation (registry-local checks; RegistryCI without General deps) + env: + JULIA_DEPOT_PATH: ${{ runner.temp }}/registry-validate-depot + run: | + julia ci/validate.jl "${{ github.workspace }}" + + - name: Run full RegistryCI check against General (network, best-effort) + env: + JULIA_DEPOT_PATH: ${{ runner.temp }}/registry-validate-depot + JULIA_REGISTRY_VALIDATE_GENERAL_DEPS: '1' + run: | + julia ci/validate.jl "${{ github.workspace }}" + continue-on-error: true diff --git a/GOVERNANCE.adoc b/GOVERNANCE.adoc index 6f18664..95fc87f 100644 --- a/GOVERNANCE.adoc +++ b/GOVERNANCE.adoc @@ -34,6 +34,8 @@ Registry-specific decisions follow this hierarchy: * *New package registrations* require the BDFL's review. The package must meet the registry's quality bar (SPDX headers, REUSE-compliant `LICENSES/`, OpenSSF Scorecard baseline, no banned-language files). * *Removals or renames* require BDFL approval and an ADR. +See link:docs/REGISTRY-POLICY.adoc[docs/REGISTRY-POLICY.adoc] for the concrete, CI-enforced version of this hierarchy: semver bump rules, mandatory upper-bounded `Compat.toml`, name/UUID immutability, and exactly which changes may auto-merge. + === Proposing changes * Contributors can propose changes by opening issues or pull requests. diff --git a/ci/validate.jl b/ci/validate.jl new file mode 100644 index 0000000..7e70b80 --- /dev/null +++ b/ci/validate.jl @@ -0,0 +1,422 @@ +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +# +# julia-professional-registry — registry validation entry point. +# +# Two layers, run back to back: +# +# 1. Canonical checks via RegistryCI.jl (the same package JuliaRegistries/General +# uses for its own CI). `RegistryCI.test(path)` checks UUID uniqueness, +# Registry.toml <-> Package.toml name/UUID consistency, Versions.toml shape, +# and that every Deps.toml / Compat.toml entry round-trips through Pkg's own +# compression/decompression and only references known UUIDs. +# +# "Known UUIDs" for a non-General registry means: this registry's own +# packages + stdlibs + (optionally) the General registry, supplied via +# `registry_deps`. This registry's packages depend on real General +# packages (HTTP.jl, JSON.jl, JSON3.jl, Zygote.jl, ...), so a bare +# `RegistryCI.test(path)` with no `registry_deps` WILL report those as +# "missing" -- that is expected/documented RegistryCI behaviour for a +# non-General registry, not a bug in this registry. Passing +# `registry_deps=["https://github.com/JuliaRegistries/General"]` is the +# correct invocation, but it requires cloning General (a large registry) +# over the network, so it is gated behind `--general-deps` / the +# JULIA_REGISTRY_VALIDATE_GENERAL_DEPS=1 env var and is expected to run +# in CI, not necessarily in a network-restricted sandbox. +# +# 2. Belt-and-braces custom checks (independent of RegistryCI, using only the +# TOML stdlib) for the specific bug classes this registry has already hit +# in the wild (see PR #40 / G20 / G21): +# (a) every package's Deps.toml name<->UUID pairs agree with the +# Registry.toml binding for that name, across every package that +# depends on another registry-local package; +# (b) Registry.toml's `repo` field host/slug agrees with each +# package's own Package.toml `repo` field and with +# `git remote get-url origin` (compared as a hyperpolymath/ +# slug, because sandboxes may rewrite the remote through a local +# proxy); +# (c) warn (do not fail) on Compat.toml entries that only specify a +# lower bound with no upper bound -- the estate wants +# upper-bounded compat everywhere. +# +# Usage: +# julia --project=@. ci/validate.jl [registry_path] +# JULIA_REGISTRY_VALIDATE_GENERAL_DEPS=1 julia ci/validate.jl # also try RegistryCI's General-deps check (network) +# +# Exit code is non-zero if either layer reports a hard failure. Layer (c) +# warnings never affect the exit code. + +using TOML +using Pkg +using Test + +const REPO_ROOT = abspath(get(ARGS, 1, joinpath(@__DIR__, ".."))) +const REGISTRY_TOML = joinpath(REPO_ROOT, "Registry.toml") +const EXPECTED_SLUG_OWNER = "hyperpolymath" + +# RegistryCI must be `using`'d at top level (not loaded dynamically inside a +# function via Base.require) -- doing it inside a function hits a Julia +# world-age problem: the freshly-loaded module's methods are not visible to +# that function's call, and RegistryCI.test(path) fails with a spurious +# "MethodError: no method matching test(::String) ... method too new to be +# called from this world context", which has nothing to do with the registry +# itself. Ground-truthed while writing this script: catching that MethodError +# without printing it via showerror would silently misreport a tooling bug as +# an "expected General-deps gap" finding -- exactly the kind of overclaim this +# script must not make. +const REGISTRYCI_AVAILABLE = try + @eval using RegistryCI + true +catch + false +end + +function banner(msg) + println() + println("=" ^ 70) + println(msg) + println("=" ^ 70) +end + +# --------------------------------------------------------------------------- +# Layer 1: canonical RegistryCI.jl checks +# --------------------------------------------------------------------------- + +function run_registryci(path::AbstractString) + banner("Layer 1: RegistryCI.test (canonical registry consistency checks)") + + if !REGISTRYCI_AVAILABLE + println("RegistryCI is not installed in this depot.") + println("Install with: julia -e 'using Pkg; Pkg.add(\"RegistryCI\")'") + println("FALLING BACK: skipping Layer 1 (RegistryCI unavailable). This is a") + println("degraded run, not a pass -- see README/CI docs for how to install it.") + return :unavailable + end + + use_general_deps = get(ENV, "JULIA_REGISTRY_VALIDATE_GENERAL_DEPS", "0") == "1" + + if use_general_deps + println("JULIA_REGISTRY_VALIDATE_GENERAL_DEPS=1 set: attempting the full") + println("check with registry_deps=[\"https://github.com/JuliaRegistries/General\"].") + println("This clones General and needs real, unrestricted network egress.") + try + RegistryCI.test(path; registry_deps=["https://github.com/JuliaRegistries/General"]) + println("RegistryCI.test (with General as registry_deps): PASS") + return :pass + catch e + println("RegistryCI.test (with General as registry_deps) FAILED or ERRORED:") + showerror(stdout, e, catch_backtrace()) + println() + println("If this is a network/egress error (e.g. 403 from a proxy, DNS") + println("failure, or clone timeout), this check is CI-only in this") + println("environment -- it is expected to work where egress to") + println("github.com/JuliaRegistries/General is unrestricted (e.g. GitHub") + println("Actions), and is not a claim of local pass/fail here.") + return :network_unavailable + end + else + println("Running RegistryCI.test(path) WITHOUT registry_deps.") + println("NOTE: this registry's packages legitimately depend on real") + println("General-registered packages (HTTP.jl, JSON.jl, JSON3.jl, Zygote.jl,") + println("...). Without registry_deps, RegistryCI has no way to know those") + println("UUIDs are valid, so it WILL report them as unresolvable deps. That") + println("is expected/documented behaviour for a non-General registry -- see") + println("the RegistryCI.test docstring (\"packages that have dependencies") + println("that are registered in other registries elsewhere\") -- not a bug in") + println("this registry. Re-run with JULIA_REGISTRY_VALIDATE_GENERAL_DEPS=1 in") + println("an environment with full network egress (e.g. CI) for the complete") + println("cross-registry check.") + println() + try + RegistryCI.test(path) + println("RegistryCI.test(path) (registry-local only): PASS") + return :pass + catch e + println("RegistryCI.test(path) (registry-local only) reported failures/errors:") + showerror(stdout, e, catch_backtrace()) + println() + if e isa Test.TestSetException || (e isa ErrorException && occursin("did not pass", sprint(showerror, e))) + println("See the RegistryCI test summary printed above. Failures under names") + println("like JSON3/HTTP/Zygote/DataFrames/... that are *General*") + println("packages are the expected registry_deps gap described above.") + println("Any OTHER failure (Registry.toml <-> Package.toml mismatch,") + println("malformed Versions.toml, a Deps.toml/Compat.toml entry that") + println("does not round-trip) is a real, actionable finding.") + return :expected_general_gap + else + println("This does NOT look like an ordinary registry-consistency test") + println("failure -- it looks like a tooling/API error (e.g. a RegistryCI") + println("API mismatch for the installed version). Treating as a hard") + println("failure of Layer 1 rather than silently downgrading it.") + return :tooling_error + end + end + end +end + +# --------------------------------------------------------------------------- +# Layer 2: belt-and-braces custom checks +# --------------------------------------------------------------------------- + +mutable struct Findings + errors::Vector{String} + warnings::Vector{String} +end +Findings() = Findings(String[], String[]) + +err!(f::Findings, msg) = push!(f.errors, msg) +warn!(f::Findings, msg) = push!(f.warnings, msg) + +""" + check_name_uuid_consistency!(findings, reg, root) + +(a) For every package in Registry.toml, load its Deps.toml (if present) and +confirm that every dependency name -> UUID pair which refers to *another +package in this same registry* matches the UUID that Registry.toml itself +binds to that name. This is exactly the class of bug fixed in PR #40 (G21): +a Deps.toml entry pointing at the right name but a stale/wrong UUID. +""" +function check_name_uuid_consistency!(findings::Findings, reg, root::AbstractString) + banner("Layer 2a: Deps.toml name<->UUID cross-check against Registry.toml") + + name_to_uuid = Dict{String,String}() + for (uuid, data) in reg["packages"] + name_to_uuid[data["name"]] = uuid + end + + checked = 0 + for (uuid, data) in reg["packages"] + name = data["name"] + path = joinpath(root, data["path"]) + pkg_toml = joinpath(path, "Package.toml") + + if !isfile(pkg_toml) + err!(findings, "$name: missing Package.toml at $pkg_toml") + continue + end + pkg = TOML.parsefile(pkg_toml) + if get(pkg, "uuid", nothing) != uuid + err!(findings, "$name: Registry.toml uuid $uuid != Package.toml uuid $(get(pkg, "uuid", ""))") + end + if get(pkg, "name", nothing) != name + err!(findings, "$name: Registry.toml name $name != Package.toml name $(get(pkg, "name", ""))") + end + + deps_toml = joinpath(path, "Deps.toml") + if isfile(deps_toml) + deps = TOML.parsefile(deps_toml) + for (_version, depmap) in deps + for (depname, depuuid) in depmap + checked += 1 + if haskey(name_to_uuid, depname) + expected = name_to_uuid[depname] + if depuuid != expected + err!(findings, + "$name/Deps.toml: dependency \"$depname\" has uuid $depuuid " * + "but Registry.toml binds \"$depname\" to $expected (registry-local name/UUID mismatch)") + end + end + # else: depname is not a registry-local package (stdlib or + # General package) -- out of scope for this check, covered + # by RegistryCI's registry_deps check (Layer 1) instead. + end + end + end + end + println("Checked $checked dependency (name, uuid) pairs across $(length(reg["packages"])) packages.") + println(isempty(findings.errors) ? "No registry-local name<->UUID mismatches found." : "Mismatches found -- see errors above.") + return findings +end + +""" + check_repo_slug!(findings, reg, root) + +(b) Registry.toml's own `repo` field, and every package's Package.toml `repo` +field, must point at a `hyperpolymath/` GitHub slug. Where +`git remote get-url origin` is available, also compare against it -- but only +the `hyperpolymath/` slug portion, since sandboxes may rewrite the +remote through a local proxy (e.g. http://local_proxy@127.0.0.1:NNNN/git/...). +""" +function extract_github_slug(url::AbstractString) + # Handles https://github.com/OWNER/REPO(.git)?, git@github.com:OWNER/REPO.git, + # and proxy-rewritten forms like http://.../git/OWNER/REPO. + m = match(r"github\.com[:/]+([^/]+)/([^/\s]+?)(?:\.git)?/?$", url) + if m !== nothing + return "$(m.captures[1])/$(m.captures[2])" + end + # Fallback: proxy path form ".../git/OWNER/REPO" + m = match(r"/git/([^/]+)/([^/\s]+?)(?:\.git)?/?$", url) + if m !== nothing + return "$(m.captures[1])/$(m.captures[2])" + end + return nothing +end + +function check_repo_slug!(findings::Findings, reg, root::AbstractString) + banner("Layer 2b: Registry.toml / Package.toml repo slug consistency") + + registry_repo = get(reg, "repo", nothing) + if registry_repo === nothing + err!(findings, "Registry.toml has no top-level 'repo' field") + else + slug = extract_github_slug(registry_repo) + if slug === nothing + err!(findings, "Registry.toml repo field \"$registry_repo\" is not a recognisable GitHub slug") + elseif !startswith(slug, "$(EXPECTED_SLUG_OWNER)/") + err!(findings, "Registry.toml repo field resolves to slug \"$slug\", expected owner \"$EXPECTED_SLUG_OWNER\"") + else + println("Registry.toml repo -> slug \"$slug\" (owner matches \"$EXPECTED_SLUG_OWNER\")") + end + end + + # Compare against the actual git remote, slug-only (see docstring). + origin_url = try + strip(read(`git -C $root remote get-url origin`, String)) + catch + nothing + end + if origin_url !== nothing && !isempty(origin_url) + origin_slug = extract_github_slug(origin_url) + if origin_slug === nothing + warn!(findings, "git remote origin (\"$origin_url\") did not parse to a GitHub slug; skipping remote comparison") + elseif registry_repo !== nothing + reg_slug = extract_github_slug(registry_repo) + if reg_slug !== nothing && lowercase(reg_slug) != lowercase(origin_slug) + err!(findings, "Registry.toml slug \"$reg_slug\" != git remote origin slug \"$origin_slug\"") + else + println("git remote origin slug \"$origin_slug\" matches Registry.toml slug.") + end + end + else + println("(no git remote 'origin' resolvable here; skipping remote-vs-Registry.toml comparison)") + end + + # Per-package repo field sanity: must exist, must resolve to a hyperpolymath/* slug. + mismatches = 0 + for (_uuid, data) in reg["packages"] + name = data["name"] + pkg_toml = joinpath(root, data["path"], "Package.toml") + isfile(pkg_toml) || continue + pkg = TOML.parsefile(pkg_toml) + repo = get(pkg, "repo", nothing) + if repo === nothing + err!(findings, "$name: Package.toml has no 'repo' field") + continue + end + slug = extract_github_slug(repo) + if slug === nothing + err!(findings, "$name: Package.toml repo \"$repo\" is not a recognisable GitHub slug") + elseif !startswith(slug, "$(EXPECTED_SLUG_OWNER)/") + mismatches += 1 + err!(findings, "$name: Package.toml repo resolves to slug \"$slug\", expected owner \"$EXPECTED_SLUG_OWNER\"") + end + end + println("Checked repo field on $(length(reg["packages"])) package Package.toml files ($mismatches owner mismatches).") + return findings +end + +""" + check_compat_upper_bounds!(findings, reg, root) + +(c) Warn (never fail) on any Compat.toml entry that specifies only a lower +bound (e.g. "1", ">= 1.2", "1.2.3") with no upper bound (Julia's caret `^` +compat entries like "1.2" DO have an implicit upper bound and are fine; bare +inequality forms like ">=1" or "*" do not). +""" +function has_no_upper_bound(spec::AbstractString) + s = strip(spec) + # "*" means "any version" -- no upper bound at all. + s == "*" && return true + # Explicit >= / > with no comma-separated upper clause is unbounded above. + if occursin(r"^(>=|>)\s*[\w\.\-\+]+$", s) + return true + end + return false +end + +function check_compat_upper_bounds!(findings::Findings, reg, root::AbstractString) + banner("Layer 2c: Compat.toml upper-bound advisory (warn-only)") + + flagged = 0 + for (_uuid, data) in reg["packages"] + name = data["name"] + compat_toml = joinpath(root, data["path"], "Compat.toml") + isfile(compat_toml) || continue + compat = TOML.parsefile(compat_toml) + for (_version, entries) in compat + for (depname, spec) in entries + specs = spec isa AbstractVector ? spec : [spec] + for s in specs + if s isa AbstractString && has_no_upper_bound(s) + flagged += 1 + warn!(findings, "$name/Compat.toml: \"$depname\" = \"$s\" has no upper bound (estate policy wants upper-bounded compat)") + end + end + end + end + end + println(flagged == 0 ? + "No unbounded Compat.toml entries found." : + "$flagged unbounded Compat.toml entries flagged (warnings only, see below).") + return findings +end + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +function main() + println("julia-professional-registry validator") + println("Registry root: $REPO_ROOT") + + isfile(REGISTRY_TOML) || error("Registry.toml not found at $REGISTRY_TOML") + reg = TOML.parsefile(REGISTRY_TOML) + println("Registry.toml parses OK: $(length(reg["packages"])) packages.") + + registryci_status = run_registryci(REPO_ROOT) + + findings = Findings() + check_name_uuid_consistency!(findings, reg, REPO_ROOT) + check_repo_slug!(findings, reg, REPO_ROOT) + check_compat_upper_bounds!(findings, reg, REPO_ROOT) + + banner("Summary") + println("RegistryCI layer status: $registryci_status") + println("Custom-check errors: $(length(findings.errors))") + println("Custom-check warnings: $(length(findings.warnings))") + + if !isempty(findings.warnings) + println() + println("Warnings (non-fatal):") + for w in findings.warnings + println(" - $w") + end + end + + if !isempty(findings.errors) + println() + println("ERRORS (fatal):") + for e in findings.errors + println(" - $e") + end + println() + println("VALIDATION FAILED: $(length(findings.errors)) error(s).") + exit(1) + end + + if registryci_status == :tooling_error + println() + println("VALIDATION FAILED: Layer 1 (RegistryCI) hit an unexpected tooling/API") + println("error rather than an ordinary registry-consistency failure. See the") + println("showerror output above for details -- likely an API mismatch between") + println("this script and the installed RegistryCI version. Fix the invocation") + println("(or pin RegistryCI) rather than ignoring this.") + exit(1) + end + + println() + println("VALIDATION PASSED (custom checks). See Layer 1 notes above for RegistryCI status.") + exit(0) +end + +main() diff --git a/docs/REGISTRY-POLICY.adoc b/docs/REGISTRY-POLICY.adoc new file mode 100644 index 0000000..1131d79 --- /dev/null +++ b/docs/REGISTRY-POLICY.adoc @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Owner: Jonathan D.A. Jewell +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += Registry Policy: Semver, Compat, and Merge Rules +:toc: preamble + +This document is the concrete, enforceable companion to +link:../GOVERNANCE.adoc[GOVERNANCE.adoc]. GOVERNANCE.adoc says *who* decides; +this document says *what* is and is not acceptable in a registry change, and +which of those rules are machine-checked by +link:../.github/workflows/registry-validate.yml[`registry-validate.yml`] / +link:../ci/validate.jl[`ci/validate.jl`]. + +== Scope + +Applies to every change under a package directory (`//`) +and to `Registry.toml` itself, across all 37 packages currently registered. + +== Semver bump rules + +* A package's `Versions.toml` entries are additive-only: once a version is + published, its `git-tree-sha1` is immutable. Correcting a mistake means + publishing a new version, never editing an existing entry in place. +* *Patch* (`x.y.Z`): bug fixes, no public API change, no new required deps. +* *Minor* (`x.Y.0`): backwards-compatible additions -- new exported names, + new optional dependencies, widened (never narrowed) `Compat.toml` ranges. +* *Major* (`X.0.0`): anything that can break a downstream consumer -- + removing/renaming an exported name, changing a function signature, + narrowing a `Compat.toml` range, dropping support for a supported Julia + version, or a behavioural change to already-documented semantics. +* Registering *any* version requires the package's own `Package.toml` + `name`/`uuid` to match the binding already recorded in `Registry.toml` for + that package -- this is machine-checked (Layer 2a in `ci/validate.jl`; also + covered by `RegistryCI.test`). This is the exact bug class fixed in PR #40 + (G20/G21): a stale or copy-pasted UUID in a dependent package's `Deps.toml`. + +== Mandatory upper-bounded compat + +* Every `Compat.toml` entry MUST specify an upper bound. Julia's caret-style + entries (`"1.2"`, `"0.3.4"`) are fine as-is -- they carry an implicit upper + bound under semver rules. Bare `">=1.2"`, `">1"`, or `"*"` are NOT fine: + they permit an unbounded range of future (potentially breaking) releases + to satisfy the registry's own resolver. +* `ci/validate.jl` Layer 2c flags unbounded entries as a **warning**, not a + hard failure -- this policy is being rolled out and existing packages may + need a follow-up PR to tighten their ranges. New packages and new versions + SHOULD ship with upper-bounded compat from the start; reviewers should + request changes on a warning rather than wave it through by default. +* `julia = "..."` compat is required on every package (already enforced by + `RegistryCI.test`). + +== Name / UUID immutability + +* A package's UUID, once registered, is permanent. It is the join key every + other package's `Deps.toml` uses to reference it; changing it retroactively + breaks every dependent package silently (this is exactly what G20/G21 + fixed). +* A package's *name* is effectively permanent too: renaming requires a new + UUID (a "new package" in Pkg's model) plus a deprecation trail in the old + entry, not an in-place rename of `Registry.toml`. +* Registry.toml's own `repo` field, and every package's `Package.toml` `repo` + field, must resolve to a `hyperpolymath/` GitHub slug (`ci/validate.jl` + Layer 2b). This is a hard failure, not a warning -- a foreign owner in that + field is either a typo or a sign the package was copied from a fork without + updating provenance. + +== What may auto-merge vs what needs BDFL review + +Mirrors GOVERNANCE.adoc's "Package registration decisions" hierarchy, made +concrete for CI purposes: + +[cols="3,2,4",options="header"] +|=== +| Change | May auto-merge? | Conditions + +| New PATCH/MINOR version of an already-registered package +| Yes, once CI is green +| `registry-validate.yml` passes with 0 errors; no `Registry.toml` UUID/name + edits; no `repo` field changes; upstream tag exists matching the version. + +| New MAJOR version of an already-registered package +| No -- BDFL review +| Same CI gate, plus a human check that the breaking change is intentional + and documented (e.g. in the upstream package's own CHANGELOG). + +| Brand-new package registration +| No -- BDFL review +| Must meet the quality bar in + `.github/ISSUE_TEMPLATE/package_registration.md`: SPDX headers, REUSE + `LICENSES/`, OpenSSF Scorecard baseline, no banned-language files, upper- + bounded `Compat.toml` from day one. + +| Any edit to an existing package's UUID or name binding in `Registry.toml` +| Never +| Name/UUID immutability (above). Treat as a removal + new registration if a + genuine rename is unavoidable, with an ADR under `docs/decisions/`. + +| Removal of a package from `Registry.toml` +| No -- BDFL review + ADR +| Per GOVERNANCE.adoc "Removals or renames require BDFL approval and an ADR." + +| Any change to `Registry.toml`'s own top-level `repo`/`name`/`uuid` fields +| No -- BDFL review +| These are registry-identity fields, not package fields; changing them + affects every consumer of the whole registry. +|=== + +"CI is green" always means: `registry-validate.yml`'s registry-local checks +(RegistryCI + Layer 2a/2b) report 0 errors. The optional +`JULIA_REGISTRY_VALIDATE_GENERAL_DEPS=1` job (cross-checking deps against the +real General registry) runs `continue-on-error: true` in CI -- see +"Residuals" below -- so it informs review but does not gate merge on its own. + +== What this policy does NOT (yet) automate + +* **RegistryCI.AutoMerge** (the same bot logic JuliaRegistries/General runs + for its own pull requests) is not wired up here. That needs a GitHub API + token with write access to label/merge/comment on PRs, plus a decision + about which of the "may auto-merge" rows above should actually be + auto-merged by a bot versus merely CI-gated for a human to click merge. + Tracked as a follow-up; see the residuals note in the validation script's + companion PR/report. + +== Licence note + +This policy document does not alter licensing. It does not touch `NOTICE`, +`LICENSES/`, or any SPDX header -- those remain owner-only, manual changes per +the estate license policy. + +''' + +Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath). Licensed under +CC-BY-SA-4.0. diff --git a/docs/decisions/0001-registry-validation-and-merge-policy.adoc b/docs/decisions/0001-registry-validation-and-merge-policy.adoc new file mode 100644 index 0000000..cf6b7d5 --- /dev/null +++ b/docs/decisions/0001-registry-validation-and-merge-policy.adoc @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// Owner: Jonathan D.A. Jewell +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) += ADR 0001: Canonical registry validation + semver/merge policy +:toc: preamble + +* Status: accepted +* Date: 2026-07-01 +* Deciders: BDFL (Jonathan D.A. Jewell) + +== Context + +This repository is a hand-maintained Julia package registry (37 packages). +Two critical resolution-breaking bugs (tracked as gaps G20/G21: a bad +`Registry.toml` repo URL, and a package name/UUID mismatch reachable via +another package's `Deps.toml`) reached `main` before being caught, because +there was no automated check that a registry change is internally +consistent. `docs/decisions/` was referenced from `GOVERNANCE.adoc` § +"Architecture Decision Records (ADRs)" and § "Amendments" from the outset, +but the directory did not exist until this ADR (gap G29). + +== Decision + +1. Adopt `RegistryCI.jl` -- the same package `JuliaRegistries/General` uses + for its own CI -- as the canonical registry consistency checker, invoked + via `RegistryCI.test(path)`. Wrap it in `ci/validate.jl` alongside three + belt-and-braces custom checks (Deps.toml name/UUID cross-check against + Registry.toml; Registry.toml/Package.toml repo-slug consistency; + Compat.toml upper-bound advisory) that are independent of RegistryCI and + target the specific bug classes this registry has already hit. +2. Wire `ci/validate.jl` into `.github/workflows/registry-validate.yml`, + triggered on every push/PR with no `paths:` filter (per the estate CI + rule that a required check must always trigger, or it strands PRs as + permanently "Expected"). +3. Adopt the semver/compat/merge policy recorded in + `docs/REGISTRY-POLICY.adoc`: mandatory upper-bounded `Compat.toml`, + name/UUID immutability, and a concrete auto-merge-vs-BDFL-review table. +4. Repair the existing Idris2 registry test suite + (`tests/idris2/ValidateTest.idr`), which asserted a `README.adoc` that no + longer exists (the README was converted to Markdown in commit `e0903a9`). + That suite remains unwired to any GitHub Actions workflow -- see + "Consequences" below. + +== Alternatives considered + +* *Hand-rolled TOML validator only, no RegistryCI.* Rejected as the primary + mechanism: RegistryCI is the canonical tool the Julia ecosystem itself + trusts for this job (round-trips Deps.toml/Compat.toml through Pkg's own + compression code, checks case-insensitive path collisions, etc.) -- + reinventing it by hand would be strictly worse and a maintenance burden. + Kept as a *supplementary* layer instead, because RegistryCI alone cannot + express this registry's estate-specific rules (repo-slug ownership, + upper-bound advisory). +* *`RegistryCI.test` with `registry_deps=General` as the only mode.* + Rejected as the *default* CI mode: cloning `JuliaRegistries/General` is a + large, slow, network-dependent operation, unsuitable for gating every push. + Kept as an opt-in (`JULIA_REGISTRY_VALIDATE_GENERAL_DEPS=1`), running + `continue-on-error: true` in CI. + +== Consequences + +* CI now fails fast on the G20/G21 bug classes recurring. +* The registry-local RegistryCI run (no `registry_deps`) will always show + "failures" for any package that depends on a real `JuliaRegistries/General` + package (HTTP.jl, JSON.jl, JSON3.jl, Zygote.jl, ...) -- this is expected, + documented RegistryCI behaviour for a non-General registry, not a defect. + `ci/validate.jl` classifies and explains this explicitly rather than + hiding it, so CI logs stay honest. +* The Idris2 suite fix (`README.adoc` -> `README.md`) is a content repair + only. That suite is still not invoked by any `.github/workflows/*.yml` -- + wiring it would require adding an Idris2 toolchain bootstrap (the estate + pattern, seen in `proven-tests-and-benches`, builds Idris2 from source via + `scripts/install-idris2.sh` and runs it `workflow_dispatch`-only to avoid + spending CI minutes on a slow build). That is left as a follow-up, tracked + separately from this ADR's Julia-registry-validation scope. +* `RegistryCI.AutoMerge` (bot-driven PR automation) is explicitly out of + scope for this decision -- it needs a GitHub API token and a decision about + which policy-doc rows should be bot-executed versus human-clicked. See + `docs/REGISTRY-POLICY.adoc` § "What this policy does NOT (yet) automate". + +''' + +Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath). Licensed under +CC-BY-SA-4.0. diff --git a/tests/idris2/ValidateTest.idr b/tests/idris2/ValidateTest.idr index 87c16c3..caf0370 100644 --- a/tests/idris2/ValidateTest.idr +++ b/tests/idris2/ValidateTest.idr @@ -67,9 +67,13 @@ allSuites = , assertTrue "[packages] section" (isInfixOf "[packages]" content) ] - , test "unit: README.adoc exists" $ do - ok <- fileExists "README.adoc" - assertTrue "README.adoc" ok + , test "unit: README.md exists" $ do + -- Was "README.adoc" until commit e0903a9 (docs(readme): convert + -- README.adoc -> Markdown, so it renders on Glama/profile/community + -- health). Updated here to match; this assertion was silently failing + -- against the pre-e0903a9 filename until this fix. + ok <- fileExists "README.md" + assertTrue "README.md" ok , test "unit: A-Z package directories exist" $ do a <- dirOrFileExists "A" @@ -105,7 +109,7 @@ allSuites = assertTrue "name and path tokens present" (has_name && has_path) , test "smoke: README lists packages with Version" $ do - content <- readFileToString "README.adoc" + content <- readFileToString "README.md" let has_pkg = isInfixOf "Package" content let has_ver = isInfixOf "Version" content assertTrue "README mentions Package + Version" (has_pkg && has_ver) @@ -148,7 +152,7 @@ allSuites = assertTrue "AcceleratorGate referenced and exists" (mentions_ag && ag_dir) , test "e2e: README references at least one package from Registry" $ do - readme <- readFileToString "README.adoc" + readme <- readFileToString "README.md" registry <- readFileToString "Registry.toml" -- AcceleratorGate is a stable canonical example from the registry. let pkg_in_both = isInfixOf "AcceleratorGate" readme && isInfixOf "AcceleratorGate" registry