Repository navigation
[CI] Ecosystem-wide workflow to audit and prune obsolete [compat] entries across CT* packages #986
Description
Context & Motivation
Across the control-toolbox ecosystem, CompatHelper is configured with KeepEntry(), and CI runs with force_latest_compatible_version = false (see control-toolbox/CTActions#91). This is intentional: when a new breaking version of a dependency is released, we want to widen [compat] (e.g. 0.9, 0.11, 0.12) without immediately dropping older entries that may still be needed by Julia LTS (1.10) or by another dependency higher up in the ecosystem.
Over time, however, old [compat] entries accumulate. Tightening [compat] bounds (dropping obsolete versions) cannot be decided locally inside a single package in isolation:
- removing an older version
0.9in a low-level package (CTBase,CTModels, …) could conflict with a transitive constraint in an upstream/downstream package (CTDirect,CTFlows,OptimalControl, …) and inadvertently forcePkgto downgrade another dependency; - a
[compat]entry is only truly obsolete if no supported Julia version (from LTS1.10to current1.13, including1.12) ever selects it across the fullcontrol-toolboxdependency graph.
Pruning [compat] entries should therefore be done top-down from OptimalControl.jl, looking at the whole CT* ecosystem together.
Goal
Design a reusable workflow in CTActions (e.g. .github/workflows/compat-audit.yml or compat-prune.yml), triggered from OptimalControl.jl (workflow_dispatch and/or periodic schedule), that:
- Collects the
CT*package dependency graph starting fromOptimalControl:- either from an explicit workflow input list (
CTBase,CTParser,CTModels,CTLie,CTFlows,CTDirect,CTSolvers,OptimalControl), - or automatically by extracting the
CT*dependency DAG (see the earlier prototype inct-conventions@v2026.04.11:experiments/dependency-graph/extract-graph.jl).
- either from an explicit workflow input list (
- Audits
[compat]entries across all supported Julia versions (1.10LTS,1.12,1.13). - Opens cross-linked issues / PRs in the relevant repositories to drop obsolete
[compat]entries, together with the bottom-up merge and release order along the dependency DAG.
Possible Directions (for information only / exploratory)
The points below are indicative ideas to guide a future implementation, not a fixed specification.
1. Graph discovery & workspace setup
- Check out
OptimalControland allCT*packages, andPkg.developthem together in a shared environment so that changes in lower-level packages are immediately visible to higher-level ones. - Inspect
Project.toml([deps],[weakdeps],[extras]/[targets] test) as well asdocs/Project.tomlfor each package.
2. Identifying obsolete [compat] entries across Julia versions (1.10 → 1.13)
For each supported Julia version (1.10, 1.12, 1.13):
- Resolve the joint environment (
OptimalControl+ allCT*packages indev, plus each package's test and docs environments) and record the exact version selected for every dependency. - Look at dependencies with multiple disjoint
[compat]specifiers (e.g.Foo = "0.9, 0.11, 0.12"):- If an older specifier (e.g.
0.9) is still selected on Julia 1.10 (such asJET 0.9on Julia 1.10 while0.12is selected on Julia 1.12+), it must be kept. - If an older specifier is never selected on any supported Julia version (
1.10,1.12,1.13), test removing it and verify that:- All environments still resolve cleanly on
1.10,1.12, and1.13; - No other dependency in the resolved graph gets downgraded compared to the baseline resolution.
- All environments still resolve cleanly on
- If an older specifier (e.g.
3. Reporting, linked PRs, and release orchestration
- Compute the topological order of affected
CT*packages (from leaves to root, e.g.CTBase/CTParser→CTModels/CTLie→CTFlows/CTDirect/CTSolvers→OptimalControl). - Open a summary tracking issue in
OptimalControl.jl(or PRs in each affectedCT*repo) containing:- Which
[compat]entries can be dropped and why (resolved versions per Julia version:1.10,1.12,1.13); - Cross-links between all generated issues / PRs across repositories;
- A step-by-step merge and release checklist in topological order (merge PR in
CTx→ tag/releaseCTxif needed → merge downstream PRs).
- Which
Activity
ocots commentedon Oct 15, 2026
Plan: ecosystem-wide
[compat]pruning workflowConventions: ct-conventions v0.5.0 (commit bc03c7e) ·
Probe: local resolution probe of 2026-10-15 (scripts reused in Phase A)
What and why
CompatHelperruns withKeepEntry(), so[compat]entries only ever widen and oldspecifiers pile up. A specifier is obsolete only if no environment of the ecosystem, on no
supported Julia version, ever selects it, which no single repository can decide alone. This
plan adds a reusable workflow to CTActions, called from OptimalControl, that finds those
specifiers, proves that dropping them changes nothing, and opens one PR per affected
repository plus one tracking issue in OptimalControl, both updated in place on later runs.
Scope decision (2026-10-15): pruning only. Widening stays with CompatHelper + Breakage +
human judgement. The workflow also reports blockers (the newest specifier of an entry is never
selected, and here is who caps it), as information only, with no PR.
Probe findings that shape the design
Measured on 2026-10-15, Julia 1.10.11 / 1.12.6 / 1.13.1, General + ct-registry:
consistent: OptimalControl requires CTSolvers 0.5 / CTLie 0.2 while their
mainare 0.6 /0.3, and CTParser
mainis 0.8.17-beta while 0.9.5-beta was registered from the unmergedbranch of CTParser#326. The audited set is what the joint resolution of OptimalControl
selects; files are read at the registered tree (registry
git-tree-sha1), not at a tag.[compat]entries are not in theregistry, and they differ per repository on purpose (
CUDSSis"0.7"in OptimalControl,"0.8"in CTDirect, see CTBase#538). Audited environments: the joint runtime env(OptimalControl + all CT*), plus for each repository its test env (
test/Project.tomlifpresent, else package +
[extras]+[weakdeps]with the repo's own compat) and its docsenv, i.e. 17 environments × 3 Julia versions.
PonDisused if, in some environment containing
P(or, for atest/ordocs/entry, in thatvery environment), the selected
Dfalls in that specifier.[compat]of every package in anenvironment. Dropping specifier
sfromP's entry onDis therefore equivalent tointersecting
D's allowed range with the kept specifiers in every environment containingP.The resolver's top-level
[compat]does exactly that, so the edit is simulated withouttouching any repository or registry. It must be applied per environment, never globally:
a global
CUDSS = "0.8"would be wrong (OptimalControl needs 0.7).baseline. Negative control: dropping
0.7from CTSolvers'CUDSSmakesOptimalControl/testandOptimalControl/docsunsatisfiable, and the check reports it.CTParser/testnever selects CTBase 0.30 because itstest/Project.tomlpinsCTModels = "0.18". Second example, worth a separate follow-up (outof scope): OptimalControl's
CUDSS = "0.7"was forced byLinearSolve ≤ 5.18.1(
WeakCompatcaps CUDSS at 0.7);LinearSolve 5.18.2accepts0.7 - 0.8, and theOptimalControl docs env now resolves with
CUDSS = "0.8"on all three Julia versions, at theprice of CUDA 6.2.0 → 6.4.2 (see the CUDA ≤ 6.2 ceiling on occidata).
Correction to the issue text: #986 cites CTActions#91 (
force_latest_compatible_version = false, merged 2026-10-15 11:19). CTActions#93 (merged 2026-10-15 14:23) reverted it toautoon purpose: without forcing, a CompatHelper PR only retests what already resolved, so the newly
allowed version is never tested. Forcing is skipped on Julia 1.10 only, because Pkg's forcing
ignores Julia compat (it forces JET 0.12, which needs Julia ≥ 1.11).
autois the intended,permanent setting. This workflow is the complement of that design: CompatHelper adds (
KeepEntry)and CI tests the newest (
auto); the audit removes what no supported Julia selects any more,resolving each Julia version properly, so JET 0.9 is kept for 1.10.
Scope
.github/workflows/compat-prune.yml,scripts/compat-prune/*.jl,scripts/compat-prune/test/runtests.jl+ fixtures..github/workflows/CompatPrune.yml.CTActions/docs/WORKFLOWS.md,CTActions/README.md.What changes
Architecture
Two jobs, not a matrix: resolution is cheap (minutes, no precompilation), so the three Julia
versions run one after the other in
audit. The split is about permissions: onlypublishcan write to other repositories, and it only consumes a JSON file.
Scripts (
CTActions/scripts/compat-prune/, stdlib only:Pkg,TOML)collect.jlgit-tree-sha1and fetchProject.toml,test/Project.toml,docs/Project.tomlfrom that tree (gh api …/git/trees/<sha>); also fetch the same files on the default branch, for drift detection.envs.jlresolve.jlversionsorerrorper env.classify.jlprune(old specifiers never used),keep, orblocked(newest never used; blocker name parsed from a resolve under "newest only").validate.jlreport.jlreport.json(machine) +report.md(job summary, issue body).edit.jl[compat]line (keeps order, quoting, comments); re-parses the file and asserts that only that key changed.publish.jlgh(see below).Shape of a prune record:
Idempotence (no duplicates across runs)
<!-- ct-compat-prune -->among open and closed issues. Body fully rewritten each run (date, audited versions, Julia
versions, prunes per repo with PR links, blockers, drift, rejected prunes). Closed with a
comment when nothing is left to prune (blockers alone do not keep it open); reopened, never
recreated, when a prune appears again.
ct/compat-prune, rebuilt from thedefault branch and force-pushed only when its content would change. Existing open PR → body
updated; empty diff → PR closed with a comment, branch deleted. One PR covers the three files
of a repository.
audited one. If the default branch already carries the pruned value, the prune is listed as
merged, awaiting release (the audit reads the registered tree, so a merged but unreleased
prune would otherwise come back every month). Any other difference is listed under drift
and no edit is made.
does not matter and no release is needed; the next regular release carries it. The issue says
so instead of a topological release checklist.
Inputs of the reusable workflow
dry_rundefaults totrue: the first runs only produce the job summary.Step 0 — Branch
Phases
Phase A — collect, envs, resolve (CTActions, local)
Steps:
collect.jl(joint resolution, registered trees, default-branch copies).envs.jlandresolve.jlfrom the probe scripts, minus the probe shortcuts (tagfetching, hard-coded versions).
Checkpoint (local, against real registries):
Phase B — classify, validate (CTActions, local)
Steps:
classify.jl(owner-scoped usage,prune/keep/blocked, blocker parsing).validate.jl(global check, then one-by-one fallback).test/runtests.jlwith fixtures: the CUDSS negative control must be rejected, theCTParser/test CTBase blocker must be named, owner scoping must keep
CTSolversCUDSS 0.7.Checkpoint:
julia scripts/compat-prune/test/runtests.jl # + full local run: prune count and blockers match the probe or differ only by registry movesPhase C — report and edit (CTActions, local, no network writes)
Steps:
report.jl(JSON + Markdown).edit.jl; tests on fixtures with comments, odd spacing, single quotes, keys out oforder.
git diffper repository.Checkpoint:
julia scripts/compat-prune/test/runtests.jl # review the scratch diffs by hand: only [compat] lines change⛔ Show the diffs before Phase D.
Phase D — audit job, dry run (CTActions + OptimalControl)
Steps:
.github/workflows/compat-prune.yml,auditjob only,dry_runhonoured.OptimalControl/.github/workflows/CompatPrune.ymlpointing atcompat-prune.yml@feat/compat-prune(switched to@mainbefore merge).actionlintboth files.Checkpoint:
workflow_dispatchrun onci/compat-prune; job summary equals the local report.⛔ Ask before pushing either branch.
Phase E — publish job
Steps:
publish.jland thepublishjob (actions/create-github-app-token, ownercontrol-toolbox, repositories frompackages).dry_run: false, then an immediate second run: it must change nothing(same issue, same PRs, no force-push).
⛔ Requires the token decision below. ⛔ Ask before the first live run.
Phase F — documentation (last)
Steps:
CTActions/docs/WORKFLOWS.md: new section (what it does, inputs, idempotence, how to readthe issue).
CTActions/README.md: workflow table row.compat-prune.ymlin the style ofdocumentation-preview-cleanup.yml.force_latest_compatible_versioncorrection).Human checkpoints
feat/compat-prune/ci/compat-prune.dry_run: false) run: it opens PRs in up to 8 repositories.Decisions
maintainer, set up together at Phase E). Permissions: Contents, Pull requests, Issues
(read & write), Metadata (read); installed on the 8 repositories; App id + private key as
organisation secrets.
workflow_dispatch(decided 2026-10-15).recreated) when a prune appears again. Blockers alone do not keep it open (decided
2026-10-15).
Out of scope
[compat], and any code adaptation to new versions (CompatHelper + Breakage).CTBase = "0.30.8").CUDSS 0.7 → 0.8move andits CUDA 6.4 consequence on occidata: separate follow-up.
main/ registered-branch divergence (CTParser#326).ct-conventionsdependency-graph prototype: the CT* list is an input, the DAG is notneeded since prunes do not require an order.
File summary
Added:
CTActions/.github/workflows/compat-prune.yml,CTActions/scripts/compat-prune/{collect,envs,resolve,classify,validate,report,edit,publish}.jl,CTActions/scripts/compat-prune/test/runtests.jl(+ fixtures),OptimalControl/.github/workflows/CompatPrune.ymlModified:
CTActions/docs/WORKFLOWS.md,CTActions/README.mdci: monthly [compat] prune audit (CompatPrune)