@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,1134 @@
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.Release
8
+ # REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
9
+ # PATH: /.mokogit/workflows/pre-release.yml
10
+ # VERSION: 07.15.00
11
+ # CHANGE (07.15.00): "Vendor PHP dependencies (build-time)" is no longer a silent no-op on the release runners. The step existed since 07.xx but EVERY vendored build was failing invisibly: the runners' php-cli ships WITHOUT ext-gd, so composer could not RESOLVE mpdf/mpdf at all ("mpdf/mpdf require ext-gd * -> it is missing from your system"), and the step's continue-on-error + `|| warning` fail-soft swallowed that into a green build — the zip then shipped WITHOUT admin/vendor while the component manifest lists <folder>vendor</folder>, so JInstaller aborts the ENTIRE package install on-site ("File does not exist [ROOT][TMP]/.../admin/vendor" → "Failed to copy administrator files"; reproduced on MokonyxCRMDCM + MokonyxCRM dev installs). Fix: (1) provision ext-gd when missing before resolving; (2) verify the package's CONFIGURED vendor-dir (composer config vendor-dir, e.g. admin/vendor) actually materialized autoload.php; (3) when vendoring fails for a package whose Joomla manifest ships <folder>vendor</folder>, FAIL the build loud (::error:: + exit 1, continue-on-error removed) — a broken artifact that bricks installs is strictly worse than a red build. Packages whose manifests do NOT reference vendor/ keep the old fail-soft warning, and repos with no sub-package composer.json are a clean no-op, so non-vendoring repos are 100% unaffected. PREVIOUS (07.14.00): #469 — gate the dev-channel version BUMP on whether the push touched the Makefile SRC_DIR. The former non-code/test paths-ignore denylist on the dev push trigger is REMOVED: a docs/workflow/sample/test-only push no longer skips the build, it now RUNS in no-bump "update the current rolling dev release in place" mode. A new step "Resolve SRC_DIR bump gate (#469)" (id: srcgate) mirrors auto-release.yml: (1) electron (file-detected) or opt-out vars.RELEASE_SRCDIR_GATE=off -> bypass/always-bump; (2) else SRC_DIR REQUIRED, missing -> fail loud; (3) else diff before..sha (HEAD^1 fallback) ∩ ${SRC_DIR}/ -> should_bump. The per-merge dev-base patch bump ("Advance dev base") is now gated on should_bump!='false', so a non-source push keeps the dev base PUT and the rest of the job re-issues the existing rolling dev release. Loop-safety unchanged (job if: still re-checks [skip ci]/[skip bump]). Fleet-wide release-semantics change. PREVIOUS (07.13.00): the dev-base-bump and checkout-fallback steps now name the per-run token env var GITHUB_TOKEN (was the misleadingly-named MOKOGIT_TOKEN; the VALUE was always ${{ github.token }}). Same-repo writes (the dev base bump) use the built-in per-run github.token with contents:write — NEVER the org MOKOGIT_TOKEN secret, which this workflow does not reference at all. Naming-only; no behavior change. (The earlier "it's an org secret" framing was wrong — MokoGIT#1721.) PREVIOUS (07.12.00): "Advance dev base (per-merge patch bump)" now FAILS the step (::error:: + exit 1) when release_bump_dev reports failure (0 version files stamped), instead of the old ::warning:: + exit 0. A stalled bump (e.g. a repo-side Contents-API HTTP 500 on release_bump_dev's temp-branch write) was silently re-cutting the SAME dev version on every merge — for ~10 merges on MokonyxTickets — so the Joomla update feed stayed at the installed version and NO update was ever offered, invisibly, because the step stayed green. Failing loud turns the pre-release RED so the stall is caught immediately. The GITHUB_TOKEN-unset and #1412 "already advanced" branches keep their graceful exit 0 (not failures). See MokoGIT#1721.
12
+ # BRIEF: NEW (07.15.00): build-time PHP vendoring is now LOUD and actually works on the runners — ext-gd provisioned (mpdf resolution failed without it), configured vendor-dir verified (autoload.php), and a vendoring failure for a package whose manifest ships <folder>vendor</folder> FAILS the build (continue-on-error removed) instead of green-shipping a zip that bricks the whole package install on JInstaller "File does not exist .../admin/vendor" (MokonyxCRMDCM/MokonyxCRM). PREVIOUS: NEW (07.13.00): dev-base-bump + checkout-fallback token env var renamed MOKOGIT_TOKEN → GITHUB_TOKEN (value unchanged = ${{ github.token }}; same-repo writes use the per-run github.token, never the org secret — naming clarity, MokoGIT#1721). PREVIOUS: NEW (07.12.00): the per-merge dev base bump now fails the pre-release (::error:: + exit 1) on a 0-file stamp instead of silently re-cutting the current version (MokoGIT#1721 — a stalled bump hid for ~10 merges on MokonyxTickets). PREVIOUS: NEW (07.11.00): Setup MokoCLI tools no longer installs a STALE mokocli during the registry publish-lag window (#1671). After the fresh registry install, the step compares the installed /tmp/mokocli PROJECT_VERSION against the /opt/mokocli failsafe and PREFERS whichever is NEWER (sort -V); the opportunistic /opt refresh is gated to only move FORWARD (never downgrade /opt when it is the newer, just-deployed copy). Backward-compatible: runners with no /opt/mokocli use the registry copy exactly as before. PREVIOUS (07.09.00): the "Cascade alpha + beta on base-version bump" step is REMOVED entirely (was opt-in via vars.PRERELEASE_CASCADE in 07.08.01). A dev push now cuts ONLY the development channel; alpha, beta and rc are ON-DEMAND ONLY — cut solely via manual workflow_dispatch (stability=alpha|beta|release-candidate). This is the durable fix: no repo/org var can re-enable an automatic alpha/beta cut. PREVIOUS (07.08.01): the "Cascade alpha + beta on base-version bump" step was OPT-IN via vars.PRERELEASE_CASCADE=true (default OFF). A dev push cuts ONLY the development channel; alpha/beta/rc come solely from manual workflow_dispatch. Previously alpha+beta auto-re-cut on every committed base bump, making Joomla offer the -beta build even to Dev-channel sites. NEW (07.08.00): electron repos now create NO release and build NO installer in CI on ANY channel (publish forced off for electron; opt back in with vars.ELECTRON_CI_BUILD=true) — always built locally + uploaded manually; the version BUMP still runs. Template-Generic #390. NEW (07.07.00): the electron installer build/attach steps (electron-builder via Wine/Xvfb) are gated to MANUAL workflow_dispatch only (`&& github.event_name == 'workflow_dispatch'`). A dev push no longer auto-builds electron installers (heavy ~90m, runner-fragile — OOM/Wine-zombie; migrating to mokocli ElectronPackager, MokoCLI #756/#758). The per-merge dev base bump ("Advance dev base") and "Resolve metadata and bump version" STILL run on push — only the electron installer is skipped and pushed MANUALLY via dispatch. Non-electron repos unaffected. Template-Generic #390. Pre-release on dev push + manual dispatch (rolling dev/rc asset, build-metadata version — #404/#1313: the build counter is folded into the PATCH with a single -<stability> suffix and NO dotted ".N" — the retired ".N" counter broke Joomla's updater fleet-wide, see the "Resolve metadata and bump version" step); non-Joomla MokoGIT release + cosmetic release-notes are best-effort. npm publishing is ABSORBED here (07.05.00) and CORRECTED (07.06.00): dev/alpha/beta/rc builds publish via plain `npm publish` (dist-tagged by channel) to the MokoGIT npm registry ONLY (per-run token; public npmjs is stable-only), is_npm-gated so non-npm repos are unaffected. The prior `moko publish` variant only made a git release (mokocli has no npm-publish verb), so dev builds never reached a registry. The dev push trigger skips [skip ci]/[skip bump] commits. NEW (06.04.00): on a dev push that BUMPS the committed base version, alpha + beta are re-cut at the same base so the whole pre-release ladder (dev/alpha/beta, plus rc/stable via auto-release) stays in lockstep — previously alpha/beta were cut only by manual dispatch and froze. Joomla-family + push only; additive, isolated, continue-on-error; single job (no matrix) to keep the fleet wave small. (Ported from Template-Joomla 06.06.00 — same step, `moko package` variant.) NEW (07.00.00): ladder-bootstrap step removed (channel ladder no longer auto-created here); `moko assets:optimize` artifact-time step added before packaging (graceful-skip on non-asset repos); Setup MokoCLI tools makes the fresh `moko` executable + first on PATH to fix exit-126 (mokocli #650). NEW (07.02.00): versioning comments corrected to the current scheme (MokoGIT #1313 — dev/rc patch-bump the base, single suffix, no dotted .N counter). CONSEQUENCE of re-cutting alpha/beta/dev at the SAME base: Joomla keeps the highest-precedence variant of a given base (beta > alpha > dev), so a site — even at minimum-stability Dev — is offered the -beta build of the newest base, not -dev. To pin a site to the -dev line, don't publish alpha/beta at that base (or point the site's update channel at dev), NOT by lowering minimum-stability (Dev is already the most permissive floor).
13
+
14
+ name: "Universal: Pre-Release"
15
+
16
+ # #404 — moved OFF every-push to a nightly schedule + manual dispatch. Building a
17
+ # dev/rc pre-release on every push (and committing a version bump back) caused
18
+ # manifest <version> merge conflicts and wasted CI builds + void versioned dev
19
+ # assets. Batching to nightly removes most of that load; the version now advances
20
+ # as BUILD METADATA (version_build_metadata.php) instead of a committed bump, and
21
+ # the asset is published under a FIXED rolling name replaced in place.
22
+ on:
23
+ # Dev-channel release on every merge to dev. Uses build-metadata versioning
24
+ # (version_build_metadata.php) so nothing is committed back to dev — the #404
25
+ # manifest <version> merge-conflict problem does NOT return. The concurrency guard
26
+ # below collapses a burst of merges into one.
27
+ #
28
+ # #469: the former non-code paths-ignore denylist (which SKIPPED docs / CI config /
29
+ # test-only / repo-metadata pushes entirely) is REMOVED. Such a push must now RUN
30
+ # the pipeline in no-bump "update the current rolling dev release in place" mode
31
+ # rather than be dropped: the in-job SRC_DIR-diff gate (steps.srcgate below) decides
32
+ # bump (push touched ${SRC_DIR}/) vs no-bump (it did not). Loop-safety is unchanged
33
+ # and fully covered by the job if: [skip ci]/[skip bump] re-checks, so the
34
+ # per-merge dev-base bump's own [skip ci] commit never re-triggers this workflow.
35
+ push:
36
+ branches:
37
+ - dev
38
+ workflow_dispatch:
39
+ inputs:
40
+ stability:
41
+ description: 'Pre-release channel'
42
+ required: true
43
+ default: development
44
+ type: choice
45
+ options:
46
+ - development
47
+ - alpha
48
+ - beta
49
+ - release-candidate
50
+ ref:
51
+ description: 'Branch to build (default: dev)'
52
+ required: false
53
+ default: dev
54
+ type: string
55
+
56
+ concurrency:
57
+ # Per channel + ref. A manual alpha/beta/rc dispatch has its own group, so it is
58
+ # never cancelled by a dev push (and vice versa); the nightly runs on the
59
+ # default-branch ref, a different group again.
60
+ #
61
+ # cancel-in-progress is TRUE: the release-runner fleet is capacity-constrained and
62
+ # cannot be scaled, and a full electron build is ~90 min, so a BURST of dev merges
63
+ # must not each run a serial build (that backs the runner up for hours). A
64
+ # superseding build cancels the older one and builds only the latest state.
65
+ # Safe for the pre-release channels: they publish to a ROLLING, last-write-wins
66
+ # tag, so a cancelled build's publish is simply re-done by the winning (latest)
67
+ # build; real STABLE releases go through auto-release.yml, which keeps its own
68
+ # serialized safety (cancel-in-progress:false there). Trade-off: a
69
+ # concurrency-CANCELLED run marks the commit combined status red on Gitea, but
70
+ # that status is cosmetic (CI is non-gating for merges) and is always superseded
71
+ # by the winning build green. The ~150x sync storm stays prevented by the
72
+ # paths-ignore denylist + the job-level ref gate.
73
+ group: pre-release-${{ github.ref }}-${{ inputs.stability || 'development' }}
74
+ cancel-in-progress: true
75
+
76
+ permissions:
77
+ contents: write
78
+ packages: write
79
+ pull-requests: write
80
+
81
+ env:
82
+ GIT_URL: ${{ vars.MOKOGIT_URL || 'https://git.mokoconsulting.tech' }}
83
+ GIT_ORG: ${{ vars.MOKOGIT_ORG || github.repository_owner }}
84
+ GIT_REPO: ${{ vars.MOKOGIT_REPO || github.event.repository.name }}
85
+
86
+ jobs:
87
+ build:
88
+ name: "Build Pre-Release (${{ inputs.stability || 'development' }})"
89
+ # LITERAL runner label — do NOT use ${{ vars.RELEASE_RUNNER_LABEL || 'release' }}.
90
+ # This MokoGIT/Gitea instance does not resolve the `|| 'release'` fallback for an
91
+ # unset var in the `runs-on` position, so the job gets no runner and aborts at the
92
+ # container level (every step, incl. step-0, marked failure). Runners are correct;
93
+ # the only registered release runner label is `release`. See #366 (regression) and
94
+ # #134 (same class: runs-on ubuntu-latest -> no runner).
95
+ runs-on: release
96
+ # Bound a hung/starved build so it can't squat the scarce release runner for
97
+ # hours (the act_runner default let stuck builds run ~3h before failing). A
98
+ # healthy package/electron build is well under this; anything past it is
99
+ # stuck and should fail fast to free the runner. Repos with a legitimately
100
+ # longer build override via the PRERELEASE_BUILD_TIMEOUT repo variable.
101
+ timeout-minutes: ${{ fromJSON(vars.PRERELEASE_BUILD_TIMEOUT || '90') }}
102
+ # Skip on template repos (Template-*) — they scaffold other repos and do not release.
103
+ # Triggers: push to dev (per-merge dev-channel build), the nightly schedule
104
+ # (safety-net batch), and manual workflow_dispatch. The push trigger is scoped
105
+ # to `dev` + a non-code paths-ignore, so a template-sync/doc-only push can't
106
+ # reach it (the ~150x storm on chore/template-sync-* stays prevented).
107
+ # As defence-in-depth we also refuse a manual dispatch aimed at a bot/sync ref.
108
+ if: >-
109
+ github.event.repository.name != '.vault' &&
110
+ (
111
+ (
112
+ github.event_name == 'push' &&
113
+ !contains(github.event.head_commit.message, '[skip ci]') &&
114
+ !contains(github.event.head_commit.message, '[skip bump]')
115
+ ) ||
116
+ github.event_name == 'schedule' ||
117
+ (
118
+ github.event_name == 'workflow_dispatch' &&
119
+ !startsWith(inputs.ref, 'chore/') &&
120
+ !startsWith(inputs.ref, 'sync/') &&
121
+ !startsWith(inputs.ref, 'bot/') &&
122
+ !startsWith(inputs.ref, 'template-sync')
123
+ )
124
+ )
125
+
126
+ steps:
127
+ # Build ref: the dispatch input if given, else `dev` — which is correct for
128
+ # both a push (only `dev` triggers it) and the nightly schedule (where
129
+ # github.ref_name would otherwise be the default branch, main).
130
+ - name: Resolve build ref
131
+ id: ref
132
+ run: echo "ref=${{ inputs.ref || 'dev' }}" >> "$GITHUB_OUTPUT"
133
+
134
+ - name: Checkout
135
+ uses: actions/checkout@v4
136
+ with:
137
+ fetch-depth: 0
138
+ token: ${{ github.token }}
139
+ ref: ${{ steps.ref.outputs.ref }}
140
+ submodules: false
141
+
142
+ # #1672: transient step-0 checkout aborts on the shared `release` runners under
143
+ # release-time load. This fallback fires ONLY when the checkout above failed
144
+ # (step-level `if: failure()` — a LITERAL, not a discarded continue-on-error
145
+ # expression) and re-materialises the workspace via a bounded 3-attempt git
146
+ # clone + backoff, checking out the SAME resolved build ref
147
+ # (steps.ref.outputs.ref) the checkout targeted. fetch-depth: 0 is preserved
148
+ # with a FULL clone (no --depth) + tags (the bump/version steps below need
149
+ # history + tags). Fails loudly (exit 1) after 3 attempts so a real breakage is
150
+ # never masked. Independent step — no needs:+always() fan-in.
151
+ - name: "Checkout fallback (retry transient flake) (#1672)"
152
+ if: failure()
153
+ env:
154
+ GITHUB_TOKEN: ${{ github.token }}
155
+ BUILD_REF: ${{ steps.ref.outputs.ref }}
156
+ run: |
157
+ set -u
158
+ TOK="$(printf '%s' "$GITHUB_TOKEN" | tr -d '[:space:]')"; echo "::add-mask::$TOK"
159
+ SERVER="${GITHUB_SERVER_URL:-https://git.mokoconsulting.tech}"
160
+ CLONE_URL="https://x-access-token:${TOK}@${SERVER#https://}/${GITHUB_REPOSITORY}.git"
161
+ REF="${BUILD_REF:-${GITHUB_SHA:-}}"
162
+ echo "Retrying full-history checkout of ${GITHUB_REPOSITORY}@${REF:-<HEAD>} into ${GITHUB_WORKSPACE}"
163
+ ok=0
164
+ for attempt in 1 2 3; do
165
+ rm -rf "${GITHUB_WORKSPACE:?}/.git" 2>/dev/null || true
166
+ find "${GITHUB_WORKSPACE}" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true
167
+ if git clone "$CLONE_URL" "${GITHUB_WORKSPACE}" \
168
+ && git -C "${GITHUB_WORKSPACE}" fetch --tags origin \
169
+ && { [ -z "$REF" ] || git -C "${GITHUB_WORKSPACE}" checkout -f "$REF"; }; then
170
+ ok=1; echo "Fallback checkout succeeded on attempt ${attempt}."; break
171
+ fi
172
+ echo "::warning::Fallback checkout attempt ${attempt} failed; backing off $((attempt * 5))s"
173
+ sleep $((attempt * 5))
174
+ done
175
+ if [ "$ok" != "1" ]; then
176
+ echo "::error::Checkout failed after 3 retry attempts (#1672) — this is not a transient flake, failing loudly."
177
+ exit 1
178
+ fi
179
+
180
+ - name: Update submodules to latest
181
+ # Skip cleanly on submodule-less repos. The old step was a silent no-op:
182
+ # `2>/dev/null || true` swallowed every error and the in-step `git pull`
183
+ # used an UN-credentialed remote, so private submodules never actually
184
+ # updated. Credential the private remote, then update each submodule to its
185
+ # tracked branch (default: the submodule's default branch) so the built
186
+ # package contains the latest submodule content. Per #404 this is
187
+ # working-tree only (no commit) — the package is built from the working tree.
188
+ env:
189
+ GITHUB_TOKEN: ${{ github.token }}
190
+ run: |
191
+ if [ -f .gitmodules ]; then
192
+ # Mask the token (trim + ::add-mask::) before embedding it in the git
193
+ # config URL rewrite — matches the checkout-fallback step's hygiene and
194
+ # keeps the derived value out of logs (#1840). --global is required so the
195
+ # rewrite reaches the submodule fetch subprocesses.
196
+ TOK="$(printf '%s' "$GITHUB_TOKEN" | tr -d '[:space:]')"; echo "::add-mask::$TOK"
197
+ git config --global url."https://x-access-token:${TOK}@git.mokoconsulting.tech/".insteadOf "https://git.mokoconsulting.tech/"
198
+ git submodule sync --recursive || true
199
+ # FAIL-OPEN (#430): the per-run Actions token is scoped to THIS repo, so a
200
+ # cross-repo submodule can return "Repository not found" and abort the whole
201
+ # release (seen: MokonyxCRM addons/* -> pre-release red). Warn loudly but do
202
+ # NOT red the build — the package is built from the working tree, and any
203
+ # accessible submodules still update.
204
+ git submodule update --init --remote --recursive \
205
+ || echo "::warning::submodule update failed (inaccessible/optional cross-repo submodule; run token may lack read). Building from the working tree without it — see Template-Generic #430."
206
+ else
207
+ echo "No .gitmodules — skipping submodule update."
208
+ fi
209
+
210
+ - name: Isolate workspace (remove foreign/stale checkouts)
211
+ run: |
212
+ find . -name .git -not -path './.git' -prune -print | while read nested; do
213
+ dir=$(dirname "$nested")
214
+ [ "$dir" = "." ] && continue
215
+ git ls-files --error-unmatch "$dir" >/dev/null 2>&1 && continue
216
+ echo "::warning::Removing untracked nested checkout: $dir"
217
+ rm -rf "$dir"
218
+ done
219
+
220
+ - name: Setup MokoCLI tools
221
+ run: |
222
+ # Install mokocli FRESH from the MokoGIT Composer registry on every run
223
+ # (PRIMARY) so CI always uses the latest published version. /opt/mokocli is
224
+ # a FAILSAFE only — its refresh unit (mokocli-sync.service) is disabled, so
225
+ # the on-disk snapshot rots and must NOT be preferred. Registry path
226
+ # /api/packages/** is anon-allowlisted (no MOKOGIT_TOKEN); it serves a dist
227
+ # zip (no git clone). --no-install: mokocli self-bootstraps via bundled lib/
228
+ # + a fallback PSR-4 autoloader (native ext-curl), so no vendor/ is needed.
229
+ if ! command -v composer > /dev/null 2>&1; then
230
+ 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
231
+ fi
232
+ rm -rf /tmp/mokocli
233
+ REG='{"type":"composer","url":"https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer"}'
234
+ if composer create-project --no-install --no-interaction --no-progress --quiet \
235
+ --repository="${REG}" mokoconsulting/mokocli /tmp/mokocli >/dev/null 2>&1 \
236
+ && [ -f /tmp/mokocli/cli/version_bump.php ]; then
237
+ echo "Installed mokocli fresh from the Composer registry (primary)"
238
+ # #1671: registry-publish LAG race. The registry install "succeeds" but can
239
+ # serve the PREVIOUS package for ~tens of seconds after a MokoCLI release
240
+ # (publish finishes AFTER the release run reports success). Meanwhile the
241
+ # /opt/mokocli deploy (pull-timer) may already carry the just-released,
242
+ # NEWER engine. So instead of blindly using the registry copy, compare the
243
+ # two on-disk versions and PREFER whichever is newer. The Makefile
244
+ # PROJECT_VERSION is the version source of truth and ships in both the
245
+ # composer dist and /opt/mokocli. Backward-compatible: with no /opt/mokocli
246
+ # (most consumer runners), the registry copy is used exactly as before.
247
+ mk_ver() { sed -n 's/^[[:space:]]*PROJECT_VERSION[[:space:]]*:*=[[:space:]]*//p' "$1" 2>/dev/null | head -n1 | tr -d '[:space:]'; }
248
+ ver_newer() { [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ]; }
249
+ REG_VER="$(mk_ver /tmp/mokocli/Makefile)"
250
+ OPT_VER="$(mk_ver /opt/mokocli/Makefile)"
251
+ MOKO_ROOT=/tmp/mokocli
252
+ if [ -f /opt/mokocli/cli/version_bump.php ] && [ -n "$OPT_VER" ] && [ -n "$REG_VER" ] \
253
+ && ver_newer "$OPT_VER" "$REG_VER"; then
254
+ echo "::notice::/opt/mokocli (${OPT_VER}) is newer than the registry copy (${REG_VER}) — using /opt (#1671 publish-lag race)."
255
+ MOKO_ROOT=/opt/mokocli
256
+ else
257
+ echo "Using registry copy (${REG_VER:-unknown}); /opt/mokocli=${OPT_VER:-none}"
258
+ fi
259
+ echo "MOKO_CLI=${MOKO_ROOT}/cli" >> $GITHUB_ENV
260
+ # #650: make the chosen moko executable + FIRST on PATH so bare `moko`
261
+ # never resolves to a stale/non-executable copy (exit 126).
262
+ for d in bin cli; do
263
+ if [ -f "${MOKO_ROOT}/$d/moko" ]; then
264
+ # #1263 defense-in-depth: strip CR bytes so a CRLF-tainted registry
265
+ # dist or rotted /opt snapshot can't yield `/usr/bin/env: 'php\r'`
266
+ # (exit 127). tr interprets \r as a real CR (unlike portable sed).
267
+ # /opt may be root-owned — write via a temp file + sudo when needed.
268
+ if tr -d '\r' < "${MOKO_ROOT}/$d/moko" > "/tmp/moko.lf" 2>/dev/null; then
269
+ if cp "/tmp/moko.lf" "${MOKO_ROOT}/$d/moko" 2>/dev/null; then :; \
270
+ else sudo cp "/tmp/moko.lf" "${MOKO_ROOT}/$d/moko" 2>/dev/null || true; fi
271
+ rm -f "/tmp/moko.lf"
272
+ fi
273
+ chmod +x "${MOKO_ROOT}/$d/moko" 2>/dev/null || sudo chmod +x "${MOKO_ROOT}/$d/moko" 2>/dev/null || true
274
+ echo "${MOKO_ROOT}/$d" >> "$GITHUB_PATH"
275
+ fi
276
+ done
277
+ sudo chmod +x /opt/mokocli/*/moko 2>/dev/null || true
278
+ # Opportunistically refresh the /opt failsafe from this fresh copy when it
279
+ # differs — keeps the disabled-sync snapshot current so the fallback is
280
+ # never far behind. Best-effort: needs sudo, gated on a diff to avoid
281
+ # fleet-wide churn, never fails the job. #1671: only refresh FORWARD — skip
282
+ # when /opt is the newer copy we just chose, so we never downgrade it.
283
+ if [ "$MOKO_ROOT" = /tmp/mokocli ] && command -v sudo >/dev/null 2>&1 && [ -d /opt/mokocli ] \
284
+ && ! cmp -s /tmp/mokocli/cli/version_reset_dev.php /opt/mokocli/cli/version_reset_dev.php 2>/dev/null; then
285
+ sudo cp -a /tmp/mokocli/. /opt/mokocli/ 2>/dev/null \
286
+ && echo "Refreshed /opt/mokocli failsafe from the fresh registry copy" \
287
+ || echo " (/opt failsafe refresh skipped — no sudo/perms)"
288
+ fi
289
+ elif [ -f /opt/mokocli/cli/version_bump.php ]; then
290
+ echo "::warning::Registry install failed — using /opt/mokocli FAILSAFE (may be stale)"
291
+ echo "MOKO_CLI=/opt/mokocli/cli" >> $GITHUB_ENV
292
+ else
293
+ echo "::error::mokocli unavailable: registry install failed and no /opt/mokocli failsafe present"
294
+ exit 1
295
+ fi
296
+
297
+ - name: Detect platform
298
+ id: platform
299
+ run: |
300
+ # PILOT: native-manifest platform detection via the MokoCLI build agent —
301
+ # NO metadata-API read. `moko platform:detect` (maps to platform_detect.php,
302
+ # detection-only) resolves the platform from the in-tree manifest and emits
303
+ # the single `platform` output the eligibility step below consumes. The
304
+ # former metadata-API-first lookup (GET /repos/.../metadata) is REMOVED; the
305
+ # build agent is the source of truth and no longer depends on the registry.
306
+ moko platform:detect --path . --github-output 2>/dev/null || true
307
+ # Fallback: if the agent emitted nothing (e.g. detached manifest), keep the
308
+ # file-based manifest read so repos still resolve a platform.
309
+ if ! grep -q '^platform=' "$GITHUB_OUTPUT" 2>/dev/null; then
310
+ echo "::notice::moko emitted no platform — falling back to manifest read"
311
+ moko manifest:read --path . --github-output 2>/dev/null || true
312
+ fi
313
+
314
+ - name: Check platform eligibility
315
+ id: eligibility
316
+ run: |
317
+ # Shared release engine (#122): the pre-release path runs for every
318
+ # artifact platform that produces releases — joomla, npm, mcp, and client. Deploy-only platforms (go) are
319
+ # EXCLUDED: they consume a tag but never bump/release here.
320
+ #
321
+ # is_joomla_family selects the Joomla/PHP-specific steps (manifest
322
+ # element/zip resolution, release_package.php zip, updates.xml). Those
323
+ # steps stay TRUE for the Joomla family so Joomla behavior is unchanged,
324
+ # and are skipped for npm/mcp/client, which ride only the platform-
325
+ # agnostic steps (version, tag, changelog, release-notes, release,
326
+ # cascade). Build/publish stay in the platform shim (ADR #124).
327
+ PLATFORM="${{ steps.platform.outputs.platform }}"
328
+ IS_JOOMLA_FAMILY=false
329
+ # STEP 1 — Authoritative baseline (unchanged): the hardcoded case gate
330
+ # remains the source of truth for `proceed` and the default value of
331
+ # is_joomla_family. Everything below (the registry lookup) is PURELY
332
+ # ADDITIVE and can only *refine* is_joomla_family for a cleanly-matched
333
+ # platform — it never flips `proceed`, never downgrades, and any
334
+ # failure/absence leaves this decision exactly as it is today.
335
+ # ADDITIVE + GATED (Template-Generic#326): electron / mokohub-manifest /
336
+ # mokohub-recipes are first-class eligible artifact platforms. They ride
337
+ # the platform-agnostic pre-release path (version + release + attach) and
338
+ # each has its OWN gated packaging step below, so joomla/npm/mcp/client
339
+ # behaviour is 100% unchanged. is_manifest / is_recipes drive those steps.
340
+ IS_MANIFEST=false
341
+ IS_RECIPES=false
342
+ IS_NPM=false
343
+ case "$PLATFORM" in
344
+ joomla*)
345
+ PROCEED=true
346
+ IS_JOOMLA_FAMILY=true
347
+ ;;
348
+ npm*)
349
+ PROCEED=true
350
+ IS_NPM=true
351
+ ;;
352
+ mcp*|client*)
353
+ PROCEED=true
354
+ ;;
355
+ electron*)
356
+ # electron is first-class eligible; the electron-builder installer
357
+ # legs below still self-gate on is_electron (file-detected) so a repo
358
+ # with an electron-builder config builds installers even before the
359
+ # registry classifies it (mokocli electron detection: MokoGIT #1201).
360
+ PROCEED=true
361
+ ;;
362
+ mokohub-manifest*)
363
+ PROCEED=true
364
+ IS_MANIFEST=true
365
+ ;;
366
+ mokohub-recipes*)
367
+ PROCEED=true
368
+ IS_RECIPES=true
369
+ ;;
370
+ *)
371
+ PROCEED=false
372
+ echo "::notice::Platform '$PLATFORM' — not an artifact release platform, skipping pre-release auto-bump"
373
+ ;;
374
+ esac
375
+
376
+ # STEP 2 — Registry enhancement (#125): classify the Joomla family from
377
+ # the live platform registry (GET /api/v1/platforms) so newly-registered
378
+ # manifest/packaging platforms get correct family classification without
379
+ # editing this workflow. We key on the registry `family` field: family
380
+ # `joomla` is exactly the Joomla-style manifest/packaging
381
+ # path (the manifest_element + release_package steps). We deliberately do
382
+ # NOT key on `manifest_based`, because npm and mcp are also
383
+ # manifest_based:true yet must ride the platform-agnostic path (family
384
+ # `node`) — so `manifest_based` is ambiguous here and `family` is the
385
+ # clean discriminator.
386
+ #
387
+ # Fail-safe contract: the curl is captured into a var (never allowed to
388
+ # crash the step under set -e -o pipefail), the JSON is parsed with a
389
+ # guarded python3 one-liner, and the result is only applied when it is
390
+ # exactly "true"/"false". On ANY of {non-200, curl error, empty/malformed
391
+ # JSON, parse failure, platform key absent} REGISTRY_FAMILY stays empty
392
+ # and we keep the STEP 1 hardcoded value untouched.
393
+ REGISTRY_JSON="$(curl -sf \
394
+ -H "Authorization: token ${{ github.token }}" \
395
+ "${GIT_URL}/api/v1/platforms" 2>/dev/null || true)"
396
+ REGISTRY_FAMILY=""
397
+ if [ -n "$REGISTRY_JSON" ]; then
398
+ # Single physical line of python3 so we stay safely inside this YAML
399
+ # block scalar (a column-0 continuation would terminate the scalar).
400
+ # Emits exactly: "true" -> matched key, family joomla;
401
+ # "false" -> matched key, some other family;
402
+ # "" -> key absent (fall back to hardcoded).
403
+ # On malformed/empty JSON the parse raises, 2>/dev/null hides the
404
+ # traceback and the trailing "|| true" keeps set -e -o pipefail from
405
+ # crashing the step, so REGISTRY_FAMILY stays "" and we fall back.
406
+ REGISTRY_FAMILY="$(printf '%s' "$REGISTRY_JSON" | PLATFORM="$PLATFORM" python3 -c 'import json,os,sys; p=os.environ.get("PLATFORM",""); f=next((x.get("family","") for x in json.load(sys.stdin) if x.get("key")==p), None); sys.stdout.write("true" if f == "joomla" else ("false" if f is not None else ""))' 2>/dev/null || true)"
407
+ fi
408
+ if [ "$REGISTRY_FAMILY" = "true" ]; then
409
+ IS_JOOMLA_FAMILY=true
410
+ echo "::notice::Registry classified '$PLATFORM' as Joomla family (via /api/v1/platforms)"
411
+ elif [ "$REGISTRY_FAMILY" = "false" ]; then
412
+ IS_JOOMLA_FAMILY=false
413
+ echo "::notice::Registry classified '$PLATFORM' as non-Joomla family (via /api/v1/platforms)"
414
+ else
415
+ echo "::notice::Registry lookup unavailable/absent for '$PLATFORM' — using hardcoded family classification"
416
+ fi
417
+
418
+ # electron (ADDITIVE + GATED): mokocli does not detect electron yet
419
+ # (MokoGIT #1201), so gate on an electron-builder config at the repo root
420
+ # and ride the platform-agnostic pre-release path (release + attach).
421
+ IS_ELECTRON=false
422
+ if [ -f electron-builder.yml ] || [ -f electron-builder.yaml ]; then
423
+ IS_ELECTRON=true
424
+ PROCEED=true
425
+ fi
426
+ echo "is_electron=${IS_ELECTRON}" >> "$GITHUB_OUTPUT"
427
+
428
+ echo "is_manifest=${IS_MANIFEST}" >> "$GITHUB_OUTPUT"
429
+ echo "is_recipes=${IS_RECIPES}" >> "$GITHUB_OUTPUT"
430
+ echo "proceed=${PROCEED}" >> "$GITHUB_OUTPUT"
431
+ echo "is_joomla_family=${IS_JOOMLA_FAMILY}" >> "$GITHUB_OUTPUT"
432
+ echo "is_npm=${IS_NPM}" >> "$GITHUB_OUTPUT"
433
+ # Releases on/off publish gate (.mokogit-private#73): the version BUMP
434
+ # runs on every eligible repo (proceed), but PUBLISH is gated on the
435
+ # repo's Releases unit so releases-off repos (Template-*, etc.) still
436
+ # bump WITHOUT publishing. Replaces the old !startsWith(name,Template-)
437
+ # name gate. Defaults ON unless the API explicitly returns
438
+ # has_releases=false (a transient error never disables a real publish).
439
+ HR=$(curl -sf -H "Authorization: token ${{ github.token }}" "${GIT_URL}/api/v1/repos/${{ github.repository }}" | jq -r '.has_releases' 2>/dev/null || true)
440
+ if [ "$PROCEED" = "true" ] && [ "$HR" != "false" ]; then PUBLISH=true; else PUBLISH=false; fi
441
+ # Electron: CI creates NO release and builds NO installer on ANY channel
442
+ # (dev/rc/stable) — always built locally + uploaded manually. The version
443
+ # BUMP (proceed) still runs; only the publish/build path is disabled. Opt a
444
+ # repo back into CI electron builds with vars.ELECTRON_CI_BUILD=true.
445
+ # (Template-Generic #390; electron -> mokocli ElectronPackager MokoCLI #756/#758.)
446
+ if [ "$IS_ELECTRON" = "true" ] && [ "${{ vars.ELECTRON_CI_BUILD }}" != "true" ]; then
447
+ PUBLISH=false
448
+ echo "::notice::electron repo — CI release/build disabled (local build + manual upload); version bump still runs"
449
+ fi
450
+ echo "publish=${PUBLISH}" >> "$GITHUB_OUTPUT"
451
+ echo "Publish gate: publish=${PUBLISH} (proceed=${PROCEED}, has_releases=${HR:-unknown})"
452
+ if [ "$PROCEED" = "true" ]; then
453
+ echo "::notice::Platform '$PLATFORM' eligible for pre-release (joomla_family=${IS_JOOMLA_FAMILY})"
454
+ fi
455
+
456
+ - name: "Resolve SRC_DIR bump gate (#469)"
457
+ id: srcgate
458
+ if: steps.eligibility.outputs.proceed == 'true'
459
+ # #469 (mirrors auto-release.yml): decide version-BUMP vs NO-BUMP from whether
460
+ # this dev push touched the Makefile source directory (SRC_DIR). Replaces the
461
+ # old non-code paths-ignore denylist — a docs/workflow/sample/test-only push no
462
+ # longer skips the build, it runs in no-bump "update the current rolling dev
463
+ # release in place" mode. On the dev channel the version advance is the
464
+ # per-merge patch bump ("Advance dev base"); should_bump=false skips it so the
465
+ # dev base stays put and the existing rolling dev release is simply re-issued.
466
+ #
467
+ # Gate logic (per #469):
468
+ # 1. ELECTRON (file-detected via steps.eligibility.is_electron) or opt-out
469
+ # vars.RELEASE_SRCDIR_GATE=off -> BYPASS, keep always-bump (electron has
470
+ # NO source dir, so a naive SRC_DIR test would never bump — a trap).
471
+ # 2. Else SRC_DIR is REQUIRED; no resolvable SRC_DIR on a non-excluded repo
472
+ # -> FAIL LOUD (::error:: + exit 1), never silently never-bump.
473
+ # 3. Else diff the push range (before..sha, HEAD^1 fallback) intersected with
474
+ # ${SRC_DIR}/ : source -> should_bump=true; none -> should_bump=false.
475
+ # workflow_dispatch / schedule always bump (explicit/scheduled re-cut).
476
+ run: |
477
+ set -uo pipefail
478
+ if [ "${{ steps.eligibility.outputs.is_electron }}" = "true" ] || [ "${{ vars.RELEASE_SRCDIR_GATE }}" = "off" ]; then
479
+ echo "should_bump=true" >> "$GITHUB_OUTPUT"
480
+ echo "::notice::SRC_DIR gate BYPASSED (electron repo or vars.RELEASE_SRCDIR_GATE=off) — keeping always-bump behaviour (#469)."
481
+ exit 0
482
+ fi
483
+ if [ "${{ github.event_name }}" != "push" ]; then
484
+ echo "should_bump=true" >> "$GITHUB_OUTPUT"
485
+ echo "event=${{ github.event_name }} (dispatch/schedule) — always bump (#469)."
486
+ exit 0
487
+ fi
488
+ # Accept all three GNU make assignment forms: SRC_DIR=, SRC_DIR :=, and
489
+ # the conditional SRC_DIR ?= (the [:?]? matches an optional : or ?).
490
+ SRC_DIR="$(grep -m1 -E '^SRC_DIR[[:space:]]*[:?]?=' Makefile 2>/dev/null | sed -E 's/.*[:?]?=[[:space:]]*//' | tr -d '[:space:]')"
491
+ if [ -z "$SRC_DIR" ]; then
492
+ echo "::error::#469 SRC_DIR gate: no SRC_DIR resolvable from the Makefile on a non-electron repo. This is a misconfiguration — refusing to silently never-bump. Set SRC_DIR in the Makefile, or opt this repo out with vars.RELEASE_SRCDIR_GATE=off (electron-style always-bump)."
493
+ {
494
+ echo "### ❌ Pre-release gate failed — no SRC_DIR (#469)"
495
+ echo ""
496
+ echo "A non-electron repo must declare a Makefile \`SRC_DIR\`. Treating an empty SRC_DIR as \"never bump\" would silently break dev releases, so this fails loud. Fix: set \`SRC_DIR\`, or set \`vars.RELEASE_SRCDIR_GATE=off\`."
497
+ } >> "$GITHUB_STEP_SUMMARY"
498
+ exit 1
499
+ fi
500
+ SRC_DIR="${SRC_DIR%/}"
501
+ BEFORE="${{ github.event.before }}"
502
+ SHA="${GITHUB_SHA:-}"
503
+ RANGE=""
504
+ ZERO="0000000000000000000000000000000000000000"
505
+ if [ -n "$BEFORE" ] && [ "$BEFORE" != "$ZERO" ] && git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
506
+ RANGE="${BEFORE}..${SHA}"
507
+ elif git rev-parse "${SHA}^1" >/dev/null 2>&1; then
508
+ RANGE="${SHA}^1..${SHA}"
509
+ fi
510
+ if [ -z "$RANGE" ]; then
511
+ echo "should_bump=true" >> "$GITHUB_OUTPUT"
512
+ echo "::notice::#469 SRC_DIR gate: no resolvable diff range (before=${BEFORE:-none}); defaulting to bump."
513
+ exit 0
514
+ fi
515
+ CHANGED="$(git diff --name-only "$RANGE" 2>/dev/null || true)"
516
+ SRC_HITS="$(printf '%s\n' "$CHANGED" | grep -E "^${SRC_DIR}/" || true)"
517
+ if [ -n "$SRC_HITS" ]; then
518
+ echo "should_bump=true" >> "$GITHUB_OUTPUT"
519
+ echo "#469 SRC_DIR gate: push touched ${SRC_DIR}/ (range ${RANGE}) — BUMP dev base + build release."
520
+ else
521
+ echo "should_bump=false" >> "$GITHUB_OUTPUT"
522
+ echo "::notice::#469 SRC_DIR gate: push touched NO ${SRC_DIR}/ paths (range ${RANGE}) — NO dev-base bump; re-issuing the current rolling dev release in place."
523
+ fi
524
+
525
+ - name: "Require Makefile (pre-release gate)"
526
+ if: steps.eligibility.outputs.proceed == 'true'
527
+ # Enforce the same Makefile requirement as auto-release, but only when the
528
+ # platform is eligible to build/release (pre-release legitimately no-ops for
529
+ # ineligible platforms). Post-checkout step: a job-level if: cannot see the workspace.
530
+ run: |
531
+ if [ ! -f Makefile ] && [ ! -f makefile ] && [ ! -f GNUmakefile ]; then
532
+ echo "::error::pre-release requires a Makefile at the repository root — none found. A release-bearing repo must ship a Makefile (build/package targets). Aborting."
533
+ echo "## Pre-release blocked: no Makefile at repo root" >> "$GITHUB_STEP_SUMMARY"
534
+ exit 1
535
+ fi
536
+ echo "Makefile present — pre-release may proceed."
537
+
538
+ - name: "Advance dev base (per-merge patch bump)"
539
+ # RESTORES per-merge version advancement (fleet-wide dev-version freeze fix).
540
+ #
541
+ # WHY THIS EXISTS: the fleet's documented per-branch bumper — auto-bump.yml,
542
+ # referenced by auto-release.yml's "fix/patch/hotfix already carried their
543
+ # patch bump from auto-bump.yml" comment — DOES NOT EXIST in any template repo
544
+ # (404). Dev advancement therefore relied entirely on version_build_metadata.php
545
+ # folding a commit counter into the PATCH, which was removed (mokocli #459/#712)
546
+ # because it ballooned the dev patch past stable (e.g. 03.03.56 > stable 03.03.00)
547
+ # and stranded Joomla's updater. With neither mechanism, the committed dev base
548
+ # FREEZES between promotions: every dev build re-cuts the SAME <version>, so the
549
+ # updater never offers a newer dev build and merged fixes never reach dev sites.
550
+ #
551
+ # THE FIX: on a real dev merge, advance the committed base by exactly ONE clean
552
+ # patch via release_bump_dev.php --bump patch --no-changelog. This uses the
553
+ # Contents API (protected-branch bypass), is idempotent + monotonic (never moves
554
+ # dev down; a re-synchronize re-run is a safe no-op), and commits with a
555
+ # [skip ci] message so the bump does NOT re-trigger this workflow (the push
556
+ # trigger already filters [skip ci]/[skip bump]). +1-per-merge stays far below
557
+ # the next minor, so dev never outruns stable — the counter's failure mode is
558
+ # avoided. Changelog promotion is deliberately skipped here; it remains a
559
+ # promotion-time action (auto-release's release_bump_dev --bump minor).
560
+ #
561
+ # Only on a genuine dev push (never the nightly schedule or manual dispatch,
562
+ # and never a non-dev ref). Degrades safely: if the bump can't run (no bypass
563
+ # token) the build simply re-cuts the current version, exactly as today.
564
+ # #469: this per-merge patch bump IS the dev-channel version advance, so it is
565
+ # skipped in no-bump mode (steps.srcgate.should_bump=false — a non-source
566
+ # push). The dev base then stays put and the rest of the job re-issues the
567
+ # current rolling dev release in place. On the bump path it runs as before.
568
+ if: >-
569
+ steps.eligibility.outputs.proceed == 'true' &&
570
+ github.event_name == 'push' &&
571
+ steps.ref.outputs.ref == 'dev' &&
572
+ steps.srcgate.outputs.should_bump != 'false'
573
+ env:
574
+ GITHUB_TOKEN: ${{ github.token }}
575
+ run: |
576
+ if [ -z "${GITHUB_TOKEN}" ]; then
577
+ echo "::warning::GITHUB_TOKEN unset — cannot advance the dev base; this build re-cuts the current version."
578
+ exit 0
579
+ fi
580
+ # #1412 idempotency: if the merge/push that triggered this build already
581
+ # advanced the base (e.g. a PR that pre-bumped PROJECT_VERSION), do NOT
582
+ # bump again - a second +1 here doubles the version. Compare HEAD's base
583
+ # to its first parent (fetch-depth: 0 guarantees the parent is present).
584
+ if git rev-parse HEAD^1 >/dev/null 2>&1; then
585
+ cur_v="$(grep -E '^[[:space:]]*PROJECT_VERSION[[:space:]]*:?=' Makefile 2>/dev/null | head -1 | grep -oE '[0-9][0-9.]+' | head -1 || true)"
586
+ par_v="$(git show HEAD^1:Makefile 2>/dev/null | grep -E '^[[:space:]]*PROJECT_VERSION[[:space:]]*:?=' | head -1 | grep -oE '[0-9][0-9.]+' | head -1 || true)"
587
+ if [ -n "$cur_v" ] && [ -n "$par_v" ] && [ "$cur_v" != "$par_v" ]; then
588
+ echo "::notice::base already advanced this merge ($par_v -> $cur_v); skipping per-merge auto-bump to avoid doubling (#1412)."
589
+ exit 0
590
+ fi
591
+ fi
592
+ API_BASE="${GIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
593
+ if ! php ${MOKO_CLI}/release_bump_dev.php --path . --branch dev --bump patch --no-changelog --token "${GITHUB_TOKEN}" --api-base "${API_BASE}"; then
594
+ echo "::error::dev base patch-bump FAILED — release_bump_dev stamped 0 version files, so the dev base did NOT advance. Refusing to silently re-cut the current version: a stalled bump keeps the Joomla update feed at the installed version and hides across many merges (it stayed green for ~10 merges on MokonyxTickets — MokoGIT#1721). This is usually a repo-side Contents-API write failure (HTTP 500 on the bot/version-advance temp branch). Fix the bump and re-run."
595
+ exit 1
596
+ fi
597
+ # release_bump_dev.php writes the BRANCH (Contents API), not the local
598
+ # checkout. Mirror the freshly-committed base into the working tree so the
599
+ # "Resolve metadata and bump version" step below (which reads the LOCAL
600
+ # Makefile) stamps THIS build with the newly-bumped version.
601
+ git fetch origin dev --quiet 2>/dev/null || true
602
+ git checkout origin/dev -- Makefile 2>/dev/null || true
603
+ echo "Dev base advanced; local Makefile mirrored for this build:"
604
+ grep -E '^\s*PROJECT_VERSION' Makefile || true
605
+
606
+ - name: Resolve metadata and bump version
607
+ id: meta
608
+ if: steps.eligibility.outputs.proceed == 'true'
609
+ run: |
610
+ # #404 — stability comes from the dispatch input; the nightly schedule
611
+ # always builds the development channel. There is no push event.
612
+ if [ "${{ github.event_name }}" = "schedule" ]; then
613
+ STABILITY="development"
614
+ else
615
+ STABILITY="${{ inputs.stability || 'development' }}"
616
+ fi
617
+
618
+ case "$STABILITY" in
619
+ development) SUFFIX="-dev"; TAG="development" ;;
620
+ alpha) SUFFIX="-alpha"; TAG="alpha" ;;
621
+ beta) SUFFIX="-beta"; TAG="beta" ;;
622
+ release-candidate) SUFFIX="-rc"; TAG="release-candidate" ;;
623
+ esac
624
+
625
+ # #404 — VERSION AS BUILD METADATA, NOT A COMMITTED BUMP.
626
+ # version_build_metadata.php derives a monotonically-advancing version by
627
+ # FOLDING the build counter (commits-since-base, #468) INTO THE PATCH and
628
+ # appending a SINGLE channel suffix — a clean XX.YY.ZZ-<stability> with NO
629
+ # dotted ".N" counter (MokoGIT #1313). It stamps this into the in-tree
630
+ # manifest ONLY (no git add/commit/push). This keeps <version> advancing so
631
+ # Joomla's updater detects each new dev/rc build, while removing the pushed
632
+ # bump commit that caused manifest <version> merge conflicts on every
633
+ # branch->dev merge.
634
+ php ${MOKO_CLI}/version_build_metadata.php \
635
+ --path . --stability "$STABILITY" --github-output 2>/dev/null || true
636
+
637
+ # #404 + #1313: cascade the FULL derived version (e.g. 02.91.03-dev), NOT
638
+ # the bare Makefile base. version_build_metadata folds the monotonic build
639
+ # counter INTO THE PATCH and emits build_version as a clean XX.YY.ZZ plus a
640
+ # SINGLE channel suffix (-dev/-alpha/-beta/-rc) — no dotted ".N".
641
+ #
642
+ # WHY the ".N" counter was removed (the fleet "version-freeze"): the old
643
+ # XX.YY.ZZ-<stability>.<N> form (e.g. 02.91.00-dev.3) broke the Joomla update
644
+ # chain end-to-end. The MokoGIT updates.xml generator dropped/mis-carried
645
+ # ".N" into <version> (MokoGIT #1307), and Joomla itself cannot parse a
646
+ # dotted pre-release counter: it fused ".N" into <element>, minted duplicate
647
+ # <version>s across builds, and broke version_compare — so dev/rc sites were
648
+ # NEVER offered a newer build (MokoGIT #1313). Folding the counter into the
649
+ # patch keeps <version> strictly increasing AND a plain, comparable
650
+ # XX.YY.ZZ-<stability>. NOTE: the feed is rendered on-demand by the MokoGIT
651
+ # server binary, so a generator fix only takes effect once that binary is
652
+ # deployed (this bit the fleet when a broken prod deploy stranded the fix).
653
+ # Re-reading the base here instead would discard the advance and re-freeze
654
+ # every build at the same <version>.
655
+ VERSION="$(grep '^build_version=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2 || true)"
656
+ if [ -z "$VERSION" ]; then
657
+ # Fallback when build metadata is unavailable: bare base + suffix (legacy,
658
+ # frozen-within-base behaviour — better than an empty version).
659
+ VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "00.00.01")
660
+ VERSION="$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')${SUFFIX}"
661
+ fi
662
+
663
+ # Pass the version VERBATIM — deliberately NO --stability. version_set_platform
664
+ # only strips a *trailing* -<stability> and only appends the suffix when it is
665
+ # absent, so a full "02.91.03-dev" is written unchanged via its semver branch;
666
+ # passing --stability would re-append and double the suffix (…-dev-dev).
667
+ # #289: also stamp <creationDate> (build date, UTC) + <author> (Makefile
668
+ # PROJECT_AUTHOR) into Joomla manifests at build. No-op on non-Joomla, and
669
+ # harmless on an older MokoCLI lacking --date/--author (2>/dev/null || true).
670
+ MG_BUILD_DATE="$(date -u +%Y-%m-%d)"
671
+ MG_AUTHOR="$(grep -m1 -E '^PROJECT_AUTHOR[[:space:]]*:?=' Makefile 2>/dev/null | sed -E 's/.*:?=[[:space:]]*//' | sed 's/[[:space:]]*$//' || true)"
672
+ php ${MOKO_CLI}/version_set_platform.php \
673
+ --path . --version "$VERSION" --branch "${{ steps.ref.outputs.ref }}" \
674
+ --date "$MG_BUILD_DATE" ${MG_AUTHOR:+--author "$MG_AUTHOR"} 2>/dev/null || true
675
+ php ${MOKO_CLI}/version_check.php --path . --fix 2>/dev/null || true
676
+
677
+ # Emit a Joomla schema-update stub for each shipped extension's own
678
+ # version so #__schemas tracks the manifest (fixes "database out of date").
679
+ # --prune-above also removes leftover old-scheme (02.x/03.x) update files
680
+ # stranded above the manifest version by the rebrand down-reset, which
681
+ # otherwise keep ChangeSet::getSchema() pinned high (MokoCLI schema drift,
682
+ # Mokonyx #469). No-op on non-Joomla repos, which have no sql/updates dirs.
683
+ php ${MOKO_CLI}/schema_stub.php --path . --prune-above
684
+
685
+ # Ensure licensing tags (updateservers, dlid) if enabled in manifest.xml
686
+ php ${MOKO_CLI}/manifest_licensing.php --path . --fix 2>/dev/null || true
687
+
688
+ # VERSION already carries its single -<stability> suffix (from build_version
689
+ # above), so it is NOT re-suffixed here — re-appending $SUFFIX produced the
690
+ # -dev-dev double-suffix (Template-Generic#319).
691
+
692
+ # NOTE: no commit/push here (#404). The derived version lives only in the
693
+ # built artifact + the dynamic updates feed; the branch tip is untouched.
694
+
695
+ # Auto-detect element via manifest_element.php (Joomla-family only:
696
+ # element/manifest resolution is Joomla-specific). For npm/mcp/client
697
+ # the generic fallback below derives ext_element/zip_name from the repo
698
+ # name — no Joomla manifest is present or required.
699
+ if [ "${{ steps.eligibility.outputs.is_joomla_family }}" = "true" ]; then
700
+ # NO --stability: $VERSION already carries its single -<stability> suffix,
701
+ # and manifest_element appends the stability suffix unconditionally — passing
702
+ # it would double the suffix in the zip name (…-dev-dev.zip). (#319)
703
+ php ${MOKO_CLI}/manifest_element.php \
704
+ --path . --version "$VERSION" \
705
+ --repo "${GIT_REPO}" --github-output
706
+ fi
707
+
708
+ # Read back element outputs
709
+ EXT_ELEMENT=$(grep '^ext_element=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2 || true)
710
+ ZIP_NAME=$(grep '^zip_name=' "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2 || true)
711
+ [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(echo "${GIT_REPO}" | tr '[:upper:]' '[:lower:]' | tr -d ' -')
712
+ [ -z "$ZIP_NAME" ] && ZIP_NAME="${EXT_ELEMENT}-${VERSION}.zip"
713
+
714
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
715
+ echo "stability=${STABILITY}" >> "$GITHUB_OUTPUT"
716
+ echo "suffix=${SUFFIX}" >> "$GITHUB_OUTPUT"
717
+ echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
718
+ echo "zip_name=${ZIP_NAME}" >> "$GITHUB_OUTPUT"
719
+ echo "ext_element=${EXT_ELEMENT}" >> "$GITHUB_OUTPUT"
720
+
721
+ echo "=== Pre-Release: ${EXT_ELEMENT} ${VERSION} ==="
722
+
723
+ # npm publishing is NOT part of this universal workflow — it lives only in
724
+ # Template-NPM (.mokogit/workflows/npm-publish.yml) so it cannot leak into
725
+ # Joomla / non-npm repos via template sync. Joomla-family repos attach a zip
726
+ # below; npm repos publish from their own dedicated workflow.
727
+ # Release API operations (create / notes / upload / cascade delete) run
728
+ # against THIS repo, so they authenticate with the built-in per-run
729
+ # github.token (contents: write) — NOT the org MOKOGIT_TOKEN. That keeps
730
+ # MOKOGIT_TOKEN least-privilege (read-only, used only for the cross-repo
731
+ # mokocli / submodule clones above) while same-repo release writes stay
732
+ # scoped to the run — matching auto-release.yml (Template-Generic #188).
733
+ # github.token carries no trailing newline, but trim + mask anyway for
734
+ # Authorization-header hygiene.
735
+ - name: Sanitize release API token
736
+ if: steps.eligibility.outputs.proceed == 'true'
737
+ run: |
738
+ T="$(printf '%s' "${{ github.token }}" | tr -d '[:space:]')"
739
+ echo "::add-mask::${T}"
740
+ echo "MOKO_TOKEN=${T}" >> "$GITHUB_ENV"
741
+
742
+ - name: Create release
743
+ id: release
744
+ if: steps.eligibility.outputs.publish == 'true'
745
+ # The MokoGIT release is the deliverable ONLY for the Joomla family. npm/mcp/
746
+ # client publish their real artifact upstream (npm) and merely mirror a
747
+ # release here, so a MokoGIT release failure must not fail those runs.
748
+ # release_create.php can fail its delete-then-recreate of the rolling dev/rc
749
+ # release (DELETE returns HTTP 200 but the release lingers), which turns an
750
+ # already-successful npm publish red. We branch on family INSIDE the run
751
+ # script (this act runner rejects `continue-on-error: ${{ ... }}` — it only
752
+ # accepts a literal bool): Joomla stays fatal; non-Joomla swallows a release
753
+ # failure so a green npm publish stays green.
754
+ run: |
755
+ TAG="${{ steps.meta.outputs.tag }}"
756
+ VERSION="${{ steps.meta.outputs.version }}"
757
+ API_BASE="${GIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
758
+ if [ "${{ steps.eligibility.outputs.is_joomla_family }}" = "true" ]; then
759
+ php ${MOKO_CLI}/release_create.php \
760
+ --path . --version "$VERSION" --tag "$TAG" \
761
+ --token "${MOKO_TOKEN}" --api-base "$API_BASE" \
762
+ --repo "${GIT_REPO}" --branch "${{ steps.ref.outputs.ref }}" --prerelease
763
+ else
764
+ php ${MOKO_CLI}/release_create.php \
765
+ --path . --version "$VERSION" --tag "$TAG" \
766
+ --token "${MOKO_TOKEN}" --api-base "$API_BASE" \
767
+ --repo "${GIT_REPO}" --branch "${{ steps.ref.outputs.ref }}" --prerelease \
768
+ || echo "::warning::MokoGIT release failed — non-fatal for npm/mcp/client (npm is the real artifact)"
769
+ fi
770
+
771
+ - name: "Deploy to dev (inline — replaces standalone deploy.yml, #1427)"
772
+ if: github.event_name == 'push' && github.ref == 'refs/heads/dev' && vars.DEV_SSH_USERNAME != ''
773
+ env:
774
+ DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
775
+ run: |
776
+ # #1427: dev-channel deploy inlined here (pre-release runs on dev push);
777
+ # the standalone deploy.yml is deleted. Only a genuine dev PUSH deploys
778
+ # (never the nightly schedule or a manual alpha/beta/rc dispatch). Gate =
779
+ # DEV_SSH_USERNAME set → non-deploy repos skip green. Targets from repo
780
+ # VARIABLES. mokocli deploy.php → restricted moko-deploy pattern.
781
+ set -euo pipefail
782
+ if [ -z "${DEPLOY_KEY:-}" ]; then
783
+ echo "::warning::DEV_SSH_USERNAME set but DEPLOY_SSH_KEY secret missing — skipping dev deploy"; exit 0
784
+ fi
785
+ mkdir -p ~/.ssh
786
+ # DEPLOY_SSH_KEY is base64-encoded (single line) so act_runner masks it in logs
787
+ # and the multi-line key is never echoed. Decode when it base64-decodes to a
788
+ # private key; else treat as a legacy raw PEM (back-compat during rollout).
789
+ install -m600 /dev/null ~/.ssh/deploy_key # 0600 before any bytes (no umask window)
790
+ trap 'rm -f ~/.ssh/deploy_key' EXIT # never leave the key on a shared runner
791
+ if printf '%s' "$DEPLOY_KEY" | base64 -d 2>/dev/null | grep -q 'PRIVATE KEY'; then
792
+ printf '%s' "$DEPLOY_KEY" | base64 -d > ~/.ssh/deploy_key
793
+ else
794
+ echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
795
+ fi
796
+ TAG="$(git describe --tags --always 2>/dev/null || git rev-parse --short HEAD)-dev"
797
+ echo "Deploying dev tag ${TAG} to ${{ vars.DEV_SSH_USERNAME }}@${{ vars.DEV_SSH_HOST }}"
798
+ php "${MOKO_CLI}/deploy.php" --tier dev --tag "$TAG" \
799
+ --ssh-host "${{ vars.DEV_SSH_HOST }}" --ssh-port "${{ vars.DEV_SSH_PORT }}" \
800
+ --ssh-user "${{ vars.DEV_SSH_USERNAME }}" --ssh-key ~/.ssh/deploy_key
801
+
802
+ # -- Electron pre-release: build installers (Windows via Wine + Linux) ------
803
+ # No Windows/macOS runner: cross-build Windows with Wine on the Ubuntu
804
+ # `release` runner, build Linux natively, skip macOS. Gated on is_electron
805
+ # so non-electron pre-releases are 100% unaffected.
806
+ - name: "Setup Node (electron)"
807
+ if: steps.eligibility.outputs.proceed == 'true' && steps.eligibility.outputs.is_electron == 'true' && vars.ELECTRON_CI_BUILD == 'true'
808
+ uses: actions/setup-node@v4
809
+ with:
810
+ node-version: 24.18.1
811
+ - name: "Setup pnpm (electron)"
812
+ if: steps.eligibility.outputs.proceed == 'true' && steps.eligibility.outputs.is_electron == 'true' && vars.ELECTRON_CI_BUILD == 'true'
813
+ uses: pnpm/action-setup@v4
814
+ with:
815
+ version: 11.20.0
816
+ - name: "Build electron installers (Windows via Wine + Linux) [pre-release]"
817
+ if: steps.eligibility.outputs.publish == 'true' && steps.eligibility.outputs.is_electron == 'true' && vars.ELECTRON_CI_BUILD == 'true'
818
+ env:
819
+ HUSKY: "0"
820
+ run: |
821
+ # electron-builder's NSIS target RUNS the built installer under Wine to
822
+ # generate + sign the uninstaller, which needs 32-bit Wine libs (the NSIS
823
+ # stub is a WoW64 exe) AND an X display. A bare `wine64` on an
824
+ # under-provisioned `release` runner aborts with "wine process failed 1
825
+ # ... ntdll.dll c0000135 / no driver could be loaded" (intermittent: only
826
+ # when a job lands on a runner without wine32/xvfb pre-seeded). Provision
827
+ # i386 Wine + Xvfb here so the Windows leg builds on ANY runner, and init
828
+ # the prefix once, headless, so electron-builder's first Wine call does
829
+ # not race prefix creation.
830
+ sudo dpkg --add-architecture i386 || true
831
+ sudo apt-get update -qq
832
+ sudo apt-get install -y -qq xvfb >/dev/null 2>&1 || echo "::warning::xvfb install failed"
833
+ sudo apt-get install -y -qq wine64 wine32 >/dev/null 2>&1 || sudo apt-get install -y -qq wine >/dev/null 2>&1 || sudo apt-get install -y -qq wine64 >/dev/null 2>&1 || echo "::warning::wine install failed -- Windows target may be skipped"
834
+ export WINEDEBUG=-all
835
+ export WINEPREFIX="${WINEPREFIX:-$HOME/.wine}"
836
+ xvfb-run -a wineboot --init >/dev/null 2>&1 || true
837
+ pnpm install --frozen-lockfile
838
+ # Compile the app into electron-builder's app dir (directories.app, e.g. ./build)
839
+ # BEFORE packaging. electron-builder validates the app dir up-front, so running
840
+ # it without a prior compile aborts with "Application directory ./build doesn't
841
+ # exist". Prefer an explicit repo script (compile:app); fall back to the esbuild
842
+ # entrypoint that Ferdium-derived electron apps ship.
843
+ if pnpm run 2>/dev/null | grep -qiE '(^|[[:space:]])compile:app([[:space:]]|$)'; then
844
+ pnpm run compile:app
845
+ elif [ -f esbuild.mjs ]; then
846
+ npx --no-install preval-build-info-cli >/dev/null 2>&1 || true
847
+ node esbuild.mjs
848
+ else
849
+ echo "::warning::no compile step found (add a 'compile:app' package.json script or esbuild.mjs) -- electron-builder may fail if the app dir is not pre-built"
850
+ fi
851
+ # xvfb-run gives Wine a virtual X display for the installer-run pass that
852
+ # generates the NSIS uninstaller; without it Wine cannot create a window
853
+ # and the build fails ("no driver could be loaded / $DISPLAY").
854
+ xvfb-run -a npx --no-install electron-builder --win --linux --publish never || xvfb-run -a npx electron-builder --win --linux --publish never
855
+ - name: "Attach electron installers (pre-release)"
856
+ if: steps.eligibility.outputs.publish == 'true' && steps.eligibility.outputs.is_electron == 'true' && vars.ELECTRON_CI_BUILD == 'true'
857
+ continue-on-error: true
858
+ run: |
859
+ API_BASE="${GIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
860
+ TOKEN="${MOKO_TOKEN}"
861
+ TAG="${{ steps.meta.outputs.tag }}"
862
+ RID=$(curl -sf -H "Authorization: token ${TOKEN}" "${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
863
+ if [ -z "$RID" ]; then echo "::warning::pre-release ${TAG} not found -- skipping electron asset upload"; else
864
+ for f in $(find out dist_electron dist -maxdepth 2 -type f \( -name '*.exe' -o -name '*.AppImage' -o -name '*.deb' -o -name 'latest*.yml' \) 2>/dev/null); do
865
+ name=$(basename "$f")
866
+ curl -sf -X POST -H "Authorization: token ${TOKEN}" "${API_BASE}/releases/${RID}/assets?name=${name}" -F "attachment=@${f}" >/dev/null && echo "attached ${name}" || echo "::warning::upload failed ${name}"
867
+ done
868
+ fi
869
+
870
+ # -- npm pre-release: publish the dev/alpha/beta/rc build to the MokoGIT npm
871
+ # registry (INTERNAL ONLY). ABSORBED from Template-NPM's retired
872
+ # npm-prerelease.yml, but CORRECTED: the old step called `moko publish`, which
873
+ # maps to release_publish.php and does a GIT RELEASE ONLY — mokocli has no npm
874
+ # publish verb, so dev builds never actually reached an npm registry (only
875
+ # npm-publish.yml's plain `npm publish` ever did). This uses plain `npm publish`
876
+ # with the channel as the npm dist-tag, to the MokoGIT npm registry via the
877
+ # per-run Actions token (least-privilege). Public npmjs.org is deliberately NOT
878
+ # used for prereleases. Gated on is_npm; idempotent (existing version = no-op).
879
+ - name: "Setup Node (npm)"
880
+ if: steps.eligibility.outputs.publish == 'true' && steps.eligibility.outputs.is_npm == 'true'
881
+ uses: actions/setup-node@v4
882
+ with:
883
+ node-version: '20'
884
+ - name: "Publish npm package (pre-release -> MokoGIT registry, dist-tag by channel)"
885
+ if: steps.eligibility.outputs.publish == 'true' && steps.eligibility.outputs.is_npm == 'true'
886
+ env:
887
+ NODE_AUTH_TOKEN: ${{ github.token }}
888
+ run: |
889
+ set -uo pipefail
890
+ VERSION="${{ steps.meta.outputs.version }}"
891
+ case "${{ steps.meta.outputs.stability }}" in
892
+ alpha) TAG=alpha ;;
893
+ beta) TAG=beta ;;
894
+ release-candidate|rc) TAG=rc ;;
895
+ *) TAG=dev ;;
896
+ esac
897
+ # Stamp package.json to the derived build version (in-tree only, never
898
+ # committed) so `npm publish` ships exactly the pre-release version.
899
+ npm version "$VERSION" --no-git-tag-version --allow-same-version >/dev/null 2>&1 || echo "::warning::could not stamp package.json to ${VERSION} (continuing)"
900
+ npm ci --no-audit --no-fund || npm install --no-audit --no-fund
901
+ npm run build --if-present
902
+ HOST="${GIT_URL#https://}"; OWNER="${GIT_ORG}"
903
+ REG="https://${HOST}/api/packages/${OWNER}/npm/"
904
+ echo "//${HOST}/api/packages/${OWNER}/npm/:_authToken=${NODE_AUTH_TOKEN}" > .npmrc
905
+ set +e; out="$(npm publish --ignore-scripts --registry "${REG}" --tag "${TAG}" 2>&1)"; rc=$?; set -e
906
+ rm -f .npmrc; echo "$out"
907
+ if [ "$rc" -eq 0 ]; then echo "Published ${VERSION} (dist-tag ${TAG}) to the MokoGIT npm registry."
908
+ elif printf '%s' "$out" | grep -qiE 'already exists|cannot publish over|EPUBLISHCONFLICT|409 Conflict'; then echo "::notice::MokoGIT registry already has ${VERSION} - no-op (idempotent)."
909
+ else echo "::error::MokoGIT-registry pre-release publish FAILED (npm exit ${rc})."; exit "$rc"; fi
910
+
911
+ # -- mokohub-manifest pre-release: zip manifest.json -> attach ---------------
912
+ # ADDITIVE + GATED (Template-Generic#326). Package the repo's manifest.json
913
+ # into a rolling zip and attach it to the pre-release. Gated on is_manifest,
914
+ # so every other platform is unaffected. Best-effort (continue-on-error): a
915
+ # cosmetic packaging hiccup must not red an otherwise-good pre-release.
916
+ - name: "Package mokohub-manifest (zip manifest.json) [pre-release]"
917
+ if: >-
918
+ steps.eligibility.outputs.publish == 'true' &&
919
+ steps.eligibility.outputs.is_manifest == 'true'
920
+ continue-on-error: true
921
+ run: |
922
+ TAG="${{ steps.meta.outputs.tag }}"
923
+ VERSION="${{ steps.meta.outputs.version }}"
924
+ API_BASE="${GIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
925
+ if [ ! -f manifest.json ]; then
926
+ echo "::warning::mokohub-manifest: no manifest.json at repo root — nothing to package"
927
+ exit 0
928
+ fi
929
+ ZIP="${RUNNER_TEMP}/manifest-${VERSION}.zip"
930
+ ( cd "$(dirname manifest.json)" && zip -j "$ZIP" manifest.json ) \
931
+ || { echo "::warning::mokohub-manifest: zip failed"; exit 0; }
932
+ RID=$(curl -sf -H "Authorization: token ${MOKO_TOKEN}" "${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
933
+ if [ -z "$RID" ]; then echo "::warning::pre-release ${TAG} not found — skipping manifest asset upload"; exit 0; fi
934
+ name="$(basename "$ZIP")"
935
+ # Replace-in-place: delete a same-named asset so the rolling zip updates.
936
+ AID=$(curl -sf -H "Authorization: token ${MOKO_TOKEN}" "${API_BASE}/releases/${RID}/assets" | N="$name" python3 -c "import json,sys,os; n=os.environ['N']; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==n),''))" 2>/dev/null || true)
937
+ [ -n "$AID" ] && curl -sf -X DELETE -H "Authorization: token ${MOKO_TOKEN}" "${API_BASE}/releases/${RID}/assets/${AID}" >/dev/null 2>&1 || true
938
+ curl -sf -X POST -H "Authorization: token ${MOKO_TOKEN}" "${API_BASE}/releases/${RID}/assets?name=${name}" -F "attachment=@${ZIP}" >/dev/null \
939
+ && echo "attached ${name}" || echo "::warning::manifest asset upload failed (${name})"
940
+
941
+ # -- mokohub-recipes pre-release: build catalog.zip (via MokoCLI) -> attach --
942
+ # ADDITIVE + GATED (Template-Generic#326). Delegate catalog.zip packaging to
943
+ # the MokoCLI build agent (`moko package`), which builds ONE signed catalog.zip
944
+ # per stream for the mokohub-recipes platform (MokoCLI #684 / epic
945
+ # mokohub-recipes#1). Gated on is_recipes; best-effort so joomla/npm/etc are
946
+ # untouched and a not-yet-shipped MokoCLI verb can't red the run.
947
+ - name: "Package mokohub-recipes (catalog.zip via MokoCLI) [pre-release]"
948
+ if: >-
949
+ steps.eligibility.outputs.publish == 'true' &&
950
+ steps.eligibility.outputs.is_recipes == 'true'
951
+ continue-on-error: true
952
+ run: |
953
+ VERSION="${{ steps.meta.outputs.version }}"
954
+ TAG="${{ steps.meta.outputs.tag }}"
955
+ STABILITY="${{ steps.meta.outputs.stability }}"
956
+ API_BASE="${GIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
957
+ # `moko package` for the mokohub-recipes platform builds the per-stream
958
+ # catalog.zip (all recipes + all.json) and attaches it as the rolling
959
+ # stream asset. Same --stability/--rolling/--tag/attach contract as the
960
+ # Joomla zip step; the platform shim selects the catalog builder.
961
+ moko package \
962
+ --path . --version "$VERSION" --tag "$TAG" \
963
+ --token "${MOKO_TOKEN}" --api-base "$API_BASE" \
964
+ --repo "${GIT_REPO}" --out "$RUNNER_TEMP" \
965
+ --stability "$STABILITY" --rolling \
966
+ || echo "::warning::mokohub-recipes: moko package (catalog.zip) failed or unavailable — non-fatal (MokoCLI #684)"
967
+
968
+ - name: Update release notes from CHANGELOG.md
969
+ if: steps.eligibility.outputs.publish == 'true'
970
+ # Release notes are cosmetic. The release-body PATCH can 403 on this MokoGIT
971
+ # instance (the per-run github.token lacks release-write), and for npm/mcp/
972
+ # client the published package is the real artifact — never let this fail
973
+ # the run. (Matches the cascade step's best-effort contract.)
974
+ continue-on-error: true
975
+ run: |
976
+ TAG="${{ steps.meta.outputs.tag }}"
977
+ VERSION="${{ steps.meta.outputs.version }}"
978
+ API_BASE="${GIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
979
+
980
+ # Extract [Unreleased] section via the shared mokocli command (#364 centralization).
981
+ if [ -f "CHANGELOG.md" ]; then
982
+ NOTES=$(php ${MOKO_CLI}/release_notes.php --path . --version Unreleased 2>/dev/null || true)
983
+ # release_notes.php echoes "Release Unreleased" when the section is empty; normalize.
984
+ [ "$NOTES" = "Release Unreleased" ] && NOTES=""
985
+ [ -z "$NOTES" ] && NOTES="Release ${VERSION}"
986
+ else
987
+ NOTES="Release ${VERSION}"
988
+ fi
989
+
990
+ # Update release body via API
991
+ RELEASE_ID=$(curl -sf -H "Authorization: token ${MOKO_TOKEN}" \
992
+ "${API_BASE}/releases/tags/${TAG}" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
993
+
994
+ if [ -n "$RELEASE_ID" ]; then
995
+ python3 -c "
996
+ import json, urllib.request
997
+ body = open('/dev/stdin').read()
998
+ payload = json.dumps({'body': body}).encode()
999
+ req = urllib.request.Request(
1000
+ '${API_BASE}/releases/${RELEASE_ID}',
1001
+ data=payload, method='PATCH',
1002
+ headers={
1003
+ 'Authorization': 'token ${MOKO_TOKEN}',
1004
+ 'Content-Type': 'application/json'
1005
+ })
1006
+ urllib.request.urlopen(req)
1007
+ " <<< "$NOTES"
1008
+ echo "Release notes updated from CHANGELOG.md"
1009
+ fi
1010
+
1011
+ # Build-time PHP dependency vendoring (universal, guarded). If a sub-package
1012
+ # ships a composer.json, fetch its runtime deps into that package's CONFIGURED
1013
+ # vendor dir (composer config vendor-dir — e.g. admin/vendor) BEFORE
1014
+ # release_package.php zips the working tree — so a component can declare a PHP
1015
+ # library (e.g. mPDF) via composer.json instead of committing vendor/. No-op
1016
+ # for any repo without a sub-package composer.json.
1017
+ #
1018
+ # (07.15.00) This step used to be a SILENT NO-OP on the release runners:
1019
+ # php-cli ships without ext-gd, so composer could not even RESOLVE mpdf/mpdf
1020
+ # ("require ext-gd * -> it is missing from your system"), and the old
1021
+ # continue-on-error + `|| warning` swallowed that into a green build. The zip
1022
+ # then shipped WITHOUT admin/vendor while the component manifest lists
1023
+ # <folder>vendor</folder>, so JInstaller aborts the ENTIRE package install
1024
+ # on-site ("File does not exist [ROOT][TMP]/.../admin/vendor" → "Failed to
1025
+ # copy administrator files"; reproduced on MokonyxCRMDCM + MokonyxCRM). Now:
1026
+ # ext-gd is provisioned when missing, the configured vendor-dir is verified
1027
+ # to have materialized autoload.php, and a vendoring failure for a package
1028
+ # whose manifest ships <folder>vendor</folder> FAILS the build loud — a
1029
+ # broken artifact that bricks installs is strictly worse than a red build.
1030
+ # Packages whose manifests do NOT reference vendor/ keep the fail-soft
1031
+ # warning, so non-vendoring repos are unaffected.
1032
+ - name: Vendor PHP dependencies (build-time)
1033
+ if: steps.eligibility.outputs.proceed == 'true'
1034
+ run: |
1035
+ set -uo pipefail
1036
+ CJS="$(find source/packages src/packages -maxdepth 2 -name composer.json 2>/dev/null || true)"
1037
+ if [ -z "$CJS" ]; then echo "No sub-package composer.json — nothing to vendor."; exit 0; fi
1038
+ command -v composer >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq composer >/dev/null 2>&1 || true; }
1039
+ # mPDF (and most PHP PDF/image libs) hard-require ext-gd; without it
1040
+ # composer fails dependency RESOLUTION outright, not just a sub-dep.
1041
+ if ! php -m 2>/dev/null | grep -qi '^gd$'; then
1042
+ PHPV="$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;' 2>/dev/null || true)"
1043
+ sudo apt-get update -qq || true
1044
+ sudo apt-get install -y -qq "php${PHPV}-gd" || sudo apt-get install -y -qq php-gd || echo "::warning::php-gd install failed (php ${PHPV})"
1045
+ php -m 2>/dev/null | grep -qi '^gd$' || echo "::warning::ext-gd STILL missing after install attempt — vendoring will fail for gd-requiring packages"
1046
+ fi
1047
+ rc=0
1048
+ for cj in $CJS; do
1049
+ dir="$(dirname "$cj")"
1050
+ echo "::notice::composer install (no-dev) in ${dir}"
1051
+ ok=true
1052
+ composer install --no-dev --no-interaction --no-progress --optimize-autoloader --working-dir="$dir" || ok=false
1053
+ # Verify the CONFIGURED vendor dir actually materialized — composer can
1054
+ # exit 0 while writing nothing the manifest needs.
1055
+ vd="$(composer config vendor-dir --working-dir="$dir" 2>/dev/null || true)"; vd="${vd:-vendor}"
1056
+ [ -f "${dir}/${vd}/autoload.php" ] || ok=false
1057
+ if [ "$ok" = "true" ]; then
1058
+ echo "vendored: ${dir}/${vd}"
1059
+ elif grep -qs '<folder>vendor</folder>' "${dir}"/*.xml; then
1060
+ echo "::error::vendoring FAILED in ${dir} but its Joomla manifest ships <folder>vendor</folder> — the built zip would abort the whole package install on JInstaller ('File does not exist .../vendor'). Failing the build instead of shipping a broken package."
1061
+ rc=1
1062
+ else
1063
+ echo "::warning::composer install failed in ${dir} — manifest does not ship vendor/; packaging the existing tree"
1064
+ fi
1065
+ done
1066
+ exit $rc
1067
+
1068
+ # Joomla-family only: mokocli release_package.php builds the extension zip
1069
+ # (+ sha256, attach). npm/mcp/client package/publish in their platform shim
1070
+ # (trust boundary, ADR #124), so this PHP zip step is skipped for them.
1071
+ - name: Optimize assets (artifact-time)
1072
+ if: steps.eligibility.outputs.proceed == 'true'
1073
+ continue-on-error: true
1074
+ run: |
1075
+ moko assets:optimize --path . 2>/dev/null || echo "::notice::moko assets:optimize unavailable or no assets — skipping"
1076
+
1077
+ - name: Build package and upload
1078
+ id: package
1079
+ if: >-
1080
+ steps.eligibility.outputs.publish == 'true' &&
1081
+ steps.eligibility.outputs.is_joomla_family == 'true'
1082
+ run: |
1083
+ VERSION="${{ steps.meta.outputs.version }}"
1084
+ TAG="${{ steps.meta.outputs.tag }}"
1085
+ STABILITY="${{ steps.meta.outputs.stability }}"
1086
+ API_BASE="${GIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1087
+ # Publish the pre-release asset under a VERSIONED rolling name
1088
+ # (pkg_<ext>-<version>-dev.zip / -rc.zip) replaced in place each build. The
1089
+ # release version is stable within a base version, so the asset URL stays
1090
+ # stable while the version is visible in the filename. The in-package
1091
+ # manifest <version> still advances (build metadata) so the updater keeps
1092
+ # detecting new builds.
1093
+ # PILOT: build the package via the MokoCLI build agent. `moko package`
1094
+ # wraps release_package.php UNCHANGED (standards preserved) and writes the
1095
+ # artifact to $RUNNER_TEMP (CI-provided temp dir) instead of a hardcoded
1096
+ # /tmp. The --stability/--rolling/--tag/attach contract is identical.
1097
+ moko package \
1098
+ --path . --version "$VERSION" --tag "$TAG" \
1099
+ --token "${MOKO_TOKEN}" --api-base "$API_BASE" \
1100
+ --repo "${GIT_REPO}" --out "$RUNNER_TEMP" \
1101
+ --stability "$STABILITY" --rolling
1102
+
1103
+ # updates.xml <downloadurl> is generated server-side by MokoGIT (the license
1104
+ # server; being moved to MokoSuiteLicensing). It must reference the VERSIONED
1105
+ # rolling asset (pkg_<ext>-<version>-dev.zip) that release_package.php now
1106
+ # publishes — keep the server-side generator in sync with that filename.
1107
+
1108
+ - name: "Delete lesser pre-release channels (cascade)"
1109
+ if: steps.eligibility.outputs.publish == 'true'
1110
+ continue-on-error: true
1111
+ run: |
1112
+ API_BASE="${GIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1113
+ TOKEN="${MOKO_TOKEN}"
1114
+
1115
+ php ${MOKO_CLI}/release_cascade.php \
1116
+ --stability "${{ steps.meta.outputs.stability }}" \
1117
+ --token "${TOKEN}" \
1118
+ --api-base "${API_BASE}"
1119
+
1120
+ - name: Summary
1121
+ if: always()
1122
+ run: |
1123
+ VERSION="${{ steps.meta.outputs.version }}"
1124
+ STABILITY="${{ steps.meta.outputs.stability }}"
1125
+ ZIP_NAME="${{ steps.meta.outputs.zip_name }}"
1126
+ SHA256="${{ steps.package.outputs.sha256_zip }}"
1127
+ echo "## Pre-Release Complete" >> $GITHUB_STEP_SUMMARY
1128
+ echo "" >> $GITHUB_STEP_SUMMARY
1129
+ echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY
1130
+ echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY
1131
+ echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
1132
+ echo "| Channel | ${STABILITY} |" >> $GITHUB_STEP_SUMMARY
1133
+ echo "| Package | \`${ZIP_NAME}\` |" >> $GITHUB_STEP_SUMMARY
1134
+ echo "| SHA-256 | \`${SHA256:-n/a}\` |" >> $GITHUB_STEP_SUMMARY