@mokoconsulting/mokoabs-mcp 0.0.0-stage → 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/.editorconfig +41 -0
  2. package/.gitattributes +94 -0
  3. package/.gitmessage +9 -0
  4. package/.mokogit/ISSUE_TEMPLATE/adr.md +110 -0
  5. package/.mokogit/ISSUE_TEMPLATE/bug_report.md +48 -0
  6. package/.mokogit/ISSUE_TEMPLATE/config.yml +18 -0
  7. package/.mokogit/ISSUE_TEMPLATE/documentation.md +52 -0
  8. package/.mokogit/ISSUE_TEMPLATE/feature_request.md +51 -0
  9. package/.mokogit/ISSUE_TEMPLATE/mcp_api_integration.md +48 -0
  10. package/.mokogit/ISSUE_TEMPLATE/mcp_connection_issue.md +69 -0
  11. package/.mokogit/ISSUE_TEMPLATE/mcp_tool_request.md +50 -0
  12. package/.mokogit/ISSUE_TEMPLATE/question.md +82 -0
  13. package/.mokogit/ISSUE_TEMPLATE/rfc.md +126 -0
  14. package/.mokogit/ISSUE_TEMPLATE/security.md +51 -0
  15. package/.mokogit/ISSUE_TEMPLATE/version.md +24 -0
  16. package/.mokogit/actions/resolve-source-dir/action.yml +72 -0
  17. package/.mokogit/workflows/apidocs.yml +132 -0
  18. package/.mokogit/workflows/auto-assign.yml +76 -0
  19. package/.mokogit/workflows/auto-dev-issue.yml +207 -0
  20. package/.mokogit/workflows/auto-release.yml +1787 -0
  21. package/.mokogit/workflows/branch-cleanup.yml +63 -0
  22. package/.mokogit/workflows/cascade.yml +645 -0
  23. package/.mokogit/workflows/ci-issue-reporter.yml +100 -0
  24. package/.mokogit/workflows/cleanup.yml +112 -0
  25. package/.mokogit/workflows/makefile-lint.yml +240 -0
  26. package/.mokogit/workflows/npm-build-test.yml +507 -0
  27. package/.mokogit/workflows/npm-sdk-check.yml +110 -0
  28. package/.mokogit/workflows/npm-tool-inventory.yml +81 -0
  29. package/.mokogit/workflows/pr-branch-check.yml +92 -0
  30. package/.mokogit/workflows/pre-release.yml +1134 -0
  31. package/.mokogit/workflows/rc-revert.yml +75 -0
  32. package/AGENTS.md +48 -0
  33. package/CHANGELOG.md +37 -0
  34. package/CODE_OF_CONDUCT.md +70 -0
  35. package/CONTRIBUTING.md +164 -0
  36. package/LICENSE +696 -0
  37. package/Makefile +94 -0
  38. package/README.md +109 -2
  39. package/SECURITY.md +245 -0
  40. package/build/README.md +7 -0
  41. package/dist/README.md +7 -0
  42. package/dist/index.js +153 -0
  43. package/package.json +34 -5
  44. package/sample/config.example.json +15 -0
  45. package/source/client.ts +132 -0
  46. package/source/config.ts +139 -0
  47. package/source/credentialStore.ts +216 -0
  48. package/source/index.ts +235 -0
  49. package/source/tokenReader.ts +120 -0
  50. package/source/types.ts +63 -0
  51. package/tsconfig.json +19 -0
@@ -0,0 +1,645 @@
1
+ # Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
2
+ #
3
+ # SPDX-License-Identifier: GPL-3.0-or-later
4
+ #
5
+ # FILE INFORMATION
6
+ # DEFGROUP: MokoGIT.Workflow
7
+ # INGROUP: MokoCLI.Cascade
8
+ # REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
9
+ # PATH: /.mokogit/workflows/cascade.yml
10
+ # VERSION: 03.07.00
11
+ # BRIEF: (03.07.00) TOPOLOGY + SERVER-CASCADE NOTE (MokoCLI #965/#967): (1) R4 dev -> child-branch pass. The workflow previously cascaded ONLY the production branch (SRC) into CASCADE_TARGETS; it now does an optional SECOND pass that cascades `dev` into a new DEV_CHILD_TARGETS variable (reusing cascade_to with SRC repointed to dev). DEV_CHILD_TARGETS DEFAULTS TO EMPTY, so this is a strict no-op for every existing repo and must be opted into per repo/org -- no fleet behaviour changes until a repo sets it. The pass is guarded by branch_exists dev and self-skips when dev is absent. dev -> child REPOS remains the SYNC engine's responsibility and is intentionally NOT done here. (2) R3 is satisfied PRIMARILY by the MokoGIT server-side cascade (the Gitea-fork notifier fires per-merge -- including on [skip ci] commits the platform skips for workflows -- and auto-merges/fan-outs/chains via the cascade rule `mode`); this workflow is the SECONDARY/fallback layer (cron reconcile + workflow_dispatch after sync). The header/`on:` comments now state that explicitly. The daily cron is UNCHANGED (left intact as the documented safety-net). --- (03.06.00) RC4 WEDGE RECOVERY: when a cascade PR's mergeability check wedges in Gitea's internal "checking" state so every /merge returns HTTP 405 "Please try again later" indefinitely (MokoGIT #1729; neither the 12x retry loop + prime_merge_status nor a close+reopen of the SAME PR recovers it), the conflict-free path now ABANDONS the stuck PR and recreates a fresh ${SRC}->${TGT} PR (the only recovery -- a brand-new PR gets a clean check task), then retries the immediate merge once; non-405 failures still short-circuit straight to notify. --- RECONCILED (03.05.01): carries BOTH divergent 03.05.00 improvements. (1) SRC=stable fix: SRC resolves to the PRODUCTION branch via branch_exists() -- `stable` if it exists, else `main` if it exists, else default_branch -- so a channel-named repo whose DEFAULT is `dev` still back-merges stable -> dev instead of cascading dev -> rc/alpha/beta the wrong direction; every PR title/body/commit/log is labelled by the RESOLVED source (chore(sync): cascade ${SRC} -> <TGT>), no more misleading literal "main". (2) composer.json conflict resolution: composer.json version conflicts are inline-resolved to version=max (via resolve_json_version), same as Makefile PROJECT_VERSION, package.json and XML manifests, so a fix/chore->stable patch bump can no longer cascade a LOWER composer.json version onto a dev already bumped ahead (the DEV VERSION NEVER REGRESSES ON CASCADE); previously composer.json fell through to the '*' deeper-resolver/human path, blocking the cascade (Mokonyx #320/#408 manual reconcile) and racing the version between channels. Cascade the production branch -> the full release ladder (dev + on-demand rc/alpha/beta). Rolls up the retired cascade-dev.yml: one job loops the ladder, cascading the source into each target that EXISTS. dev is permanent and self-heals (recreated from main if missing after an RC-rename); rc/alpha/beta are on-demand and are SKIPPED when absent (never conjured). Branch-motivated (real push to main cascades down immediately) PLUS a daily scheduled safety-net: workflow_sync.php commits to stable/main carry [skip ci] and fire NO push trigger, so if its post-sync workflow_dispatch is missed the sync commits never back-merge and stable silently forks from dev (the MokonyxLicensing 79-commit drift incident). The cron reconciles stable/main -> dev periodically regardless of [skip ci]; it is idempotent/no-op when already in sync. Auto-merge clean, auto-resolve routine conflicts INLINE (manifest version=max / .mokogit-.gitea-.github -> main / docs-stamps -> dev) with no mokocli dependency, fall back to cascade_resolve.php then notify. Resolved merges are LANDED VIA PR (temp branch + PR-merge API), never direct-pushed to a protected target (the runner token is refused by the pre-receive hook). Conflict-free path does an IMMEDIATE PR merge so a clean cascade self-completes on repos with no required status checks.
12
+
13
+ name: "Cascade Main -> Ladder"
14
+
15
+ on:
16
+ # PRIMARY per-merge guarantee (MokoCLI #965 R3) is the MokoGIT SERVER-SIDE
17
+ # cascade: the Gitea-fork notifier fires on EVERY merge to the production
18
+ # branch -- INCLUDING [skip ci] commits, which the platform skips for
19
+ # workflows -- and auto-merges / fans out / chains transitively per the
20
+ # cascade rule `mode`. THIS WORKFLOW is the SECONDARY/fallback layer only.
21
+ #
22
+ # Branch-motivated: any real push to main/stable cascades to the ladder
23
+ # immediately. Sync commits from workflow_sync.php carry [skip ci] and are
24
+ # skipped by the platform (they fire no trigger at all), so workflow_sync.php
25
+ # dispatches this workflow directly (workflow_dispatch) after it changes a repo
26
+ # -- that is what carries .mokogit drift down the ladder.
27
+ push:
28
+ branches:
29
+ - main
30
+ - stable
31
+ # Safety-net: a daily schedule reconciles stable/main -> dev regardless of
32
+ # [skip ci]. If the post-sync workflow_dispatch is ever missed, [skip ci]-only
33
+ # sync commits to the production branch would otherwise never back-merge and the
34
+ # production branch silently forks from dev (the MokonyxLicensing 79-commit
35
+ # drift that blocked its stable cut, back-merged in Licensing PR #160). The cron
36
+ # closes that gap: it is idempotent -- cascade_to() skips any target already up
37
+ # to date -- so a repo already in sync is a no-op. Scheduled runs execute on the
38
+ # repo default (production) branch, matching the existing branch-guard convention.
39
+ schedule:
40
+ - cron: '17 6 * * *'
41
+ workflow_dispatch:
42
+
43
+ permissions:
44
+ contents: write
45
+ pull-requests: write
46
+
47
+ env:
48
+ MOKOGIT_URL: ${{ vars.MOKOGIT_URL || 'https://git.mokoconsulting.tech' }}
49
+ # ntfy destination is configured via repo or org variables (org vars are inherited).
50
+ NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
51
+ NTFY_TOPIC: ${{ vars.CASCADE_NTFY_TOPIC || vars.NTFY_TOPIC || 'git-releases' }}
52
+ # The release ladder, high -> low. main cascades into each of these that exists.
53
+ # `dev` is permanent (self-heals); the rest are on-demand (skipped when absent).
54
+ # Overridable per repo/org via the CASCADE_TARGETS variable.
55
+ CASCADE_TARGETS: ${{ vars.CASCADE_TARGETS || 'dev rc alpha beta' }}
56
+ # R4 (MokoCLI #967): optional dev -> child-BRANCH cascade targets. DEFAULTS
57
+ # EMPTY so this is a strict no-op unless a repo/org opts in by setting the
58
+ # DEV_CHILD_TARGETS variable (e.g. a repo-local integration branch that must
59
+ # track dev). dev -> child REPOS is the SYNC engine's job, NOT this workflow.
60
+ DEV_CHILD_TARGETS: ${{ vars.DEV_CHILD_TARGETS || '' }}
61
+
62
+ jobs:
63
+ cascade:
64
+ name: Cascade main -> ladder
65
+ runs-on: ubuntu-latest
66
+ # Branch-guard: only run on the production branch context. push is already
67
+ # filtered to main/stable above; scheduled and manual runs execute against the
68
+ # repo default (production) branch, so require ref_name == default_branch. This
69
+ # never fires the cascade from a feature/dev checkout.
70
+ if: >-
71
+ github.event_name == 'push' ||
72
+ github.ref_name == github.event.repository.default_branch
73
+ steps:
74
+ - name: Checkout (full history for merge/resolve)
75
+ uses: actions/checkout@v4
76
+ with:
77
+ fetch-depth: 0
78
+ token: ${{ github.token }}
79
+
80
+ - name: Cascade main -> ladder (auto-resolve routine conflicts, else notify)
81
+ env:
82
+ TOKEN: ${{ github.token }}
83
+ REPO: ${{ github.repository }}
84
+ MOKOGIT_TOKEN: ${{ github.token }}
85
+ run: |
86
+ set -uo pipefail
87
+ API="${MOKOGIT_URL}/api/v1/repos/${REPO}"
88
+ AUTH="Authorization: token ${TOKEN}"
89
+ jqget() { python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('$1',''))" 2>/dev/null; }
90
+
91
+ # prime_merge_status(pr): a cascade PR carries NO CI, so its head commit
92
+ # has zero commit statuses -> Gitea reports the combined state as "pending"
93
+ # (never "success"). A Do:merge on such a PR is accepted (2xx) but SCHEDULED
94
+ # (merge-when-checks-succeed) and then waits forever for a success that can
95
+ # never arrive, so the cascade PR sits open and dev never advances past
96
+ # stable (fleet-wide drift; MokoCLI #813). Stamp a synthetic success status
97
+ # on the PR head so the (possibly deferred) merge lands immediately. Best
98
+ # effort; never fatal. $1 = PR number.
99
+ prime_merge_status() {
100
+ local sha
101
+ sha=$(curl -sf -H "$AUTH" "${API}/pulls/$1" 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin).get('head',{}).get('sha',''))" 2>/dev/null || true)
102
+ [ -n "$sha" ] && curl -s -o /dev/null -H "$AUTH" -H "Content-Type: application/json" \
103
+ -X POST "${API}/statuses/${sha}" \
104
+ -d '{"state":"success","context":"cascade/auto-merge","description":"Cascade PR: no CI required; approved for auto-merge (#813)."}' || true
105
+ }
106
+
107
+ # recreate_cascade_pr(): wedge recovery for MokoGIT #1729 (root cause 4). A
108
+ # cascade PR can get its mergeability check stuck in Gitea's internal
109
+ # "checking" state, so every /merge returns HTTP 405 "Please try again later"
110
+ # indefinitely -- the 12x retry loop and prime_merge_status cannot recover it,
111
+ # and close+reopen of the SAME PR keeps the wedged check. The ONLY fix is to
112
+ # abandon the PR and open a BRAND-NEW one for the same SRC->TGT, which gets a
113
+ # clean check task. Closes the current ${PR}, opens a fresh ${SRC}->${TGT} PR,
114
+ # and updates ${PR} (caller's local; bash dynamic scope, same as notify) to
115
+ # the new number. Returns 0 if a new PR now exists, 1 otherwise.
116
+ recreate_cascade_pr() {
117
+ echo "::warning::cascade PR #${PR} merge wedged in Gitea 'checking' (persistent 405); abandoning it and recreating ${SRC} -> ${TGT} (MokoGIT #1729)."
118
+ curl -s -o /dev/null -H "$AUTH" -H "Content-Type: application/json" \
119
+ -X PATCH "${API}/pulls/${PR}" -d '{"state":"closed"}' || true
120
+ local RESP NEWPR
121
+ RESP=$(curl -s -H "$AUTH" -H "Content-Type: application/json" -X POST "${API}/pulls" \
122
+ -d "{\"head\":\"${SRC}\",\"base\":\"${TGT}\",\"title\":\"chore(sync): cascade ${SRC} -> ${TGT} (recreated)\",\"body\":\"Recreated after the prior cascade PR wedged in Gitea 'checking' (405 'Please try again later'); see MokoGIT #1729.\"}")
123
+ NEWPR=$(printf '%s' "$RESP" | jqget number)
124
+ if [ -z "$NEWPR" ]; then
125
+ echo "::warning::Could not recreate cascade PR for ${TGT}: $RESP"; return 1
126
+ fi
127
+ PR="$NEWPR"
128
+ echo "Recreated cascade PR #${PR} (${SRC} -> ${TGT})"
129
+ return 0
130
+ }
131
+
132
+ # SRC = the production branch this cascade flows FROM. Resolve it to the
133
+ # PRODUCTION branch, NOT default_branch: use `stable` if it exists, else
134
+ # `main` if it exists, else fall back to the repo default. This is the
135
+ # SRC=stable fix -- a channel-named repo whose DEFAULT is `dev` must still
136
+ # back-merge stable -> dev (+rc/alpha/beta), never cascade dev DOWN the
137
+ # ladder in the wrong direction. Existence is probed with the same
138
+ # /branches/<name> API the target loop uses (#1294 model).
139
+ DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
140
+ branch_exists() { curl -sf -H "$AUTH" "${API}/branches/$1" >/dev/null 2>&1; }
141
+ if branch_exists stable; then
142
+ SRC="stable"
143
+ elif branch_exists main; then
144
+ SRC="main"
145
+ else
146
+ SRC="${DEFAULT_BRANCH}"
147
+ fi
148
+ echo "Cascade source (production) branch: ${SRC} (default_branch=${DEFAULT_BRANCH})"
149
+
150
+ # The source must exist. A dev-only repo (no stable/main branch) has
151
+ # nothing to cascade FROM, so skip the whole run immediately.
152
+ if ! branch_exists "${SRC}"; then
153
+ echo "No ${SRC} branch (dev-only repo) - nothing to cascade."; exit 0
154
+ fi
155
+
156
+ # notify(msg): ntfy that the CURRENT target ($TGT / $PR, dynamic scope from
157
+ # cascade_to) needs a manual merge. $1 = context sentence.
158
+ notify() {
159
+ curl -sS \
160
+ -H "Title: ${REPO}: ${TGT} cascade needs manual merge" \
161
+ -H "Tags: warning,twisted_rightwards_arrows" \
162
+ -H "Priority: high" \
163
+ -H "Click: ${MOKOGIT_URL}/${REPO}/pulls/${PR}" \
164
+ -d "${SRC} -> ${TGT} cascade PR #${PR} $1 It was NOT auto-merged; resolve it manually." \
165
+ "${NTFY_URL}/${NTFY_TOPIC}" || true
166
+ }
167
+
168
+ # Land the current (already-committed) resolved merge on the protected
169
+ # target branch WITHOUT a direct push. The runner's github.token is refused
170
+ # by the target's pre-receive / branch-protection hook on a direct push, but
171
+ # it CAN merge a PR. So publish HEAD to a throwaway branch and land it on the
172
+ # target through the PR API. Reads $TGT / $PR from cascade_to (dynamic scope).
173
+ # $1 = note for the merge comment.
174
+ land_on_target_via_pr() {
175
+ local note="$1"
176
+ local tmp="cascade/auto-${TGT}-$(git rev-parse --short HEAD)"
177
+ # Publish the resolved merge to a throwaway branch. Prefer MOKOGIT_TOKEN
178
+ # (guaranteed push rights); the temp branch is NON-protected, so this does
179
+ # not bypass the target's protection -- the target is only ever written via
180
+ # the PR-merge API below. Fall back to origin (github.token) if unset.
181
+ local remote="origin"
182
+ [ -n "${MOKOGIT_TOKEN:-}" ] && remote="https://x-access-token:${MOKOGIT_TOKEN}@${MOKOGIT_URL#https://}/${REPO}.git"
183
+ if ! git push -f "${remote}" "HEAD:refs/heads/${tmp}"; then
184
+ echo "::error::Could not publish resolved merge to ${tmp}"; return 1
185
+ fi
186
+ local tpr resp
187
+ tpr=$(curl -sf -H "$AUTH" "${API}/pulls?state=open&base=${TGT}" \
188
+ | python3 -c "import sys,json; d=json.load(sys.stdin); print(next((str(p['number']) for p in d if p.get('head',{}).get('ref')=='${tmp}'), ''))" 2>/dev/null || echo "")
189
+ if [ -z "$tpr" ]; then
190
+ resp=$(curl -s -H "$AUTH" -H "Content-Type: application/json" -X POST "${API}/pulls" \
191
+ -d "{\"head\":\"${tmp}\",\"base\":\"${TGT}\",\"title\":\"chore(sync): cascade ${SRC} -> ${TGT} (auto-resolved)\",\"body\":\"Auto-resolved cascade merge, landed via PR (${TGT} is protected; the runner token cannot direct-push).\"}")
192
+ tpr=$(printf '%s' "$resp" | jqget number)
193
+ if [ -z "$tpr" ]; then
194
+ echo "::error::Could not open temp cascade PR: $resp"
195
+ git push "${remote}" --delete "${tmp}" >/dev/null 2>&1 || true
196
+ return 1
197
+ fi
198
+ fi
199
+ # Any failure exit from here MUST tear down ${tmp} + PR #${tpr} so a failed
200
+ # run does not leave orphaned cascade/auto-* branches / PRs.
201
+ _cleanup_tmp() {
202
+ curl -s -H "$AUTH" -H "Content-Type: application/json" -X PATCH "${API}/pulls/${tpr}" -d '{"state":"closed"}' >/dev/null 2>&1 || true
203
+ git push "${remote}" --delete "${tmp}" >/dev/null 2>&1 || true
204
+ }
205
+ local m=""
206
+ for _ in 1 2 3 4 5 6; do
207
+ m=$(curl -sf -H "$AUTH" "${API}/pulls/${tpr}" | jqget mergeable)
208
+ case "$m" in True|False) break ;; esac
209
+ sleep 3
210
+ done
211
+ if [ "$m" != "True" ]; then
212
+ echo "::warning::Temp cascade PR #${tpr} not mergeable (mergeable=${m}); cleaning up."
213
+ _cleanup_tmp
214
+ return 1
215
+ fi
216
+ # Prefer an IMMEDIATE merge (delete_branch_after_merge is honoured on it).
217
+ # Right after PR creation MokoGIT may still be computing the merge base and
218
+ # returns HTTP 405 "Please try again later" -- retry with backoff. Do NOT rely
219
+ # on merge_when_checks_succeed as the primary path: on a repo with NO required
220
+ # checks it waits forever for checks that never run (target never lands).
221
+ prime_merge_status "${tpr}"
222
+ local code=000 attempt
223
+ for attempt in $(seq 1 12); do
224
+ code=$(curl -s -o /tmp/tmerge.json -w "%{http_code}" -H "$AUTH" -H "Content-Type: application/json" -X POST "${API}/pulls/${tpr}/merge" -d '{"Do":"merge","delete_branch_after_merge":true}')
225
+ { [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; } && break
226
+ echo "::notice::merge attempt ${attempt} for temp PR #${tpr} got HTTP ${code}; retrying in 15s"
227
+ sleep 15
228
+ done
229
+ if ! { [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; }; then
230
+ code=$(curl -s -o /tmp/tmerge.json -w "%{http_code}" -H "$AUTH" -H "Content-Type: application/json" -X POST "${API}/pulls/${tpr}/merge" -d '{"Do":"merge","merge_when_checks_succeed":true,"delete_branch_after_merge":true}')
231
+ fi
232
+ if [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; then
233
+ echo "Cascade landed on ${TGT} via PR #${tpr} (merged, or scheduled to merge+delete when checks pass)."
234
+ curl -s -H "$AUTH" -H "Content-Type: application/json" -X POST "${API}/issues/${tpr}/comments" \
235
+ -d "{\"body\":\"${note}\"}" >/dev/null 2>&1 || true
236
+ # The original main->TGT PR is superseded by this resolved PR.
237
+ [ -n "${PR:-}" ] && curl -s -H "$AUTH" -H "Content-Type: application/json" -X PATCH "${API}/pulls/${PR}" \
238
+ -d '{"state":"closed"}' >/dev/null 2>&1 || true
239
+ return 0
240
+ fi
241
+ echo "::warning::Temp cascade PR #${tpr} merge returned HTTP ${code}: $(cat /tmp/tmerge.json); cleaning up."
242
+ _cleanup_tmp
243
+ return 1
244
+ }
245
+
246
+ # cascade_to(target): cascade main into one ladder target. Returns 0 on
247
+ # success/no-op/soft-fail-notified, non-zero only on an unexpected error so
248
+ # the loop can flag the run. $TGT / $PR are locals visible to the helpers
249
+ # above via bash dynamic scoping.
250
+ cascade_to() {
251
+ local TGT="$1"
252
+ local PR=""
253
+
254
+ # Target existence. dev is PERMANENT and self-heals; on-demand targets
255
+ # (rc/alpha/beta) are SKIPPED when absent -- an on-demand branch that does
256
+ # not exist must not be conjured into being by the cascade.
257
+ if ! curl -sf -H "$AUTH" "${API}/branches/${TGT}" >/dev/null 2>&1; then
258
+ if [ "$TGT" = "dev" ]; then
259
+ # LADDER FIX (defensive): the release ladder renames `dev` -> `rc` on
260
+ # promote and is meant to recreate a fresh `dev` from `main`. If that
261
+ # recreate ever fails to land, `dev` goes missing; recreate it from
262
+ # `main` via the Git POST /branches CREATE endpoint (governance permits
263
+ # ref creation even on a protected target; NOT a protection toggle).
264
+ # IDEMPOTENT: only runs when `dev` is genuinely absent.
265
+ echo "No dev branch - recreating it from ${SRC} (ladder RC-rename recovery)."
266
+ local CODE
267
+ CODE=$(curl -s -o /tmp/cascade_recreate_dev.json -w "%{http_code}" \
268
+ -H "$AUTH" -H "Content-Type: application/json" \
269
+ -X POST "${API}/branches" \
270
+ -d "{\"new_branch_name\":\"dev\",\"old_branch_name\":\"${SRC}\"}")
271
+ if [ "$CODE" -ge 200 ] && [ "$CODE" -lt 300 ]; then
272
+ echo "Recreated dev from ${SRC} (HTTP ${CODE}); continuing cascade."
273
+ else
274
+ echo "::warning::Could not recreate dev from ${SRC} (HTTP ${CODE}): $(cat /tmp/cascade_recreate_dev.json 2>/dev/null)"
275
+ echo "No dev branch and recreate failed - nothing to cascade to dev."; return 0
276
+ fi
277
+ else
278
+ echo "On-demand target '${TGT}' does not exist - skipping."; return 0
279
+ fi
280
+ fi
281
+
282
+ # 1. is main ahead of TGT? Resolve BOTH sides to their BRANCH head SHAs
283
+ # first: the channel-named branch model (#1294) allows a same-named TAG
284
+ # (alpha/beta/stable exist as both a release tag AND a branch), and Gitea's
285
+ # compare resolves a bare ref as branch-OR-tag — so `compare/${TGT}...main`
286
+ # could measure against the like-named TAG and silently skip a needed
287
+ # cascade. The /branches/<name> API is branch-scoped, so its .commit.id is
288
+ # unambiguous; comparing by SHA can't be fooled by a same-named tag.
289
+ local AHEAD TGT_SHA MAIN_SHA
290
+ TGT_SHA=$(curl -sf -H "$AUTH" "${API}/branches/${TGT}" \
291
+ | python3 -c "import sys,json; print(json.load(sys.stdin).get('commit',{}).get('id',''))" 2>/dev/null || echo "")
292
+ MAIN_SHA=$(curl -sf -H "$AUTH" "${API}/branches/${SRC}" \
293
+ | python3 -c "import sys,json; print(json.load(sys.stdin).get('commit',{}).get('id',''))" 2>/dev/null || echo "")
294
+ if [ -z "$TGT_SHA" ] || [ -z "$MAIN_SHA" ]; then
295
+ echo "::warning::Could not resolve branch SHAs for ${TGT}/main — skipping cascade this run."; return 0
296
+ fi
297
+ AHEAD=$(curl -sf -H "$AUTH" "${API}/compare/${TGT_SHA}...${MAIN_SHA}" \
298
+ | python3 -c "import sys,json; print(json.load(sys.stdin).get('total_commits',0))" 2>/dev/null || echo 0)
299
+ if [ "${AHEAD:-0}" -eq 0 ]; then
300
+ echo "${TGT} already up to date with main."; return 0
301
+ fi
302
+ echo "main is ${AHEAD} commit(s) ahead of ${TGT}."
303
+
304
+ # 2. reuse an open main->TGT PR, else create one
305
+ PR=$(curl -sf -H "$AUTH" "${API}/pulls?state=open&base=${TGT}" \
306
+ | python3 -c "import sys,json; d=json.load(sys.stdin); print(next((str(p['number']) for p in d if p.get('head',{}).get('ref')=='${SRC}'), ''))" 2>/dev/null || echo "")
307
+ if [ -z "$PR" ]; then
308
+ local RESP
309
+ RESP=$(curl -s -H "$AUTH" -H "Content-Type: application/json" -X POST "${API}/pulls" \
310
+ -d "{\"head\":\"${SRC}\",\"base\":\"${TGT}\",\"title\":\"chore(sync): cascade ${SRC} -> ${TGT}\",\"body\":\"Automated cascade of ${SRC} into ${TGT}. Auto-merges when conflict-free, auto-resolves VERSION-stamp-only conflicts, otherwise left open for manual resolution.\"}")
311
+ PR=$(printf '%s' "$RESP" | jqget number)
312
+ if [ -z "$PR" ]; then
313
+ echo "::warning::Could not open cascade PR for ${TGT}: $RESP"; return 0
314
+ fi
315
+ echo "Opened cascade PR #${PR} (${SRC} -> ${TGT})"
316
+ else
317
+ echo "Reusing open cascade PR #${PR} (${SRC} -> ${TGT})"
318
+ fi
319
+
320
+ # 3. wait for MokoGIT to compute mergeability (conflict detection)
321
+ local MERGEABLE=""
322
+ for _ in 1 2 3 4 5 6; do
323
+ MERGEABLE=$(curl -sf -H "$AUTH" "${API}/pulls/${PR}" | jqget mergeable)
324
+ case "$MERGEABLE" in True|False) break ;; esac
325
+ sleep 3
326
+ done
327
+ echo "mergeable=${MERGEABLE}"
328
+
329
+ # 4a. conflict-free -> IMMEDIATE merge via API (retry 405 "try again later";
330
+ # only fall back to merge_when_checks_succeed if genuinely refused).
331
+ if [ "$MERGEABLE" = "True" ]; then
332
+ # Ensure the head has a passing status so Do:merge lands now, not deferred.
333
+ prime_merge_status "${PR}"
334
+ local CODE=000 attempt
335
+ for attempt in $(seq 1 12); do
336
+ CODE=$(curl -s -o /tmp/merge.json -w "%{http_code}" -H "$AUTH" -H "Content-Type: application/json" \
337
+ -X POST "${API}/pulls/${PR}/merge" -d '{"Do":"merge"}')
338
+ { [ "$CODE" -ge 200 ] && [ "$CODE" -lt 300 ]; } && break
339
+ echo "::notice::merge attempt ${attempt} for cascade PR #${PR} got HTTP ${CODE}; retrying in 15s"
340
+ sleep 15
341
+ done
342
+ # Persistent 405 "Please try again later" across the whole retry loop ==
343
+ # the PR's mergeability check is wedged in Gitea's "checking" state
344
+ # (MokoGIT #1729 RC4). Recreating the PR is the ONLY recovery: close the
345
+ # wedged one, open a fresh ${SRC}->${TGT} PR, re-check mergeability, and
346
+ # retry the immediate merge ONCE on the new PR before any other fallback.
347
+ # Non-405 failures short-circuit this and fall straight through to notify.
348
+ if ! { [ "$CODE" -ge 200 ] && [ "$CODE" -lt 300 ]; } \
349
+ && [ "$CODE" = "405" ] && grep -q "Please try again later" /tmp/merge.json 2>/dev/null \
350
+ && recreate_cascade_pr; then
351
+ local MERGEABLE2=""
352
+ for _ in 1 2 3 4 5 6; do
353
+ MERGEABLE2=$(curl -sf -H "$AUTH" "${API}/pulls/${PR}" | jqget mergeable)
354
+ case "$MERGEABLE2" in True|False) break ;; esac
355
+ sleep 3
356
+ done
357
+ if [ "$MERGEABLE2" = "True" ]; then
358
+ prime_merge_status "${PR}"
359
+ for attempt in $(seq 1 12); do
360
+ CODE=$(curl -s -o /tmp/merge.json -w "%{http_code}" -H "$AUTH" -H "Content-Type: application/json" \
361
+ -X POST "${API}/pulls/${PR}/merge" -d '{"Do":"merge"}')
362
+ { [ "$CODE" -ge 200 ] && [ "$CODE" -lt 300 ]; } && break
363
+ echo "::notice::merge attempt ${attempt} for recreated cascade PR #${PR} got HTTP ${CODE}; retrying in 15s"
364
+ sleep 15
365
+ done
366
+ fi
367
+ fi
368
+ # Still not merged -> last-resort deferred merge (merge-when-checks-succeed).
369
+ if ! { [ "$CODE" -ge 200 ] && [ "$CODE" -lt 300 ]; }; then
370
+ CODE=$(curl -s -o /tmp/merge.json -w "%{http_code}" -H "$AUTH" -H "Content-Type: application/json" \
371
+ -X POST "${API}/pulls/${PR}/merge" -d '{"Do":"merge","merge_when_checks_succeed":true}')
372
+ fi
373
+ if [ "$CODE" -ge 200 ] && [ "$CODE" -lt 300 ]; then
374
+ echo "Cascade PR #${PR} merged (or scheduled to merge when checks pass)."
375
+ return 0
376
+ fi
377
+ echo "::warning::Auto-merge returned HTTP ${CODE}: $(cat /tmp/merge.json)"
378
+ notify "could not be auto-merged (HTTP ${CODE})."
379
+ return 0
380
+ fi
381
+
382
+ # 4b. conflicts -> try to auto-resolve if they are ONLY routine classes.
383
+ echo "PR not cleanly mergeable; checking whether conflicts are auto-resolvable..."
384
+ git config user.name "MokoGIT Cascade"
385
+ git config user.email "actions@mokoconsulting.tech"
386
+ # Fetch the BRANCH refs explicitly (refs/heads/...): the channel-named
387
+ # branch model (#1294) allows a tag and a branch to share a name (e.g. a
388
+ # `stable`/`rc` release tag alongside the branch), so a bare `git fetch
389
+ # origin ${SRC} ${TGT}` could resolve to the same-named TAG and poison
390
+ # origin/${TGT} + the merge below. Pin refs/heads/* into the remote-tracking
391
+ # refs so only branches are considered.
392
+ git fetch --quiet origin \
393
+ "+refs/heads/${SRC}:refs/remotes/origin/${SRC}" \
394
+ "+refs/heads/${TGT}:refs/remotes/origin/${TGT}"
395
+ git checkout -B "${TGT}" "origin/${TGT}"
396
+
397
+ if git merge --no-ff --no-commit "origin/${SRC}" >/dev/null 2>&1; then
398
+ # Became clean at git level (e.g. mergeability was still computing).
399
+ git commit -m "chore(sync): cascade ${SRC} -> ${TGT} [skip ci]" >/dev/null
400
+ if land_on_target_via_pr "Cascade merged cleanly at git level and landed on ${TGT} via PR."; then return 0; fi
401
+ notify "merged cleanly but could not be landed on ${TGT} via PR."
402
+ return 0
403
+ fi
404
+
405
+ local CONFLICTS
406
+ CONFLICTS=$(git diff --name-only --diff-filter=U)
407
+ echo "Conflicted files:"; echo "${CONFLICTS}"
408
+
409
+ # --- Inline resolver (pure bash/git, NO mokocli dependency) -------------
410
+ # * manifest <version> conflicts (*.xml with <extension) -> version =
411
+ # max(TGT, main) via `sort -V`, preserving the file's indentation.
412
+ # * .mokogit/** .gitea/** .github/** -> take main/canonical (--theirs).
413
+ # * docs / CHANGELOG / stamp files -> keep TGT (--ours).
414
+ # Anything still conflicted falls through to cascade_resolve.php then human.
415
+ resolve_manifest_version() {
416
+ local f="$1"
417
+ local nonver
418
+ nonver=$(awk '
419
+ /^<<<<<<< /{c=1;next} /^=======$/{c=2;next} /^>>>>>>> /{c=0;next}
420
+ (c==1||c==2) && $0 !~ /<version>[^<]*<\/version>/ {print "X"}
421
+ ' "$f")
422
+ [ -n "$nonver" ] && return 1
423
+ local ours theirs maxv
424
+ ours=$(awk '
425
+ /^<<<<<<< /{c=1;next} /^=======$/{c=2;next} /^>>>>>>> /{c=0;next}
426
+ c==1' "$f" | sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' | head -n1)
427
+ theirs=$(awk '
428
+ /^<<<<<<< /{c=1;next} /^=======$/{c=2;next} /^>>>>>>> /{c=0;next}
429
+ c==2' "$f" | sed -n 's/.*<version>\([^<]*\)<\/version>.*/\1/p' | head -n1)
430
+ [ -z "$ours" ] && [ -z "$theirs" ] && return 1
431
+ maxv=$(printf '%s\n%s\n' "$ours" "$theirs" | grep -v '^$' | sort -V | tail -n1)
432
+ [ -z "$maxv" ] && return 1
433
+ awk -v maxv="$maxv" '
434
+ /^<<<<<<< /{c=1;next}
435
+ /^=======$/{c=2;next}
436
+ /^>>>>>>> /{c=0;next}
437
+ {
438
+ if (c==2) next
439
+ if (($0 ~ /<version>[^<]*<\/version>/) && (c==1)) {
440
+ sub(/<version>[^<]*<\/version>/, "<version>" maxv "</version>")
441
+ }
442
+ print
443
+ }' "$f" > "${f}.resolved" && mv "${f}.resolved" "$f"
444
+ ! grep -qE '^(<<<<<<< |=======$|>>>>>>> )' "$f"
445
+ }
446
+
447
+ # resolve_makefile_version: auto-resolve a Makefile conflict whose ONLY
448
+ # conflicting content is the PROJECT_VERSION assignment, taking
449
+ # version = max(TGT, main) via `sort -V`. This closes the strand-dev gap:
450
+ # a version change that reaches the production branch out of band (e.g. a
451
+ # hotfix bump made directly on stable, bypassing bump-on-dev) can now
452
+ # cascade back to dev instead of parking an unresolved conflict.
453
+ resolve_makefile_version() {
454
+ local f="$1"
455
+ local nonver
456
+ nonver=$(awk '
457
+ /^<<<<<<< /{c=1;next} /^=======$/{c=2;next} /^>>>>>>> /{c=0;next}
458
+ (c==1||c==2) && $0 !~ /^[ \t]*PROJECT_VERSION[ \t]*[:?]*=/ {print "X"}
459
+ ' "$f")
460
+ [ -n "$nonver" ] && return 1
461
+ local ours theirs maxv
462
+ ours=$(awk '
463
+ /^<<<<<<< /{c=1;next} /^=======$/{c=2;next} /^>>>>>>> /{c=0;next}
464
+ c==1' "$f" | sed -n 's/^[ \t]*PROJECT_VERSION[ \t]*[:?]*=[ \t]*\([0-9][0-9.]*\).*/\1/p' | head -n1)
465
+ theirs=$(awk '
466
+ /^<<<<<<< /{c=1;next} /^=======$/{c=2;next} /^>>>>>>> /{c=0;next}
467
+ c==2' "$f" | sed -n 's/^[ \t]*PROJECT_VERSION[ \t]*[:?]*=[ \t]*\([0-9][0-9.]*\).*/\1/p' | head -n1)
468
+ [ -z "$ours" ] && [ -z "$theirs" ] && return 1
469
+ maxv=$(printf '%s\n%s\n' "$ours" "$theirs" | grep -v '^$' | sort -V | tail -n1)
470
+ [ -z "$maxv" ] && return 1
471
+ awk -v maxv="$maxv" '
472
+ /^<<<<<<< /{c=1;next}
473
+ /^=======$/{c=2;next}
474
+ /^>>>>>>> /{c=0;next}
475
+ {
476
+ if (c==2) next
477
+ if (($0 ~ /^[ \t]*PROJECT_VERSION[ \t]*[:?]*=/) && (c==1)) {
478
+ sub(/[0-9][0-9.]*[0-9]/, maxv)
479
+ }
480
+ print
481
+ }' "$f" > "${f}.resolved" && mv "${f}.resolved" "$f"
482
+ ! grep -qE '^(<<<<<<< |=======$|>>>>>>> )' "$f"
483
+ }
484
+
485
+ # resolve_json_version: same idea for a package.json conflict whose ONLY
486
+ # conflicting content is the top-level "version" field -> version = max.
487
+ resolve_json_version() {
488
+ local f="$1"
489
+ local nonver
490
+ nonver=$(awk '
491
+ /^<<<<<<< /{c=1;next} /^=======$/{c=2;next} /^>>>>>>> /{c=0;next}
492
+ (c==1||c==2) && $0 !~ /"version"[ \t]*:/ {print "X"}
493
+ ' "$f")
494
+ [ -n "$nonver" ] && return 1
495
+ local ours theirs maxv
496
+ ours=$(awk '
497
+ /^<<<<<<< /{c=1;next} /^=======$/{c=2;next} /^>>>>>>> /{c=0;next}
498
+ c==1' "$f" | sed -n 's/.*"version"[ \t]*:[ \t]*"\([^"]*\)".*/\1/p' | head -n1)
499
+ theirs=$(awk '
500
+ /^<<<<<<< /{c=1;next} /^=======$/{c=2;next} /^>>>>>>> /{c=0;next}
501
+ c==2' "$f" | sed -n 's/.*"version"[ \t]*:[ \t]*"\([^"]*\)".*/\1/p' | head -n1)
502
+ [ -z "$ours" ] && [ -z "$theirs" ] && return 1
503
+ maxv=$(printf '%s\n%s\n' "$ours" "$theirs" | grep -v '^$' | sort -V | tail -n1)
504
+ [ -z "$maxv" ] && return 1
505
+ awk -v maxv="$maxv" '
506
+ /^<<<<<<< /{c=1;next}
507
+ /^=======$/{c=2;next}
508
+ /^>>>>>>> /{c=0;next}
509
+ {
510
+ if (c==2) next
511
+ if (($0 ~ /"version"[ \t]*:/) && (c==1)) {
512
+ sub(/"[0-9][^"]*"/, "\"" maxv "\"")
513
+ }
514
+ print
515
+ }' "$f" > "${f}.resolved" && mv "${f}.resolved" "$f"
516
+ ! grep -qE '^(<<<<<<< |=======$|>>>>>>> )' "$f"
517
+ }
518
+
519
+ local f
520
+ for f in ${CONFLICTS}; do
521
+ case "$f" in
522
+ .mokogit/*|.gitea/*|.github/*)
523
+ git checkout --theirs -- "$f" && git add -- "$f" \
524
+ && echo "resolved (theirs/main): $f" ;;
525
+ *CHANGELOG*|*/docs/*|docs/*|*.md|*VERSION*|*.stamp)
526
+ git checkout --ours -- "$f" && git add -- "$f" \
527
+ && echo "resolved (ours/${TGT}): $f" ;;
528
+ *.xml)
529
+ if grep -q '<extension' "$f" 2>/dev/null && resolve_manifest_version "$f"; then
530
+ git add -- "$f"; echo "resolved (manifest version=max): $f"
531
+ else
532
+ echo "unresolved (xml, not a simple version stamp): $f"
533
+ fi ;;
534
+ Makefile|*/Makefile)
535
+ if resolve_makefile_version "$f"; then
536
+ git add -- "$f"; echo "resolved (Makefile PROJECT_VERSION=max): $f"
537
+ else
538
+ echo "unresolved (Makefile, not a simple version bump): $f"
539
+ fi ;;
540
+ package.json|*/package.json)
541
+ if resolve_json_version "$f"; then
542
+ git add -- "$f"; echo "resolved (package.json version=max): $f"
543
+ else
544
+ echo "unresolved (package.json, not a simple version bump): $f"
545
+ fi ;;
546
+ composer.json|*/composer.json)
547
+ if resolve_json_version "$f"; then
548
+ git add -- "$f"; echo "resolved (composer.json version=max): $f"
549
+ else
550
+ echo "unresolved (composer.json, not a simple version bump): $f"
551
+ fi ;;
552
+ *)
553
+ echo "unresolved (needs deeper resolver): $f" ;;
554
+ esac
555
+ done
556
+
557
+ # If the inline pass cleared every conflict, land it -- no mokocli needed.
558
+ if [ -z "$(git diff --name-only --diff-filter=U)" ]; then
559
+ git commit -m "chore(sync): cascade ${SRC} -> ${TGT} (auto-resolved inline) [skip ci]" >/dev/null
560
+ if land_on_target_via_pr "Cascade auto-resolved inline (manifest version=max(${TGT},main), .mokogit/.gitea/.github -> main, docs/stamps -> ${TGT}) with no mokocli dependency, and landed on ${TGT} via PR."; then return 0; fi
561
+ notify "was auto-resolved inline but could not be landed on ${TGT} via PR."
562
+ return 0
563
+ fi
564
+
565
+ echo "Inline pass left conflicts; falling back to cascade_resolve.php..."
566
+ echo "Still conflicted:"; git diff --name-only --diff-filter=U
567
+
568
+ # --- Fallback: cascade_resolve.php (existing behaviour) -----------------
569
+ # BAILS (exit 2) on real code conflicts, so those still get a human.
570
+ # Standard mokocli install: FRESH from the MokoGIT Composer registry
571
+ # (PRIMARY); /opt/mokocli is a FAILSAFE only (may be stale). Registry path
572
+ # /api/packages/** is anon-allowlisted. git clone is the last resort.
573
+ local RESOLVER=""
574
+ local REG='{"type":"composer","url":"https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer"}'
575
+ if ! command -v composer >/dev/null 2>&1; then
576
+ sudo apt-get update -qq && sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1 || true
577
+ fi
578
+ rm -rf /tmp/mokocli
579
+ if command -v composer >/dev/null 2>&1 \
580
+ && composer create-project --no-install --no-interaction --no-progress --quiet \
581
+ --repository="${REG}" mokoconsulting/mokocli /tmp/mokocli >/dev/null 2>&1 \
582
+ && [ -f /tmp/mokocli/cli/cascade_resolve.php ]; then
583
+ RESOLVER=/tmp/mokocli/cli/cascade_resolve.php
584
+ if command -v sudo >/dev/null 2>&1 && [ -d /opt/mokocli ] \
585
+ && ! cmp -s /tmp/mokocli/cli/version_reset_dev.php /opt/mokocli/cli/version_reset_dev.php 2>/dev/null; then
586
+ sudo cp -a /tmp/mokocli/. /opt/mokocli/ 2>/dev/null || true
587
+ fi
588
+ elif [ -f /opt/mokocli/cli/cascade_resolve.php ]; then
589
+ echo "::warning::registry install failed — using /opt/mokocli FAILSAFE cascade_resolve.php (may be stale)"
590
+ RESOLVER=/opt/mokocli/cli/cascade_resolve.php
591
+ elif git clone --depth 1 --quiet \
592
+ "https://x-access-token:${MOKOGIT_TOKEN}@${MOKOGIT_URL#https://}/MokoConsulting/mokocli.git" \
593
+ /tmp/mokocli 2>/dev/null \
594
+ || git clone --depth 1 --quiet "${MOKOGIT_URL}/MokoConsulting/mokocli.git" /tmp/mokocli 2>/dev/null; then
595
+ RESOLVER=/tmp/mokocli/cli/cascade_resolve.php
596
+ else
597
+ echo "::warning::On-demand mokocli install failed; cascade_resolve.php is UNAVAILABLE. Inline resolver handled the routine classes; any remaining conflicts need a human."
598
+ RESOLVER=/tmp/mokocli/cli/cascade_resolve.php
599
+ fi
600
+
601
+ if [ -f "$RESOLVER" ] && php "$RESOLVER" --path . --theirs-ref "origin/${SRC}"; then
602
+ git commit -m "chore(sync): cascade ${SRC} -> ${TGT} (auto-resolved) [skip ci]" >/dev/null
603
+ if land_on_target_via_pr "cascade_resolve auto-resolved the merge (manifests/workflows/docs/stamps, modify-delete) and landed on ${TGT} via PR."; then return 0; fi
604
+ notify "was auto-resolved but could not be landed on ${TGT} via PR."
605
+ return 0
606
+ fi
607
+
608
+ # Resolver unavailable, or it bailed on a real (code) conflict -> human.
609
+ git merge --abort || true
610
+ notify "has code conflicts cascade_resolve could not safely auto-resolve."
611
+ return 0
612
+ }
613
+
614
+ # Cascade main into every ladder target that exists, high -> low.
615
+ RC=0
616
+ for TGT in ${CASCADE_TARGETS}; do
617
+ echo "::group::cascade ${SRC} -> ${TGT}"
618
+ cascade_to "${TGT}" || { echo "::warning::cascade_to ${TGT} returned non-zero"; RC=1; }
619
+ echo "::endgroup::"
620
+ done
621
+
622
+ # R4 (MokoCLI #967): optional SECOND pass -- cascade dev into its child
623
+ # BRANCHES. Strict no-op unless DEV_CHILD_TARGETS is set (defaults empty),
624
+ # so no existing repo changes behaviour. Repoint SRC to `dev` so the same
625
+ # cascade_to/helpers flow dev -> each child; guarded by dev existing. A
626
+ # child named `dev` would be self-referential, so it is skipped. dev ->
627
+ # child REPOS is the SYNC engine's job and is intentionally not done here.
628
+ if [ -n "${DEV_CHILD_TARGETS// /}" ]; then
629
+ if branch_exists dev; then
630
+ SRC="dev"
631
+ echo "Dev-child cascade source branch: ${SRC}; targets: ${DEV_CHILD_TARGETS}"
632
+ for TGT in ${DEV_CHILD_TARGETS}; do
633
+ if [ "${TGT}" = "dev" ]; then
634
+ echo "Skipping self-referential dev-child target 'dev'."; continue
635
+ fi
636
+ echo "::group::cascade ${SRC} -> ${TGT}"
637
+ cascade_to "${TGT}" || { echo "::warning::cascade_to ${TGT} returned non-zero"; RC=1; }
638
+ echo "::endgroup::"
639
+ done
640
+ else
641
+ echo "DEV_CHILD_TARGETS set but no dev branch - skipping dev-child cascade."
642
+ fi
643
+ fi
644
+
645
+ exit "${RC}"