Skip to content

[CI] Ecosystem-wide workflow to audit and prune obsolete [compat] entries across CT* packages #986

Closed

Description

@ocots

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.9 in a low-level package (CTBase, CTModels, …) could conflict with a transitive constraint in an upstream/downstream package (CTDirect, CTFlows, OptimalControl, …) and inadvertently force Pkg to downgrade another dependency;
  • a [compat] entry is only truly obsolete if no supported Julia version (from LTS 1.10 to current 1.13, including 1.12) ever selects it across the full control-toolbox dependency 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:

  1. Collects the CT* package dependency graph starting from OptimalControl:
  2. Audits [compat] entries across all supported Julia versions (1.10 LTS, 1.12, 1.13).
  3. 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 OptimalControl and all CT* packages, and Pkg.develop them 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 as docs/Project.toml for 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 + all CT* packages in dev, 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 as JET 0.9 on Julia 1.10 while 0.12 is 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:
      1. All environments still resolve cleanly on 1.10, 1.12, and 1.13;
      2. No other dependency in the resolved graph gets downgraded compared to the baseline resolution.

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 affected CT* 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/release CTx if needed → merge downstream PRs).

Activity

  1. ocots commented on Oct 15, 2026

    @ocots
    MemberAuthor

    Plan: ecosystem-wide [compat] pruning workflow

    Conventions: ct-conventions v0.5.0 (commit bc03c7e) ·
    Probe: local resolution probe of 2026-10-15 (scripts reused in Phase A)

    What and why

    CompatHelper runs with KeepEntry(), so [compat] entries only ever widen and old
    specifiers 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:

    1. Audit registered versions, not default branches. Default branches are not mutually
      consistent: OptimalControl requires CTSolvers 0.5 / CTLie 0.2 while their main are 0.6 /
      0.3, and CTParser main is 0.8.17-beta while 0.9.5-beta was registered from the unmerged
      branch 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.
    2. One joint environment is not enough. Test and docs [compat] entries are not in the
      registry, and they differ per repository on purpose (CUDSS is "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.toml if
      present, else package + [extras] + [weakdeps] with the repo's own compat) and its docs
      env, i.e. 17 environments × 3 Julia versions.
    3. A usage only counts where the declaring package is present. An entry of P on D is
      used if, in some environment containing P (or, for a test/ or docs/ entry, in that
      very environment), the selected D falls in that specifier.
    4. Counterfactual by intersection. Pkg intersects the [compat] of every package in an
      environment. Dropping specifier s from P's entry on D is therefore equivalent to
      intersecting D's allowed range with the kept specifiers in every environment containing P.
      The resolver's top-level [compat] does exactly that, so the edit is simulated without
      touching any repository or registry. It must be applied per environment, never globally:
      a global CUDSS = "0.8" would be wrong (OptimalControl needs 0.7).
    5. The check has teeth. With the 43 retained prunes, all 51 manifests are identical to the
      baseline. Negative control: dropping 0.7 from CTSolvers' CUDSS makes
      OptimalControl/test and OptimalControl/docs unsatisfiable, and the check reports it.
    6. Blockers are cheap. Example found: CTParser/test never selects CTBase 0.30 because its
      test/Project.toml pins CTModels = "0.18". Second example, worth a separate follow-up (out
      of scope): OptimalControl's CUDSS = "0.7" was forced by LinearSolve ≤ 5.18.1
      (WeakCompat caps CUDSS at 0.7); LinearSolve 5.18.2 accepts 0.7 - 0.8, and the
      OptimalControl docs env now resolves with CUDSS = "0.8" on all three Julia versions, at the
      price 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 to auto
    on 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). auto is 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

    • Repositories: CTActions (workflow + scripts + docs), OptimalControl (caller workflow).
    • Files added (CTActions): .github/workflows/compat-prune.yml, scripts/compat-prune/*.jl,
      scripts/compat-prune/test/runtests.jl + fixtures.
    • Files added (OptimalControl): .github/workflows/CompatPrune.yml.
    • Files modified: CTActions/docs/WORKFLOWS.md, CTActions/README.md.
    • Public API changes: no (new reusable workflow, opt-in).

    What changes

    Architecture

    OptimalControl/.github/workflows/CompatPrune.yml     (workflow_dispatch + monthly schedule)
      └─ uses CTActions/.github/workflows/compat-prune.yml@main
           ├─ job audit    (permissions: contents: read; no write token)
           │    checkout CTActions scripts → add ct-registry → for each Julia version, in sequence:
           │      collect → resolve baseline → classify → validate → report.json / report.md
           │    upload artifact `compat-prune-report`, write report.md to the job summary
           └─ job publish  (needs: audit; skipped when dry_run; only job holding the App token)
                download artifact → edit files → one PR per repo → one tracking issue
    

    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: only publish
    can write to other repositories, and it only consumes a JSON file.

    Scripts (CTActions/scripts/compat-prune/, stdlib only: Pkg, TOML)

    File Role
    collect.jl Resolve the joint env (OptimalControl + CT* list) on the newest Julia; for each CT* package read its registered git-tree-sha1 and fetch Project.toml, test/Project.toml, docs/Project.toml from that tree (gh api …/git/trees/<sha>); also fetch the same files on the default branch, for drift detection.
    envs.jl Build the environment specs (name, deps, compat) from the collected files.
    resolve.jl Resolve every env on the running Julia, optionally under a prune set (per-env intersection, empty intersection = UNSAT), write versions or error per env.
    classify.jl Merge the three Julia results; per multi-specifier entry: prune (old specifiers never used), keep, or blocked (newest never used; blocker name parsed from a resolve under "newest only").
    validate.jl Re-resolve under all prunes; if any env differs or fails, test prunes one by one and keep only those that leave every manifest identical. Rejected prunes go to the report with the reason.
    report.jl report.json (machine) + report.md (job summary, issue body).
    edit.jl Textual edit of one [compat] line (keeps order, quoting, comments); re-parses the file and asserts that only that key changed.
    publish.jl Idempotent issue/PR management through gh (see below).

    Shape of a prune record:

    [[prune]]
    repo = "CTSolvers"          # repository
    file = "docs/Project.toml"  # "", "test/" or "docs/" Project.toml
    dep = "CUDSS"
    before = "0.6, 0.7, 0.8"
    after = "0.8"
    evidence = ["CTSolvers/docs@1.10=0.8.1", "CTSolvers/docs@1.12=0.8.1", "…"]

    Idempotence (no duplicates across runs)

    • Tracking issue (OptimalControl): found by the hidden marker <!-- 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.
    • PRs: one fixed bot-owned branch per repository, ct/compat-prune, rebuilt from the
      default 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.
    • Drift: a prune is applied only if the line on the default branch is byte-identical to the
      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.
    • No version bump in the PRs: every prune leaves all manifests identical, so merge order
      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

    on:
      workflow_call:
        inputs:
          packages:        { type: string,  default: '["CTBase","CTParser","CTModels","CTLie","CTFlows","CTDirect","CTSolvers","OptimalControl"]' }
          julia_versions:  { type: string,  default: '["1.10","1.12","1.13"]' }
          dry_run:         { type: boolean, default: true }
          ctactions_ref:   { type: string,  default: main }   # where the scripts are checked out from
        secrets:
          SSH_KEY:            { required: true }   # ct-registry
          CT_APP_ID:          { required: false }  # publish only
          CT_APP_PRIVATE_KEY: { required: false }

    dry_run defaults to true: the first runs only produce the job summary.

    Step 0 — Branch

    git -C CTActions checkout main && git -C CTActions pull
    git -C CTActions checkout -b feat/compat-prune
    git -C OptimalControl checkout main && git -C OptimalControl pull
    git -C OptimalControl checkout -b ci/compat-prune

    Phases

    Phase A — collect, envs, resolve (CTActions, local)

    Steps:

    1. Write collect.jl (joint resolution, registered trees, default-branch copies).
    2. Write envs.jl and resolve.jl from the probe scripts, minus the probe shortcuts (tag
      fetching, hard-coded versions).

    Checkpoint (local, against real registries):

    for v in 1.10 1.12 1.13; do julia +$v scripts/compat-prune/resolve.jl … ; done
    # expect: 17 envs ok on each Julia version, same audited versions as the probe

    Phase B — classify, validate (CTActions, local)

    Steps:

    1. Write classify.jl (owner-scoped usage, prune / keep / blocked, blocker parsing).
    2. Write validate.jl (global check, then one-by-one fallback).
    3. Write test/runtests.jl with fixtures: the CUDSS negative control must be rejected, the
      CTParser/test CTBase blocker must be named, owner scoping must keep CTSolvers CUDSS 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 moves

    Phase C — report and edit (CTActions, local, no network writes)

    Steps:

    1. Write report.jl (JSON + Markdown).
    2. Write edit.jl; tests on fixtures with comments, odd spacing, single quotes, keys out of
      order.
    3. Dry local publish: apply the edits to fresh clones in a scratch directory and show
      git diff per 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:

    1. Write .github/workflows/compat-prune.yml, audit job only, dry_run honoured.
    2. Write OptimalControl/.github/workflows/CompatPrune.yml pointing at
      compat-prune.yml@feat/compat-prune (switched to @main before merge).
    3. actionlint both files.

    Checkpoint: workflow_dispatch run on ci/compat-prune; job summary equals the local report.

    ⛔ Ask before pushing either branch.

    Phase E — publish job

    Steps:

    1. Write publish.jl and the publish job (actions/create-github-app-token, owner
      control-toolbox, repositories from packages).
    2. First live run with 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:

    1. CTActions/docs/WORKFLOWS.md: new section (what it does, inputs, idempotence, how to read
      the issue). CTActions/README.md: workflow table row.
    2. Header comment of compat-prune.yml in the style of documentation-preview-cleanup.yml.
    3. Update [CI] Ecosystem-wide workflow to audit and prune obsolete [compat] entries across CT* packages #986 (method, link to the workflow, force_latest_compatible_version correction).

    Human checkpoints

    • ⛔ Ask before the first commit, in each repository.
    • ⛔ Ask before pushing feat/compat-prune / ci/compat-prune.
    • ⛔ Show the Phase C diffs before wiring the workflow.
    • ⛔ Ask before the first live (dry_run: false) run: it opens PRs in up to 8 repositories.
    • ⛔ Ask if a design decision arises that is not in this plan.

    Decisions

    1. Write token: GitHub App of the organisation (decided 2026-10-15; first App for the
      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.
    2. Schedule: monthly + workflow_dispatch (decided 2026-10-15).
    3. Tracking issue with nothing left to prune: closed with a comment, reopened (never
      recreated) when a prune appears again. Blockers alone do not keep it open (decided
      2026-10-15).

    Out of scope

    • Widening [compat], and any code adaptation to new versions (CompatHelper + Breakage).
    • Raising lower bounds inside a specifier (e.g. CTBase = "0.30.8").
    • Acting on blockers (report only). In particular the OptimalControl CUDSS 0.7 → 0.8 move and
      its CUDA 6.4 consequence on occidata: separate follow-up.
    • Releasing packages, and the CTParser main / registered-branch divergence (CTParser#326).
    • The ct-conventions dependency-graph prototype: the CT* list is an input, the DAG is not
      needed 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.yml
    Modified: CTActions/docs/WORKFLOWS.md, CTActions/README.md

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions