Add the cascade cross-repository wave machinery - #3
Conversation
…ules These three repositories consume modules already assembled here as upstream dependencies, so cross-repository rollouts should reach them: * `delivery-server` tracks nearly the whole SDK — `base-libraries`, `core-jvm`, `compiler`, `validation`, and nine more — at versions close to the tips; * `BuildSpeed` exercises the compiler line (`compiler`, `core-jvm-compiler`, `core-jvm`, `validation`) to measure build-tool performance; * `documentation` builds the spine.io site and its code samples. All three are pinned to fixed commits and declare no tracked `branch`, so `init-submodules` treats them as consumer-owned and leaves them alone, exactly as it does the SDK submodules already listed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Advances 14 submodules to the tip of their `master` branch, refreshing the snapshot of the SDK this superproject records. The remaining pins were already at their tips. `.agents/shared` is deliberately left unstaged: it declares a tracked `branch` and `ignore = all`, so it floats on its own and never shows up as churn here. Nested mounts are synced to the commits their own superprojects pin, not floated — those pointers belong to the repositories that record them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the deterministic driver for propagating changes across the SDK repositories in dependency order (`refresh` after a `config` merge, `ripple` from one upstream, `retarget` for coordinated version assignment such as `2.0.0-M1`): * `cascade` — wave subcommands (`graph`/`plan`/`status`/`next`/`await`/`close`) and per-repo steps (`prep`/`build`/`ship`/`refresh`/`park`/`resume`), with typed exit codes: 3 = a named skill must act, 4 = waiting on an external event, 5 = registry-throttling halt. State is derived from git, GitHub, the registry, and `~/.m2` — never remembered. * `docs/dependency-graph.md` — the standalone build-order contract: canonical order (audited: one-way dependencies constrain it absolutely), reduced production diagram, test-only and mutual-pair sections, and the complete machine-readable list of declared dependencies. * `docs/rollout/rebuild.md` + `rebuild-pr-body.md` — the playbook pairing the script with the agent-driven judgement steps, per the proofread precedent. * `README.md`/`docs/project.md` — links to the new contract and rollout. * `.agents/tasks/cascade.md` — the reviewed design record with decision log. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drops `Bash(git commit:*)` from `permissions.ask` — an ask rule outranks every allow and makes unattended cascade sessions impossible, which `safety-rules.md` → *Commits and history-writing* explicitly warns against. Protection stays with the authorization protocol (skill-scoped commit sections, session grants), the secret-scan `pre-commit` hook, and PR review. Adds `allow` entries for the cascade tooling: `git commit` (summit is where waves execute; repo-rooted sessions elsewhere keep the org default), `./cascade`, and read-only `gh pr view`/`gh pr list`/`gh run list`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A jenv/profile JAVA_HOME may point at an older JDK (observed: corretto 11 while the SDK builds on 17, which Gradle refuses outright). The guard now checks the resolved JVM's major version and falls back to `/usr/libexec/java_home -v 17` instead of trusting the environment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
….bat` The submodule now points past SpineEventEngine/documentation#87, whose line-ending renormalization ends the perpetual phantom modification every checkout of `gradlew.bat` used to show. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The upstream-published gate read `pin_vector`, which records only forward (wave-ordering) upstreams. Published-floor pins point backwards in the canonical order and never appear there, so a floor raised past the registry was invisible and `ship` would open a PR whose CI cannot resolve it. Derive every pinned version from the pin files by content instead, and separate the two outcomes: an unpublished in-wave version is a wait (exit 4), anything else is a bad pin needing a decision (exit 3). Also correct `testlib`'s registry marker: the repo publishes `base-testlib`, while `spine-testlib` is a separate artifact stalled at an older version, so every probe of a current testlib version answered wrongly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Record the verified builds for logging, base-types, and change so the derivation reaches `pr-open` instead of stalling at `bumped`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A repo opted into a wave after planning carries no `target_version`, so the status table printed a dash for it even though the branch plainly declares a version. Fall back to the derived value, and record the target for `money`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`prep` ran `./config/pull` unconditionally, so a repo that does not consume `config` died on a missing script instead of taking part in the wave. Skip the pull step when there is no `config/pull` to run. BuildSpeed then reaches its real wall and is parked: its SDK versions arrive as CI environment variables substituted into `settings.gradle.kts.template`, so it has no file pins to apply, no version to bump, and nothing to publish. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The derived-version fallback referenced an out-of-scope `branch`, which tripped `set -u` on every `status` run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`money` applies the CoreJvm Compiler plugin to generate its own `money.proto` (`money/build.gradle.kts` applies `coreJvmCompiler.pluginId`, and the root buildscript puts `CoreJvmCompiler.gradlePlugin` on the classpath), while nothing depends on `money` at all. The pair is therefore not mutual, so the audit rule applies without exception: a repository is never placed before one it depends on. `money` is a leaf, so moving it one place later costs nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The raw dependency relation is cyclic, so any order satisfies one direction
of each cycle from an already-published artifact. Which direction lags is a
convention. It now runs tools-first: the code-generation tools are built
against the previous generation of the libraries, and the libraries against
the current generation of the tools. A published library therefore names
tools of its own generation, and the lag moves into the tools, whose
artifacts are build-time rather than runtime.
Deriving the edges honestly first was a prerequisite. The usage pattern
rejected a preceding dot, so a fully-qualified reference such as
`classpath(io.spine.dependency.local.CoreJvmCompiler.gradlePlugin)` produced
no edge -- and that is the house style inside `buildscript {}`, where
file-level imports do not apply. Accepting the qualified form adds eight
edges (92 -> 100) and removes none:
base-types -> compiler money -> compiler
change -> compiler money -> core-jvm-compiler
change -> core-jvm-compiler time -> core-jvm-compiler
delivery-server -> compiler gcloud-jvm -> compiler
Every one of them is a library depending on a tool -- precisely the edges
the new convention promotes to build-order constraints.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It now ships as its own PR, so the closing vector PR must not cherry-pick it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Once a wave branch merges, `origin/master..<branch>` is empty, so the state derivation read the repository as `pending` -- as though prep had never run -- and the wave asked to re-prep and rebuild repositories it had already shipped. Check the registry for the recorded target version first: if it is served, the repository is `published-remote` and done, whatever the branch looks like. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Owner feedback after `config#754`: a deprecated, removal-scheduled Gradle syntax survived a rewrite of the very line carrying it, and the reviewer had to catch it. Records the rule and the two mechanics that hid it -- `-q` suppressing deprecation output, and a failed build aborting before the summary prints. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The repository publishes `io.spine.tools:dokka-extensions`, the Dokka plugin every other repository loads to hide `@Internal` declarations, so it belongs in the graph and in the canonical order. It is registered as a submodule here for the same reason as its siblings. One edge is derived, `dokka-tools -> base-libraries`, from the plugin's use of `io.spine.annotation.Internal`. `Logging` appears in its build only inside a `force(...)` list, which is version management rather than a dependency, and correctly yields no edge. The reverse direction cannot be derived at all: every repository takes the plugin through `buildSrc/src/main/kotlin/DokkaExts.kt`, which `config` distributes identically, so no repository-owned build file declares it. A new section records that, rather than leaving the graph to imply the plugin has no consumers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both warnings that Spine builds emit come from plugins we apply, not from our own scripts. The detekt one is easy to misread as a `config` defect because its stack frame names `detekt-code-analysis.gradle.kts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects in the pin-advance loop, all latent until now — `close` had never run. 1. It matched `val version` anywhere in a pin file, including nested objects that declare *different* artifacts on independent version lines. `ToolBase.JavadocFilter.version` is `spine-javadoc-filter`, published at `2.0.0-SNAPSHOT.75`; closing this wave would have rewritten it to `tool-base`'s `2.0.0-SNAPSHOT.422`, which returns 404. Every consumer would have taken that pin on the next `./config/pull`. 2. Restricting the match to the pin object's own constants must still accept every declaration form in use. `Time` is a `Dependency()` subclass and writes `override val version`, while `Compiler` writes `private const val fallbackVersion`. 3. A `grep` that matches nothing exits 1, and under `pipefail` that aborted the whole run part-way — leaving some pins advanced, none committed, and no error printed. It now warns and continues, so a pin file whose shape changed is loud rather than silently skipped. The object's own constants sit at indent 4 and nested ones deeper, so anchoring both the scan and the substitution there selects exactly the intended set. Verified against the current pin files: the only indent-8 match in any of them is the `JavadocFilter` line that must be skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `cascade-refresh-2cd0e3b` wave is complete: every repository published, and `config` now carries the version vector recorded by config#759. Each submodule moves from its wave branch to the master that now contains it. `dokka-tools` moves off `fix-javadoc-publication`, merged during the same stretch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**Pin-file handling, factored into one place.** `cmd_close` had three defects fixed earlier in this branch; review found the same shape still live in `cmd_prep` and `derive_state`, which run far more often. In `cmd_prep` it was worse than at `close`: the match was unanchored, so on `ToolBase.kt` a `head -1` could take the nested `JavadocFilter.version` — a different artifact — and the verification step counted matches anywhere in the file, so a run that clobbered the wrong constant still reported success. All three now share `read_pin_constant`, `read_pin_constant_at` and `write_pin_constant`, anchored to the pin object's own indent-4 constants and accepting every declaration form in use: `const val`, `private const val`, and the `override val` of a `Dependency()` subclass. **Silent aborts.** A `grep` matching nothing exits 1, and under `pipefail` that killed the caller before its own diagnostic could run — so `cmd_prep` died without printing "constant not found", and `version_of` died before `die "cannot read $prop"`. Both now absorb the no-match and let the caller report. **Permissions.** `allow` carried `Bash(git push:*)`, which matches any arguments, while `deny` named no force variant — so `git push --force` was reachable. `safety-rules.md` is explicit that force-push, `git tag`, `git rebase`, `gh release create` and `gh pr merge` stay per-action even under a grant. Force-push is now denied and `git tag` asks; the other three were already covered. **Documentation accuracy.** Only `ship` performs the upstream drift check, not `status`. Not every exit-3 message names a skill. The prerequisites omitted the `read:packages` scope, without which `ship` and `close` cannot verify `delivery-server`, which publishes to GitHub Packages — a gap that blocked this very wave. One prose example used Mermaid's invalid single-dash arrow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
🟡 Changes recommended
There is at least one confirmed functional issue in cascade (ripple slug/version handling) plus smaller correctness/clarity issues that should be addressed before approval.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Adds the new cascade driver to orchestrate deterministic cross-repository “waves” across Spine SDK submodules (plan/status/next/prep/build/ship/close), alongside documentation that defines and explains the canonical dependency/build order contract used to sequence those waves.
Changes:
- Introduce
cascade(bash) with wave planning, state derivation, gating, and PR automation. - Add/extend documentation for the dependency graph contract and the operator playbook for rebuild waves.
- Update repo layout docs and submodule configuration to reflect new participating repositories/tooling.
File summaries
| File | Description |
|---|---|
| README.md | Links repository layout to the canonical dependency/build-order contract and updates submodule tables. |
| docs/rollout/rebuild.md | Operator playbook describing how to run rebuild waves using cascade. |
| docs/rollout/rebuild-pr-body.md | Standard PR body template used when cascade ship opens per-repo PRs. |
| docs/project.md | Documents rebuild waves as a cross-repository workflow and links to cascade + the dependency graph. |
| docs/dependency-graph.md | Canonical, reviewed build order + dependency diagrams and per-repo metadata consumed by cascade. |
| cascade | New wave orchestration script implementing graph verification, planning, state derivation, and per-repo steps. |
| .gitmodules | Adds new submodules (e.g., delivery-server, BuildSpeed, documentation, dokka-tools). |
| .claude/settings.json | Updates tool/skill permission configuration for running cascade (skipped per repo guidelines). |
| .agents/tasks/cascade.md | Wave/cascade design and task record (skipped per repo guidelines). |
| .agents/tasks/cascade-refresh-2cd0e3b.md | Recorded wave task state (skipped per repo guidelines). |
| .agents/tasks/cascade-refresh-2cd0e3b.json | Machine manifest for a wave (skipped per repo guidelines). |
| .agents/memory/MEMORY.md | Team memory index (skipped per repo guidelines). |
| .agents/memory/fix-deprecations-in-touched-code.md | Team guidance on deprecation cleanup (skipped per repo guidelines). |
Review details
Suppressed comments (1)
cascade:853
plan --kind ripple --seed <repo>allows omitting@<version>, but the slug is still built fromseed_ver(cascade-ripple-$seed-${seed_ver##*.}), producing a trailing-and not freezing the seed version at plan time. This can lead to ambiguous/non-deterministic wave identifiers.
case "$kind" in refresh|ripple|retarget) ;; *) usage_plan ;; esac
[ "$kind" = ripple ] && [ -z "$seed" ] && usage_plan
[ "$kind" = retarget ] && [ -z "$target" ] && usage_plan
"$0" graph --check
# Trigger + slug.
local cfg_sha slug
git -C "$SUMMIT/config" fetch --quiet origin "$BASE" 2>/dev/null || true
cfg_sha="$(git -C "$SUMMIT/config" rev-parse "origin/$BASE" 2>/dev/null \
|| git -C "$SUMMIT/config" rev-parse HEAD)"
case "$kind" in
refresh) slug="cascade-refresh-$(echo "$cfg_sha" | cut -c1-7)" ;;
ripple) slug="cascade-ripple-$seed-${seed_ver##*.}" ;;
retarget) slug="cascade-retarget-$target" ;;
esac
- Files reviewed: 33/33 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7c01d61c4a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
**A park did not hold the whole downstream.** `next` only looked at direct upstreams whose *stored* state was literally `parked`. With `A -> B -> C` and `A` parked, `B` was blocked but `C` was not, so `next` could schedule `prep` for `C` against an unbuilt `B`. Blocking is now computed in a pass over `.order`, which is topological, so it propagates. **A parked repo held the wave open forever.** `done_all` required `published-remote` from every repo, and a park is terminal — `close` skips parked repos. `BuildSpeed` is parked on every refresh, so `CLOSE` could never be emitted and `next` returned `WAIT` indefinitely. With this change `next` reports `DONE` for the completed wave, where before it reported `WAIT`. **Rebuilding at an unchanged version left stale artifacts.** `publishToMavenLocal` ran only when the marker directory was absent. `refresh` rebuilds a repo at the same version by design, so the directory existed and publication was skipped — downstream builds then resolved the previous HEAD's artifacts while the manifest recorded the new HEAD as built. Publication now follows every successful build. **A published ripple seed was scheduled for a version bump.** `plan` initialized every scoped repo as `pending`, including a seed named as `repo@version`, which by definition is already published. `next` then scheduled `prep` for it and bumped it a second time. Such a seed now starts terminal at the supplied version; downstream repos still read that version through `.seed.version`. **`close --ship` skipped the review gate.** It pushed and opened the config PR without checking the sentinel. The script's own `git push` and `gh pr create` never reach the PreToolUse hooks, so the documented close -> pre-pr -> close --ship step rested on operator memory. It now applies the same HEAD-bound gate as `ship`. Also: `floor_edges()` was defined twice with identical bodies; the duplicate is gone. And the `JAVA_HOME` prerequisite gave a macOS-only incantation without saying so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rule keyed on the lines being edited, which misses the case that actually recurs: a wave propagates a new dependency version, that version deprecates API our untouched code uses, and nothing in the diff points at the warning. By the old wording nobody owned those lines, so the warning could ride through every repo the wave touches and surface only at the removal, far from the change that caused it. Restated around the warnings a change introduces, by either route — the line was edited, or the baseline moved under it. Bounded with the split `cascade-adapt` already uses for build breakage: a deprecation with a documented, behaviour-preserving successor is fixed in the same PR; one whose replacement changes behaviour, or whose surface needs design judgement, parks the repo with a diagnosis instead of being guessed at to keep a wave moving. Renamed accordingly; the third-party attribution notes are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The `cascade-refresh-2cd0e3b` wave is finished — every repository
published and the closing vector merged — so its manifest and task file
move to `.agents/tasks/archive/`. `active_wave` globs
`$TASKS_DIR/cascade-*.json` without recursing, so an archived wave stops
being auto-selected, which is what completion should mean.
The move surfaced two defects in the script:
`cmd_status` declared and assigned in one statement:
local wave="${1:-$(active_wave)}"
`local` returns its own exit status, so a failing substitution was
masked, `errexit` never fired, and `active_wave`'s `die` did not stop the
script — it carried on with an empty wave and printed a second,
contradictory error. Splitting the declaration lets the failure surface
once. This was unreachable while a manifest always existed.
`manifest_path` looked only in the top level, so archiving a wave made it
unreadable by the tool that wrote it, even when named. It now falls back
to `archive/`, keeping a completed wave inspectable via
`CASCADE_WAVE=<slug> ./cascade status` while staying out of the glob.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Introduces
cascade, the machinery for propagating a change across the SpineSDK repositories in dependency order, and the documentation around it. Proven
end to end: the
cascade-refresh-2cd0e3bwave ran on it, publishing 15repositories and closing with config#759.
What lands
cascadegraph,plan,status,next,prep,build,ship,refresh,park,resume,await,closedocs/dependency-graph.mdcascade graphdocs/rollout/rebuild.mddocs/rollout/rebuild-pr-body.md.agents/memory/,.agents/tasks/BuildSpeed,delivery-server,documentation,dokka-toolsadded; all pointers moved to the wave's merged mastersDesign
Deterministic mechanics live in the script; judgement steps — build breakage,
pre-PR review, version edge cases — are handed back via exit code 3 with a
resume command. State is derived, never remembered:
statusre-proves everyfact from the registry,
gh, and the git tree, so a recordedbuiltisdemoted the moment a later commit lands. Interrupt anything and re-run it.
Two rules the wave exists to enforce:
only once every version it pins is provably published — CI resolves from the
registry, not from Maven Local.
close.versionForBuildScript,dogfoodingVersionand thefallback*pair feed buildscript andpluginManagementclasspaths that cannot see Maven Local, so during a wavethey must keep pointing at the previous published generation.
Review fixes in
7c01d61Pin-file handling. Three defects were found in
cmd_close, then the sameshape was found still live in
cmd_prepandderive_state. Incmd_prepitwas worse: the match was unanchored, so on
ToolBase.ktahead -1could takethe nested
JavadocFilter.version— a different artifact,spine-javadoc-filter— and the verification counted matches anywhere in thefile, so a run that clobbered the wrong constant still reported success. All
three now share anchored helpers accepting
const val,private const valandoverride val.Silent aborts. A
grepmatching nothing exits 1; underpipefailthatkilled the caller before its own diagnostic could run.
cmd_prepdied withoutprinting "constant not found", and
version_ofbeforedie "cannot read".Permissions.
allowcarriedBash(git push:*), which matches anyarguments, while
denynamed no force variant — sogit push --forcewasreachable.
safety-rules.mdrequires force-push,git tag,git rebase,gh release createandgh pr mergeto stay per-action even under a grant.Force-push is now denied,
git tagasks; the other three were already covered.For the reviewer
cascadenever force-pushes, never touchesmasterdirectly, and contains nogh pr merge,git rebase, or history rewriting. Branch names are alwayscascade-{refresh,ripple,retarget}-*.cmd_buildandcmd_shipdeliberately re-implement the pre-PR andversion-bump gates internally.
Bash(./cascade:*)is one allow-listedinvocation, so the PreToolUse hooks — which pattern-match the literal command
text — never see the
gh pr createand./gradlewcalls made inside. Thesecret-scan protection is unaffected, being a real git
pre-commithook.sededit to a pin file is anchored, then verified, and reverts thefile on mismatch.
limit because of cell-alignment padding. Cosmetic, and de-padding would churn
every row; happy to do it if preferred.
Verification
config#759.
bash -nclean;./cascade statusand./cascade closere-run green andidempotent after the refactor.
ToolBase.versionreads.422and not the nested.75;private const valandoverride valbothread; a missing constant returns empty without aborting; a write touches only
the intended line.
review-docs— APPROVE WITH CHANGES (accuracy items applied).spine-code-review— REQUEST CHANGES; all three Must-fix items applied above.🤖 Generated with Claude Code