@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,1787 @@
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/auto-release.yml
10
+ # VERSION: 07.24.00
11
+ # BRIEF: NEW (07.24.00): npmjs.org publish now FAILS LOUD (::error:: + exit $rc) on an AUTHORIZATION error (404 "not in this registry" / 401 / 403 / ENEEDAUTH / EOTP) instead of swallowing EVERY failure as a non-failing ::warning:: — a missing-scope / unauthorized / revoked / expired NPM_TOKEN previously hid behind a green release fleet-wide (the "secondary registry, not failing the run" catch masked a 404 PUT, which is exactly how npm reports an unauthorized scoped write; CI had in fact NEVER successfully published any MCP package to npmjs while every run reported success). Transient/5xx/network failures still stay a non-failing ::warning:: (npmjs is the SECONDARY mirror; the MokoGIT registry is primary and already hard-fails), and an unset NPM_TOKEN still skips cleanly — so repos that do not publish to npmjs are 100% unaffected. PREVIOUS (07.23.00): "Vendor PHP dependencies (build-time)" added to BOTH publish legs (promote-rc + stable) — this workflow had NO build-time composer vendoring at all, so any component declaring runtime PHP deps via a sub-package composer.json (vendor-dir admin/vendor, e.g. mPDF in MokonyxCRM/MokonyxCRMDCM) shipped its rc/stable zip WITHOUT admin/vendor while the component manifest lists <folder>vendor</folder> — JInstaller then aborts the ENTIRE package install on-site ("File does not exist [ROOT][TMP]/.../admin/vendor" → "Failed to copy administrator files"). The step mirrors pre-release.yml 07.15.00: no-op without a sub-package composer.json; provisions ext-gd when missing (the release runners' php-cli lacks it, which failed mpdf dependency RESOLUTION outright); verifies the CONFIGURED vendor-dir (composer config vendor-dir) materialized autoload.php; FAILS the build loud when vendoring fails for a package whose manifest ships <folder>vendor</folder> (a green build with an install-bricking artifact is strictly worse than a red build); fail-soft warning for packages whose manifests do not reference vendor/. Runs before `moko publish` (release_publish.php → release_package.php zips the plain working tree; vendor/ was never excluded — the tree simply never had it in CI). PREVIOUS (07.22.00): #498 — the #469 SRC_DIR gate no longer self-traps, and the fast-merge guard is MINOR-identity aware (fixes stable version never advancing; MokoCLI stuck re-publishing v10.20.00). (1) The gate read SRC_DIR with grep|sed, capturing the LITERAL unexpanded `$(shell …)` from the fleet-standard `SRC_DIR := $(shell [ -d src ] && echo src || echo source)` — an unmatchable directory that forced should_bump=false on EVERY push even when source changed. It now resolves SRC_DIR via `make --eval` expansion, falls back to the grep|sed read only if make is unavailable, nulls out a still-unexpanded `$(...)`/backtick value, and treats a declared-but-NONEXISTENT SRC_DIR (repos with code in cli/lib/bin, not src/source) as the non-source denylist fallback instead of an unmatchable INCLUSION prefix. (2) The "Guarantee monotonic release version (fast-merge bump guard)" compared the FULL version, so dev's per-merge PATCH bumps (10.20.00→10.20.04) fooled it into "normal path, no heal" and it re-published the already-released vMAJOR.MINOR.00. It now compares the MINOR identity (MAJOR.MINOR), so a fast dev→stable merge with no promote-rc cuts a new MINOR. Fleet-wide release-semantics change — validate on one repo before syncing. PREVIOUS (07.21.00): #469 — gate the version BUMP on whether the push touched the Makefile SRC_DIR. The former non-code paths-ignore denylist on the push trigger is REMOVED: a docs/workflow/sample push no longer skips the pipeline, it now RUNS in no-bump "update the existing stable release in place" mode (re-stamps the UNCHANGED current version via the existing `moko publish --bump none --no-commit`, refreshes notes/assets, no version advance, no new vNN tag). A new early step "Resolve SRC_DIR bump gate (#469)" (id: srcgate) decides should_bump: (1) ELECTRON (file-detected) or opt-out vars.RELEASE_SRCDIR_GATE=off -> BYPASS, always bump as today (electron has NO source dir, so a naive SRC_DIR test would never bump — a release-breaking trap); (2) else SRC_DIR is REQUIRED — a non-electron repo with no resolvable SRC_DIR FAILS LOUD (::error:: + exit 1), never silently never-bump; (3) else diff the push range (github.event.before..GITHUB_SHA, with a HEAD^1 / merge-base fallback) intersected with ${SRC_DIR}/ — source hit -> should_bump=true, none -> should_bump=false. workflow_dispatch always bumps. The self-heal monotonic bump + the new vNN semver tag are now gated on should_bump!='false' so no-bump mode keeps the version PUT; the publish + "Update release notes and reconcile changelog" (channel-pointer resolution) run on BOTH paths to refresh the current release. Loop-safety is unchanged: the job if: still re-checks [skip ci]/[skip sync], so the workflow's own commits never re-trigger. Fleet-wide release-semantics change — validate on one repo before syncing. PREVIOUS (07.20.00): the dev/promote bump + release steps now name the per-run token env var GITHUB_TOKEN (was the misleadingly-named MOKOGIT_TOKEN; the VALUE was always ${{ github.token }}). All token use in this workflow is SAME-REPO (release API, the release_bump_dev dev base bump, pushes to its own repo) — the org MOKOGIT_TOKEN secret is not referenced here. Naming-only; no behavior change. Mirrors the pre-release.yml fix (MokoGIT#1721). PREVIOUS: NEW (07.19.00): #1621 Phase 6 / .mokogit-private#76 — resolve the CURRENT stable release by its CHANNEL POINTER, not by a fixed tag_name=stable. Under version-primary releases (a release is born with the immutable tag_name=vNN and `stable`/`rc` attached as movable is_channel tags), GET /releases/tags/stable no longer resolves the current stable release, which would break two steps that read "the last released version". Both now call GET /repos/{owner}/{repo}/releases/channel/stable (#1702 — resolves whichever release the `stable` channel points at via ReleaseStreamMap) with a GRACEFUL FALLBACK to the legacy GET /releases/tags/stable for forges predating #1702 (the channel route 404s pre-rollout and the rolling `stable` tag still exists). (1) The monotonic-version guard ("Guarantee monotonic release version") resolves ROLLING_LAST by parsing the same `VERSION: NN.NN.NN` out of the channel-resolved release NAME. (2) "Update release notes and reconcile changelog" resolves the stable RELEASE_ID + VERSION the same channel-first way. The #386 integrity gate is UNCHANGED (it already keys off the immutable vNN version tag). SEQUENCING unchanged: do NOT enable in prod until #1702 server support is on stable/prod; works pre-#1702 via the fallback. PREVIOUS (07.18.00): #1621 Phase 6 — version/* BRANCHES retired in favor of dual-tagged version RELEASES. REMOVED three now-redundant steps: "Step 12: Create version branch from main", "Step 13: Prune superseded-major version branches", and "Advance rolling channel tag via git". A release's permanent version identity is now the git tag v<version>, attached by the publish as a MOVABLE channel tag on the dual-tagged release (MokoCLI release_publish -> release_create channel_tags[]/update_stream; the forge attaches/moves the tag at the release commit via AttachChannelTag server-side) — so a new cut is born dual-tagged (rolling `stable`/`release-candidate` primary tag + permanent v<version> channel tag) and the channel tag MOVES onto the new release instead of a new version/* branch being created. The rolling `stable` tag is still pinned by release_create forceTagRef() (#808), so the "Advance rolling channel tag" force-push was redundant. KEPT UNCHANGED: "Create semver tag" (v<version>), the #386 integrity gate (still resolves tags/stable to the built commit), and the monotonic guard (still reads the stable release name VERSION: NN.NN.NN). Pruning EXISTING version/* branches is a SEPARATE one-time manual reconcile — not done here. SEQUENCING: do NOT enable in prod until the #1621 SERVER channel_tags support is on stable/prod. PREVIOUS (07.17.00): Setup MokoCLI tools no longer installs a STALE mokocli during the registry publish-lag window (#1671). After the fresh registry install, the step now compares the installed /tmp/mokocli PROJECT_VERSION against the /opt/mokocli failsafe and PREFERS whichever is NEWER (sort -V), and 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.16.00): release-integrity + dev-channel fixes. (1) A new pre-publish "no stale (pre-merge) tree" guard (#428/MokoCLI#901) hard-FAILS the release if the triggering GITHUB_SHA is an ANCESTOR of the current branch tip (a concurrent merge landed mid-run) -- otherwise a green, version-bumped release silently ships old code missing that merge (reproduced: built ef8c1a5d while tip was 2740342). Fail-open on any unresolved/replica-lagged tip so a good release is never falsely blocked. Complements the mokocli engine assertion (packaged-tree == GITHUB_SHA), which cannot see that GITHUB_SHA itself trails the tip. (2) The "Post-release: Reset dev version" safety-net (#309/MokoCLI#900) no longer swallows failure: a failed dev-advance now emits ::error:: + a job-summary block (dev-channel would trail stable), and a SUCCESSFUL advance dispatches pre-release.yml (development) to actually publish the leading dev build -- the advance commit is [skip ci]+paths-ignored and never auto-fires. Stays green (continue-on-error) since stable already published. NEW (07.15.00): the rolling stable channel tag is now advanced via a git force-push BEFORE the #386 integrity gate. MokoGIT's tags API 405s on the release-attached `stable` tag, so release_publish.php could not move it ("Could not pin tag stable ... HTTP 405") and every Joomla/electron stable cut left the tag lagging the freshly-built commit -- red-failing a correct release on the integrity gate. A new step git-force-pushes refs/tags/stable to the built HEAD (force-push allowlist -- the same escape hatch the rc BRANCH pin already uses), so the gate passes and no manual tag repoint is needed. PREVIOUS (07.14.00): integrity gate #386 no longer false-fails. It compared the published tag against GITHUB_SHA (the triggering commit), but the publish step commits the CHANGELOG entry ("build NN.NN.NN [skip ci]") and mokocli pins the channel tag + semver tag to that POST-PUBLISH HEAD -- so every cut whose CHANGELOG wasn't pre-seeded on dev (e.g. a version set straight on the release branch) diverged by one commit and red-failed a correct release. The gate now compares against the actual built/published HEAD (git rev-parse HEAD, falling back to GITHUB_SHA), which equals what the tag is pinned to in both the commit and no-commit cases while still catching a tag pinned to a commit we did NOT build; the semver tag is pinned to the same built HEAD for consistency. PREVIOUS (07.13.00): RC is ON-DEMAND ONLY. The `pull_request` trigger (RC-preview on a PR to main/stable) is REMOVED and the promote-rc job now runs solely on workflow_dispatch action=promote-rc. Only dev + stable release automatically (dev via pre-release.yml push->dev; stable via the push->main release job here); alpha/beta/rc never auto-cut. PREVIOUS (07.12.00): electron repos create NO release on ANY channel (RC enabled=false + stable publish/tag/notes/integrity guarded on is_electron; opt back in with vars.ELECTRON_CI_BUILD=true) and build NO installer in CI — always built locally + uploaded manually; the dev/promote version BUMP still runs. Template-Generic #390. NEW (07.11.00): the electron installer build/attach steps (electron-builder via Wine/Xvfb) are gated to MANUAL workflow_dispatch only (`&& github.event_name == 'workflow_dispatch'`). An automatic push no longer auto-builds electron installers (heavy ~90m, runner-fragile — OOM/Wine-zombie; migrating to mokocli ElectronPackager, MokoCLI #756/#758). The version bump, CHANGELOG, tag and npm publish STILL run on push — only the electron installer is skipped and pushed MANUALLY via dispatch. Non-electron platforms (joomla/go/npm) unaffected. Template-Generic #390. Universal build & release - BUMP-ON-DEV: base version + CHANGELOG promoted on dev pre-promotion (promote-rc), main publishes --bump none --no-commit so it never diverges from dev. MokoCLI build agent (moko publish). NEW (07.06.00): npm stable publishing ABSORBED here from Template-NPM's retired npm-publish.yml as npm-gated dual-registry steps (MokoGIT registry via per-run token + npmjs.org best-effort), replacing the former npm-publish.yml dispatch. No-Makefile repos SKIP the release job gracefully (green) instead of failing. Conflict-marker scan reads git-tracked files only (git ls-files | xargs grep) so vendored/generated trees can't false-match. NEW (07.07.00): npm stamp normalizes the zero-padded Makefile PROJECT_VERSION (NN.NN.NN) to strict semver via awk before `npm version`, and an unstampable version now HARD-FAILS instead of silently keeping the old package.json version (#382). NEW (07.00.00): `moko assets:optimize` artifact-time step added before publish (graceful-skip on non-asset repos); Setup MokoCLI tools makes the fresh `moko` executable + first on PATH to fix exit-126 (mokocli #650).
12
+ #
13
+ # +=======================================================================+
14
+ # | UNIVERSAL BUILD & RELEASE PIPELINE |
15
+ # +=======================================================================+
16
+ # | |
17
+ # | Native-manifest platform detect (joomla|generic) drives branching. |
18
+ # | |
19
+ # | Platform-specific: |
20
+ # | joomla: XML manifest, type-prefixed packages |
21
+ # | generic: README-only, no update stream |
22
+ # | |
23
+ # +=======================================================================+
24
+
25
+ name: "Universal: Build & Release"
26
+
27
+ on:
28
+ # Stable release on every PUSH to main (a merged promotion) -- the same
29
+ # push-based principle as pre-release's dev-channel build. A push trigger is
30
+ # race-free vs the old pull_request:closed, which could miss its run when this
31
+ # workflow file re-synced from the template concurrently with the merge.
32
+ # Loop-safe: the workflow's own commits to main carry [skip ci]/[skip sync]
33
+ # (mokocli's build commit + the changelog-promote commit), and the release job's
34
+ # if: re-checks [skip ci]/[skip sync], so a self-push never re-triggers a release.
35
+ # #469: the former non-code paths-ignore denylist (which SKIPPED docs/CI-only
36
+ # pushes entirely) is REMOVED. A docs/workflow/sample push must now RUN the
37
+ # pipeline in no-bump "update existing release" mode (refresh notes/assets on the
38
+ # current stable release, no version bump, no new vNN tag) rather than be dropped.
39
+ # The bump-vs-no-bump decision is made in-job by the SRC_DIR-diff gate (the
40
+ # "Resolve SRC_DIR bump gate" step: steps.srcgate.outputs.should_bump), not by a
41
+ # trigger-level path filter — so no release signal is ever lost. Loop-safety is
42
+ # still fully covered by the [skip ci]/[skip sync] re-checks in the job if:.
43
+ push:
44
+ branches:
45
+ - main
46
+ - stable
47
+ workflow_dispatch:
48
+ inputs:
49
+ action:
50
+ description: 'Action to perform'
51
+ required: false
52
+ type: choice
53
+ default: release
54
+ options:
55
+ - release
56
+ - promote-rc
57
+
58
+ env:
59
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
60
+ MOKOGIT_URL: ${{ vars.MOKOGIT_URL || 'https://git.mokoconsulting.tech' }}
61
+ GIT_ORG: ${{ vars.MOKOGIT_ORG || github.repository_owner }}
62
+ GIT_REPO: ${{ vars.MOKOGIT_REPO || github.event.repository.name }}
63
+
64
+ permissions:
65
+ contents: write
66
+ pull-requests: write
67
+ packages: write
68
+
69
+ concurrency:
70
+ # Serialise the release path per CHANNEL BRANCH (ref), not per commit (#423):
71
+ # a rapid sequence of DISTINCT stable commits shares one group, so the newest
72
+ # push SUPERSEDES older *queued* releases (concurrency keeps one running + only
73
+ # the latest pending, cancelling older pending runs) -- avoiding a full
74
+ # build+publish+deploy of a commit that has already been superseded. Same-commit
75
+ # push / schedule / workflow_dispatch still collapse (same ref), preserving the
76
+ # fleet sync-burst de-dup. cancel-in-progress stays FALSE so a publish already in
77
+ # progress is NEVER cancelled (cancelling mid-publish could leave a half-created
78
+ # release -- npmjs published but Gitea-registry publish dropped); only *pending*
79
+ # superseded runs are dropped. Mirrors pre-release.yml's per-ref grouping.
80
+ group: auto-release-${{ github.workflow }}-${{ github.ref }}
81
+ cancel-in-progress: false
82
+
83
+ jobs:
84
+ # -- On-demand RC promote (workflow_dispatch action=promote-rc) --------------------
85
+ # RC is NO LONGER auto-built on a PR to main/stable. A release-candidate is cut ONLY
86
+ # when a human runs this workflow with action=promote-rc. Only dev + stable release
87
+ # automatically (dev via pre-release.yml push->dev; stable via the push->main job below).
88
+ promote-rc:
89
+ name: Promote to RC
90
+ # LITERAL runner label — see #366/#134. Gitea does not resolve the
91
+ # `${{ vars.RELEASE_RUNNER_LABEL || 'release' }}` fallback for an unset var in
92
+ # `runs-on`, so the job aborts with no runner. The registered label is `release`.
93
+ runs-on: release
94
+ # Own-repo writes: move rc + create release (contents:write), and dispatch
95
+ # deploy-rc (actions:write) -- all via the per-repo github.token.
96
+ permissions:
97
+ contents: write
98
+ pull-requests: write
99
+ actions: write
100
+ # Skip on template repos (Template-*) -- they scaffold other repos and do not release.
101
+ # RC is ON-DEMAND ONLY: this job runs solely on a manual workflow_dispatch with
102
+ # action=promote-rc. The former auto-trigger (PR dev->main/stable) was removed so a
103
+ # promotion PR no longer auto-cuts an RC (and template-sync PRs can't spawn an
104
+ # RC-promote storm). A human explicitly chooses to run it.
105
+ if: >-
106
+ github.event.repository.name != '.vault' &&
107
+ github.event_name == 'workflow_dispatch' &&
108
+ inputs.action == 'promote-rc'
109
+
110
+ steps:
111
+ - name: Checkout repository
112
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
113
+ with:
114
+ token: ${{ github.token }}
115
+ fetch-depth: 1
116
+ submodules: false
117
+
118
+ # #1672: the actions/checkout above intermittently aborts at step-0 under
119
+ # release-time load on the shared `release` runners (git fetch timeout /
120
+ # workspace contention). A plain re-run usually gets a clean checkout, so the
121
+ # fault is transient/environmental, not code. This fallback fires ONLY when
122
+ # the checkout above failed (step-level `if: failure()` — a LITERAL, never a
123
+ # continue-on-error expression, which Gitea would discard the whole workflow
124
+ # over) and re-materialises the workspace via a bounded 3-attempt git clone
125
+ # with backoff. It does NOT mask real failures: a genuinely bad ref makes all
126
+ # three attempts fail and the step exits 1 (loud). Independent step — no
127
+ # needs:+always() fan-in (which act_runner strands).
128
+ - name: "Checkout fallback (retry transient flake) (#1672)"
129
+ if: failure()
130
+ env:
131
+ GITHUB_TOKEN: ${{ github.token }}
132
+ run: |
133
+ set -u
134
+ TOK="$(printf '%s' "$GITHUB_TOKEN" | tr -d '[:space:]')"; echo "::add-mask::$TOK"
135
+ SERVER="${GITHUB_SERVER_URL:-https://git.mokoconsulting.tech}"
136
+ CLONE_URL="https://x-access-token:${TOK}@${SERVER#https://}/${GITHUB_REPOSITORY}.git"
137
+ REF="${GITHUB_SHA:-${GITHUB_REF_NAME:-}}"
138
+ echo "Retrying checkout of ${GITHUB_REPOSITORY}@${REF:-<HEAD>} into ${GITHUB_WORKSPACE}"
139
+ ok=0
140
+ for attempt in 1 2 3; do
141
+ rm -rf "${GITHUB_WORKSPACE:?}/.git" 2>/dev/null || true
142
+ find "${GITHUB_WORKSPACE}" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true
143
+ if git clone --depth 1 "$CLONE_URL" "${GITHUB_WORKSPACE}" \
144
+ && { [ -z "$REF" ] || git -C "${GITHUB_WORKSPACE}" fetch --depth 1 origin "$REF" && git -C "${GITHUB_WORKSPACE}" checkout -f FETCH_HEAD; }; then
145
+ ok=1; echo "Fallback checkout succeeded on attempt ${attempt}."; break
146
+ fi
147
+ echo "::warning::Fallback checkout attempt ${attempt} failed; backing off $((attempt * 5))s"
148
+ sleep $((attempt * 5))
149
+ done
150
+ if [ "$ok" != "1" ]; then
151
+ echo "::error::Checkout failed after 3 retry attempts (#1672) — this is not a transient flake, failing loudly."
152
+ exit 1
153
+ fi
154
+
155
+ - name: "Require Makefile (auto-release gate)"
156
+ # RC promotion is part of auto-release; it must also refuse to run without
157
+ # a Makefile (see the release job for rationale). Post-checkout step because
158
+ # a job-level if: cannot inspect the workspace.
159
+ run: |
160
+ if [ ! -f Makefile ] && [ ! -f makefile ] && [ ! -f GNUmakefile ]; then
161
+ echo "::error::auto-release (RC promote) requires a Makefile at the repository root -- none found. Aborting."
162
+ echo "## RC promote blocked: no Makefile at repo root" >> "$GITHUB_STEP_SUMMARY"
163
+ exit 1
164
+ fi
165
+ echo "Makefile present -- RC promote may proceed."
166
+
167
+ - name: Setup MokoCLI tools
168
+ run: |
169
+ # Install mokocli FRESH from the MokoGIT Composer registry on every run
170
+ # (PRIMARY) so CI always uses the latest published version. /opt/mokocli is
171
+ # a FAILSAFE only — its refresh unit (mokocli-sync.service) is disabled, so
172
+ # the on-disk snapshot rots and must NOT be preferred. Registry path
173
+ # /api/packages/** is anon-allowlisted (no GITHUB_TOKEN); it serves a dist
174
+ # zip (no git clone). --no-install: mokocli self-bootstraps via bundled lib/
175
+ # + a fallback PSR-4 autoloader (native ext-curl), so no vendor/ is needed.
176
+ if ! command -v composer > /dev/null 2>&1; then
177
+ 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
178
+ fi
179
+ rm -rf /tmp/mokocli
180
+ REG='{"type":"composer","url":"https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer"}'
181
+ if composer create-project --no-install --no-interaction --no-progress --quiet \
182
+ --repository="${REG}" mokoconsulting/mokocli /tmp/mokocli >/dev/null 2>&1 \
183
+ && [ -f /tmp/mokocli/cli/version_bump.php ]; then
184
+ echo "Installed mokocli fresh from the Composer registry (primary)"
185
+ # #1671: registry-publish LAG race. The registry install "succeeds" but can
186
+ # serve the PREVIOUS package for ~tens of seconds after a MokoCLI release
187
+ # (publish finishes AFTER the release run reports success). Meanwhile the
188
+ # /opt/mokocli deploy (pull-timer) may already carry the just-released,
189
+ # NEWER engine. So instead of blindly using the registry copy, compare the
190
+ # two on-disk versions and PREFER whichever is newer. The Makefile
191
+ # PROJECT_VERSION is the version source of truth and ships in both the
192
+ # composer dist and /opt/mokocli. Backward-compatible: with no /opt/mokocli
193
+ # (most consumer runners), the registry copy is used exactly as before.
194
+ mk_ver() { sed -n 's/^[[:space:]]*PROJECT_VERSION[[:space:]]*:*=[[:space:]]*//p' "$1" 2>/dev/null | head -n1 | tr -d '[:space:]'; }
195
+ ver_newer() { [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ]; }
196
+ REG_VER="$(mk_ver /tmp/mokocli/Makefile)"
197
+ OPT_VER="$(mk_ver /opt/mokocli/Makefile)"
198
+ MOKO_ROOT=/tmp/mokocli
199
+ if [ -f /opt/mokocli/cli/version_bump.php ] && [ -n "$OPT_VER" ] && [ -n "$REG_VER" ] \
200
+ && ver_newer "$OPT_VER" "$REG_VER"; then
201
+ echo "::notice::/opt/mokocli (${OPT_VER}) is newer than the registry copy (${REG_VER}) — using /opt (#1671 publish-lag race)."
202
+ MOKO_ROOT=/opt/mokocli
203
+ else
204
+ echo "Using registry copy (${REG_VER:-unknown}); /opt/mokocli=${OPT_VER:-none}"
205
+ fi
206
+ echo "MOKO_CLI=${MOKO_ROOT}/cli" >> $GITHUB_ENV
207
+ # #650: make the chosen moko executable + FIRST on PATH so bare `moko`
208
+ # never resolves to a stale/non-executable copy (exit 126).
209
+ for d in bin cli; do
210
+ if [ -f "${MOKO_ROOT}/$d/moko" ]; then
211
+ # #1263 defense-in-depth: strip CR bytes so a CRLF-tainted registry
212
+ # dist or rotted /opt snapshot can't yield `/usr/bin/env: 'php\r'`
213
+ # (exit 127). tr interprets \r as a real CR (unlike portable sed).
214
+ # /opt may be root-owned — write via a temp file + sudo when needed.
215
+ if tr -d '\r' < "${MOKO_ROOT}/$d/moko" > "/tmp/moko.lf" 2>/dev/null; then
216
+ if cp "/tmp/moko.lf" "${MOKO_ROOT}/$d/moko" 2>/dev/null; then :; \
217
+ else sudo cp "/tmp/moko.lf" "${MOKO_ROOT}/$d/moko" 2>/dev/null || true; fi
218
+ rm -f "/tmp/moko.lf"
219
+ fi
220
+ chmod +x "${MOKO_ROOT}/$d/moko" 2>/dev/null || sudo chmod +x "${MOKO_ROOT}/$d/moko" 2>/dev/null || true
221
+ echo "${MOKO_ROOT}/$d" >> "$GITHUB_PATH"
222
+ fi
223
+ done
224
+ sudo chmod +x /opt/mokocli/*/moko 2>/dev/null || true
225
+ # Opportunistically refresh the /opt failsafe from this fresh copy when it
226
+ # differs — keeps the disabled-sync snapshot current so the fallback is
227
+ # never far behind. Best-effort: needs sudo, gated on a diff to avoid
228
+ # fleet-wide churn, never fails the job. #1671: only refresh FORWARD — skip
229
+ # when /opt is the newer copy we just chose, so we never downgrade it.
230
+ if [ "$MOKO_ROOT" = /tmp/mokocli ] && command -v sudo >/dev/null 2>&1 && [ -d /opt/mokocli ] \
231
+ && ! cmp -s /tmp/mokocli/cli/version_reset_dev.php /opt/mokocli/cli/version_reset_dev.php 2>/dev/null; then
232
+ sudo cp -a /tmp/mokocli/. /opt/mokocli/ 2>/dev/null \
233
+ && echo "Refreshed /opt/mokocli failsafe from the fresh registry copy" \
234
+ || echo " (/opt failsafe refresh skipped — no sudo/perms)"
235
+ fi
236
+ elif [ -f /opt/mokocli/cli/version_bump.php ]; then
237
+ echo "::warning::Registry install failed — using /opt/mokocli FAILSAFE (may be stale)"
238
+ echo "MOKO_CLI=/opt/mokocli/cli" >> $GITHUB_ENV
239
+ else
240
+ echo "::error::mokocli unavailable: registry install failed and no /opt/mokocli failsafe present"
241
+ exit 1
242
+ fi
243
+
244
+ - name: "Bump base version + promote changelog on dev (pre-promotion)"
245
+ env:
246
+ # Contents-API writes to protected dev need the admin-bypass token
247
+ # (github.token cannot bypass branch protection). Same token/primitive
248
+ # as version_reset_dev.php and the changelog persist below (#510).
249
+ GITHUB_TOKEN: ${{ github.token }}
250
+ # Pass the PR head ref via env — never interpolate ${{ }} into run:
251
+ # (a crafted branch name could inject shell). #1823
252
+ HEAD_REF: ${{ github.event.pull_request.head.ref }}
253
+ run: |
254
+ # "Bump on dev" (release-engineering inversion): move the base-version
255
+ # bump AND the CHANGELOG [Unreleased] promotion to dev BEFORE the
256
+ # promotion, so the rc cut (force-pushed from dev below) and the eventual
257
+ # main INHERIT the final version + promoted changelog. Nothing new is
258
+ # minted on main, so dev stays a clean superset and dev->main stops
259
+ # conflicting on Makefile/manifest/package.json/CHANGELOG.
260
+ #
261
+ # Only dev-headed promotions bump minor. fix/patch/hotfix/bugfix work is
262
+ # patch-bumped per-merge on dev by pre-release.yml (there is NO separate
263
+ # auto-bump.yml) — do NOT double-bump them (mirrors the release job's bump-level gate). Only runs
264
+ # for a real dev promotion (PR head == dev), never for a workflow_dispatch
265
+ # promote-rc (no PR head) — which is a manual re-cut of the current dev.
266
+ if [ "$HEAD_REF" != "dev" ]; then
267
+ echo "PR head '${HEAD_REF:-<none>}' is not dev — skipping dev pre-bump (fix/hotfix or manual re-cut)."
268
+ exit 0
269
+ fi
270
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
271
+ # release_bump_dev.php: minor-bump the base on dev + promote CHANGELOG on
272
+ # dev, both via the Contents API. Idempotent + monotonic (never moves dev
273
+ # down), so a PR re-synchronize that re-runs this step is a safe no-op.
274
+ php ${MOKO_CLI}/release_bump_dev.php \
275
+ --path . --branch dev --bump minor \
276
+ --token "${GITHUB_TOKEN}" --api-base "${API_BASE}"
277
+
278
+ - name: "Resolve releases-enabled"
279
+ id: rel
280
+ run: |
281
+ # Releases on/off gate (.mokogit-private#73): the pre-promotion BUMP
282
+ # above runs on every repo; RC-PROMOTION (rename rc, deploy, publish)
283
+ # is gated on the repo's Releases unit so releases-off repos (Template-*)
284
+ # bump WITHOUT promoting/publishing. Replaces the old
285
+ # !startsWith(name,'Template-') name gate. Defaults ON unless the API
286
+ # explicitly returns has_releases=false (transient error never disables).
287
+ set +e
288
+ HR=$(curl -sf -H "Authorization: token ${{ github.token }}" "${MOKOGIT_URL}/api/v1/repos/${{ github.repository }}" | jq -r '.has_releases' 2>/dev/null)
289
+ if [ "$HR" = "false" ]; then EN=false; else EN=true; fi
290
+ # Electron: no CI release on ANY channel (local build + manual upload);
291
+ # opt back in with vars.ELECTRON_CI_BUILD=true. The dev bump above still runs.
292
+ if { [ -f electron-builder.yml ] || [ -f electron-builder.yaml ]; } && [ "${{ vars.ELECTRON_CI_BUILD }}" != "true" ]; then
293
+ EN=false
294
+ echo "::notice::electron repo — RC release disabled (local build + manual upload)"
295
+ fi
296
+ echo "enabled=${EN}" >> "$GITHUB_OUTPUT"
297
+ echo "RC-promotion gate: enabled=${EN} (has_releases=${HR:-unknown})"
298
+
299
+ - name: Rename branch to rc
300
+ if: steps.rel.outputs.enabled == 'true'
301
+ env:
302
+ GITHUB_TOKEN: ${{ github.token }}
303
+ HEAD_REF: ${{ github.event.pull_request.head.ref }}
304
+ run: |
305
+ PUSH_TOK="$(printf '%s' "$GITHUB_TOKEN" | tr -d '[:space:]')"; [ -z "$PUSH_TOK" ] && PUSH_TOK="${{ github.token }}"; echo "::add-mask::$PUSH_TOK"
306
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
307
+ AUTH="Authorization: token ${{ github.token }}"
308
+ FROM="${HEAD_REF:-dev}"
309
+ PR="${{ github.event.pull_request.number }}"
310
+
311
+ # Resolve the source branch HEAD commit.
312
+ SRC_JSON=$(curl -sf -H "$AUTH" "${API_BASE}/branches/${FROM}") \
313
+ || { echo "::error::Source branch ${FROM} not found"; exit 1; }
314
+ SRC_SHA=$(printf '%s' "$SRC_JSON" | python3 -c "import sys, json; print(json.load(sys.stdin)['commit']['id'])" 2>/dev/null || true)
315
+ [ -n "$SRC_SHA" ] || { echo "::error::Could not resolve HEAD of ${FROM}"; exit 1; }
316
+
317
+ # Point rc at the source commit via git push. Git's git/refs PATCH API
318
+ # returns HTTP 405 on ANY protected branch (force or not, even for a user in
319
+ # the force-push allowlist), so it cannot move a protected rc. git push honors
320
+ # the push + force-push allowlists and creates rc if it is absent.
321
+ PUSH_URL="https://x-access-token:${PUSH_TOK}@${MOKOGIT_URL#https://}/${GIT_ORG}/${GIT_REPO}.git"
322
+ git config --global user.name "mokogit-actions[bot]"
323
+ git config --global user.email "actions@mokoconsulting.tech"
324
+ git fetch --no-tags "$PUSH_URL" "${FROM}"
325
+ git push --force "$PUSH_URL" "FETCH_HEAD:refs/heads/rc" \
326
+ || { echo "::error::Failed to point rc at ${FROM} (${SRC_SHA}) via git push"; exit 1; }
327
+ echo "rc set to ${FROM} (${SRC_SHA})"
328
+
329
+ # Repoint the PR at rc, then delete the old source branch (non-fatal).
330
+ if [ -n "$PR" ]; then
331
+ curl -s -X PATCH -H "$AUTH" -H "Content-Type: application/json" \
332
+ "${API_BASE}/pulls/${PR}" -d '{"head":"rc"}' >/dev/null || true
333
+ fi
334
+ # Never delete permanent branches (dev/main/rc/...); only ephemeral feature branches.
335
+ case "$FROM" in
336
+ dev|main|master|rc|stable|production|release|develop|staging|beta|alpha)
337
+ echo "Keeping permanent branch ${FROM} (not deleting)" ;;
338
+ *)
339
+ curl -s -X DELETE -H "$AUTH" "${API_BASE}/branches/${FROM}" >/dev/null || true ;;
340
+ esac
341
+ echo "Renamed ${FROM} -> rc"
342
+
343
+ # RC deploy is now INLINE (see "Deploy to RC" below, after the rc checkout).
344
+ # The former "Trigger RC deploy" step dispatched a non-existent deploy-rc.yml
345
+ # and always warn-failed; the standalone deploy.yml is deleted in #1427.
346
+
347
+ - name: Checkout rc and configure git
348
+ if: steps.rel.outputs.enabled == 'true'
349
+ env:
350
+ GITHUB_TOKEN: ${{ github.token }}
351
+ run: |
352
+ PUSH_TOK="$(printf '%s' "$GITHUB_TOKEN" | tr -d '[:space:]')"; [ -z "$PUSH_TOK" ] && PUSH_TOK="${{ github.token }}"; echo "::add-mask::$PUSH_TOK"
353
+ # Pin the BRANCH ref (refs/heads/rc): the channel-named branch model (#1294)
354
+ # lets an `rc` release TAG and the `rc` BRANCH coexist, so a bare
355
+ # `git fetch origin rc` / `git checkout rc` is ambiguous and could grab the tag.
356
+ git fetch origin "+refs/heads/rc:refs/remotes/origin/rc"
357
+ git checkout -B rc origin/rc
358
+ git config --local user.email "mokogit-actions[bot]@mokoconsulting.tech"
359
+ git config --local user.name "mokogit-actions[bot]"
360
+ git remote set-url origin "https://x-access-token:${PUSH_TOK}@git.mokoconsulting.tech/${{ github.repository }}.git"
361
+
362
+ # Build-time PHP dependency vendoring (universal, guarded — mirrors
363
+ # pre-release.yml 07.15.00). If a sub-package ships a composer.json, fetch its
364
+ # runtime deps into that package's CONFIGURED vendor dir (e.g. admin/vendor —
365
+ # mPDF) BEFORE `moko publish` zips the working tree. No-op without a
366
+ # sub-package composer.json. FAILS LOUD when vendoring fails for a package
367
+ # whose Joomla manifest ships <folder>vendor</folder>: shipping that zip
368
+ # without vendor/ makes JInstaller abort the whole package install on-site
369
+ # ("File does not exist .../admin/vendor" — MokonyxCRMDCM/MokonyxCRM), which
370
+ # is strictly worse than a red release run.
371
+ - name: Vendor PHP dependencies (build-time)
372
+ if: steps.rel.outputs.enabled == 'true'
373
+ run: |
374
+ set -uo pipefail
375
+ CJS="$(find source/packages src/packages -maxdepth 2 -name composer.json 2>/dev/null || true)"
376
+ if [ -z "$CJS" ]; then echo "No sub-package composer.json — nothing to vendor."; exit 0; fi
377
+ command -v composer >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq composer >/dev/null 2>&1 || true; }
378
+ # mPDF (and most PHP PDF/image libs) hard-require ext-gd; the runners'
379
+ # php-cli ships without it, which fails dependency RESOLUTION outright.
380
+ if ! php -m 2>/dev/null | grep -qi '^gd$'; then
381
+ PHPV="$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;' 2>/dev/null || true)"
382
+ sudo apt-get update -qq || true
383
+ 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})"
384
+ php -m 2>/dev/null | grep -qi '^gd$' || echo "::warning::ext-gd STILL missing after install attempt — vendoring will fail for gd-requiring packages"
385
+ fi
386
+ rc=0
387
+ for cj in $CJS; do
388
+ dir="$(dirname "$cj")"
389
+ echo "::notice::composer install (no-dev) in ${dir}"
390
+ ok=true
391
+ composer install --no-dev --no-interaction --no-progress --optimize-autoloader --working-dir="$dir" || ok=false
392
+ vd="$(composer config vendor-dir --working-dir="$dir" 2>/dev/null || true)"; vd="${vd:-vendor}"
393
+ [ -f "${dir}/${vd}/autoload.php" ] || ok=false
394
+ if [ "$ok" = "true" ]; then
395
+ echo "vendored: ${dir}/${vd}"
396
+ elif grep -qs '<folder>vendor</folder>' "${dir}"/*.xml; then
397
+ 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."
398
+ rc=1
399
+ else
400
+ echo "::warning::composer install failed in ${dir} — manifest does not ship vendor/; packaging the existing tree"
401
+ fi
402
+ done
403
+ exit $rc
404
+
405
+ - name: Publish RC release
406
+ if: steps.rel.outputs.enabled == 'true'
407
+ continue-on-error: true
408
+ run: |
409
+ # PILOT: call the MokoCLI build agent's publish verb (generic git-release).
410
+ # Standards preserved -- `moko publish` maps to release_publish.php UNCHANGED
411
+ # for the generic platform; the stability/bump/token contract is identical
412
+ # to the former direct invocation. No --branch: release_publish auto-detects
413
+ # the branch (here rc, from the checkout above).
414
+ #
415
+ # --bump none (bump-on-dev): the base was already minor-bumped on dev by
416
+ # the pre-promotion step above, and rc was force-pushed FROM that dev, so
417
+ # rc already carries the final base. Bumping again here would double-bump
418
+ # (dev=+1, rc=+2). --no-commit: don't commit a version change back to rc
419
+ # (the rc tip must stay identical to the promoted dev). The RC release
420
+ # still stamps the -rc suffix on the inherited base in-tree.
421
+ moko publish \
422
+ --path . --stability rc --bump none --no-commit \
423
+ --token "${{ github.token }}"
424
+
425
+ - name: "Deploy to RC (inline — replaces standalone deploy.yml, #1427)"
426
+ if: steps.rel.outputs.enabled == 'true' && vars.RC_SSH_USERNAME != ''
427
+ env:
428
+ DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
429
+ run: |
430
+ # #1427: deploy is inlined into the release pipeline; the standalone
431
+ # deploy.yml is deleted. Gate = RC_SSH_USERNAME set (the same signal
432
+ # deploy.yml used) so only SSH-deploy-onboarded repos deploy; every
433
+ # other repo/platform skips green. Targets come from repo VARIABLES,
434
+ # never assumed. Deploy LOGIC stays server-side: mokocli deploy.php
435
+ # SSHes the restricted moko-deploy pattern, which builds+deploys+health-
436
+ # checks (with rollback) and, on RC, runs the clean-room RC_SEED + RC
437
+ # Build Tests. A failed RC deploy fails promote-rc — the stable gate.
438
+ set -euo pipefail
439
+ if [ -z "${DEPLOY_KEY:-}" ]; then
440
+ echo "::warning::RC_SSH_USERNAME set but DEPLOY_SSH_KEY secret missing — skipping RC deploy"; exit 0
441
+ fi
442
+ mkdir -p ~/.ssh
443
+ # DEPLOY_SSH_KEY is base64-encoded (single line) so act_runner masks it in logs
444
+ # and the multi-line key is never echoed. Decode when it base64-decodes to a
445
+ # private key; else treat as a legacy raw PEM (back-compat during rollout).
446
+ install -m600 /dev/null ~/.ssh/deploy_key # 0600 before any bytes (no umask window)
447
+ trap 'rm -f ~/.ssh/deploy_key' EXIT # never leave the key on a shared runner
448
+ if printf '%s' "$DEPLOY_KEY" | base64 -d 2>/dev/null | grep -q 'PRIVATE KEY'; then
449
+ printf '%s' "$DEPLOY_KEY" | base64 -d > ~/.ssh/deploy_key
450
+ else
451
+ echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
452
+ fi
453
+ TAG="$(git describe --tags --always 2>/dev/null || git rev-parse --short HEAD)-rc"
454
+ echo "Deploying rc tag ${TAG} to ${{ vars.RC_SSH_USERNAME }}@${{ vars.RC_SSH_HOST }}"
455
+ php "${MOKO_CLI}/deploy.php" --tier rc --tag "$TAG" \
456
+ --ssh-host "${{ vars.RC_SSH_HOST }}" --ssh-port "${{ vars.RC_SSH_PORT }}" \
457
+ --ssh-user "${{ vars.RC_SSH_USERNAME }}" --ssh-key ~/.ssh/deploy_key
458
+
459
+ - name: Update RC release notes from CHANGELOG.md
460
+ if: steps.rel.outputs.enabled == 'true'
461
+ continue-on-error: true
462
+ run: |
463
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
464
+ TOKEN="${{ github.token }}"
465
+
466
+ # Extract [Unreleased] section via the shared mokocli command (#364 centralization).
467
+ NOTES=""
468
+ if [ -f "release_notes.md" ] && [ -s "release_notes.md" ]; then
469
+ NOTES="$(cat release_notes.md)"
470
+ fi
471
+ # #71: fall back to CHANGELOG [Unreleased] when no curated release_notes.md
472
+ if [ -z "$NOTES" ] && [ -f "CHANGELOG.md" ]; then
473
+ NOTES=$(php ${MOKO_CLI}/release_notes.php --path . --version Unreleased 2>/dev/null || true)
474
+ # release_notes.php echoes "Release Unreleased" when the section is empty; normalize
475
+ # to empty so the platform fallback below applies.
476
+ [ "$NOTES" = "Release Unreleased" ] && NOTES=""
477
+ fi
478
+ [ -z "$NOTES" ] && NOTES="Release candidate"
479
+
480
+ # Find the RC release and update its body
481
+ RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
482
+ "${API_BASE}/releases/tags/release-candidate" \
483
+ | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
484
+
485
+ if [ -n "$RELEASE_ID" ]; then
486
+ python3 -c "
487
+ import json, urllib.request
488
+ body = open('/dev/stdin').read()
489
+ payload = json.dumps({'body': body}).encode()
490
+ req = urllib.request.Request(
491
+ '${API_BASE}/releases/${RELEASE_ID}',
492
+ data=payload, method='PATCH',
493
+ headers={
494
+ 'Authorization': 'token ${TOKEN}',
495
+ 'Content-Type': 'application/json'
496
+ })
497
+ urllib.request.urlopen(req)
498
+ " <<< "$NOTES"
499
+ echo "RC release notes updated from CHANGELOG.md"
500
+ fi
501
+
502
+ - name: Summary
503
+ if: always()
504
+ run: |
505
+ echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
506
+ echo "Base bumped + changelog promoted on dev (pre-promotion); rc cut from dev; RC release built" >> $GITHUB_STEP_SUMMARY
507
+
508
+ # -- Merged PR -> Build & Release (or promote RC to stable) -------------------------
509
+ release:
510
+ name: Build & Release Pipeline
511
+ # LITERAL runner label — see #366/#134. Gitea does not resolve the
512
+ # `${{ vars.RELEASE_RUNNER_LABEL || 'release' }}` fallback for an unset var in
513
+ # `runs-on`, so the job aborts with no runner. The registered label is `release`.
514
+ runs-on: release
515
+ # Release-create (vNN tag + release entry), rc-tag move, push, npm-registry publish,
516
+ # and the post-release dispatch are all contents/packages/actions WRITES. Gitea's
517
+ # auto-token derives perms from the JOB-level permissions block ONLY (workflow-level
518
+ # `permissions:` is NOT inherited into a job's token); without this block the token
519
+ # falls to the org default (empty) and POST /releases 404s fleet-wide (#1712).
520
+ permissions:
521
+ contents: write
522
+ packages: write
523
+ actions: write
524
+ # Skip on template repos (Template-*) -- they scaffold other repos and do not release.
525
+ # Stable release fires on a merged PR into main (pull_request:closed + merged) OR a
526
+ # direct PUSH to main. The pull_request path is the reliable one -- a PR-merge does not
527
+ # always emit a base-branch push event to Actions, so relying on push alone silently
528
+ # skipped releases (a nightly scheduled safety-net used to be the band-aid; it and its
529
+ # cron have been removed -- the pull_request path is the durable fix). The release
530
+ # steps already read github.event.pull_request.head.ref, so they handle both events.
531
+ # Loop guard: skip a self-push that carries
532
+ # [skip ci]/[skip sync] (mokocli's build commit + the changelog-promote commit).
533
+ # #469: the non-code paths-ignore was REMOVED, so a docs/CI-only push now reaches
534
+ # this job and runs in no-bump update mode (gated by steps.srcgate below) instead
535
+ # of being dropped; the [skip ci]/[skip sync] re-checks here remain the loop guard.
536
+ # workflow_dispatch stays exempt (explicit human run).
537
+ # STABLE-RELEASE GATE (default_branch == 'main'): a stable release only fires when
538
+ # the repo's DEFAULT branch is main. main's mere existence no longer arms stable
539
+ # releases — so every repo can pre-create all channel branches (dev/rc/beta/alpha/main)
540
+ # and have them sit idle. A repo promotes itself to "stable-ready" by setting its
541
+ # default to main; new/in-development repos keep default 'dev' and never auto-release.
542
+ if: >-
543
+ !startsWith(github.event.repository.name, 'Template-') && github.event.repository.name != '.vault' &&
544
+ (github.event.repository.default_branch == 'main' || github.event.repository.default_branch == 'stable') &&
545
+ (
546
+ (
547
+ github.event_name == 'push' &&
548
+ !contains(github.event.head_commit.message, '[skip ci]') &&
549
+ !contains(github.event.head_commit.message, '[skip sync]')
550
+ ) ||
551
+ (
552
+ github.event_name == 'pull_request' &&
553
+ github.event.action == 'closed' &&
554
+ github.event.pull_request.merged == true &&
555
+ github.event.pull_request.base.ref == github.event.repository.default_branch &&
556
+ !contains(github.event.pull_request.title, '[skip ci]') &&
557
+ !contains(github.event.pull_request.title, '[skip sync]')
558
+ ) ||
559
+ (github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
560
+ )
561
+
562
+ steps:
563
+ - name: Checkout repository
564
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
565
+ with:
566
+ token: ${{ github.token }}
567
+ fetch-depth: 0
568
+ submodules: false
569
+
570
+ # #1672: transient step-0 checkout aborts on the shared `release` runners make
571
+ # every stable release a dice-roll. This fallback fires ONLY when the checkout
572
+ # above failed (step-level `if: failure()` — a LITERAL, not a discarded
573
+ # continue-on-error expression) and re-materialises the workspace with a
574
+ # bounded 3-attempt git clone + backoff. fetch-depth: 0 is preserved with a
575
+ # FULL clone (no --depth) + tags, because the monotonic-guard and bump steps
576
+ # below need the parent commit and tag history. Fails loudly (exit 1) if all
577
+ # three attempts fail, so a real (non-transient) breakage is never masked.
578
+ # Independent step — no needs:+always() fan-in (act_runner would strand it).
579
+ - name: "Checkout fallback (retry transient flake) (#1672)"
580
+ if: failure()
581
+ env:
582
+ GITHUB_TOKEN: ${{ github.token }}
583
+ run: |
584
+ set -u
585
+ TOK="$(printf '%s' "$GITHUB_TOKEN" | tr -d '[:space:]')"; echo "::add-mask::$TOK"
586
+ SERVER="${GITHUB_SERVER_URL:-https://git.mokoconsulting.tech}"
587
+ CLONE_URL="https://x-access-token:${TOK}@${SERVER#https://}/${GITHUB_REPOSITORY}.git"
588
+ REF="${GITHUB_SHA:-${GITHUB_REF_NAME:-}}"
589
+ echo "Retrying full-history checkout of ${GITHUB_REPOSITORY}@${REF:-<HEAD>} into ${GITHUB_WORKSPACE}"
590
+ ok=0
591
+ for attempt in 1 2 3; do
592
+ rm -rf "${GITHUB_WORKSPACE:?}/.git" 2>/dev/null || true
593
+ find "${GITHUB_WORKSPACE}" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true
594
+ if git clone "$CLONE_URL" "${GITHUB_WORKSPACE}" \
595
+ && git -C "${GITHUB_WORKSPACE}" fetch --tags origin \
596
+ && { [ -z "$REF" ] || git -C "${GITHUB_WORKSPACE}" checkout -f "$REF"; }; then
597
+ ok=1; echo "Fallback checkout succeeded on attempt ${attempt}."; break
598
+ fi
599
+ echo "::warning::Fallback checkout attempt ${attempt} failed; backing off $((attempt * 5))s"
600
+ sleep $((attempt * 5))
601
+ done
602
+ if [ "$ok" != "1" ]; then
603
+ echo "::error::Checkout failed after 3 retry attempts (#1672) — this is not a transient flake, failing loudly."
604
+ exit 1
605
+ fi
606
+
607
+ - name: "Detect Makefile (auto-release gate)"
608
+ id: gate
609
+ # auto-release drives its build/package targets through make, so a repo
610
+ # WITHOUT a root Makefile has nothing to release. This is not an error --
611
+ # many universal repos (deploy handlers, docs, config, e.g. .vault) sync
612
+ # this workflow but never release. Treat "no Makefile" as a clean SKIP:
613
+ # emit has_makefile=false and let every downstream build/release step
614
+ # no-op via its if:, so the job goes GREEN with "Release Skipped" instead
615
+ # of reding every main push on a non-release repo. Must be a post-checkout
616
+ # step (a job-level if: cannot inspect the workspace).
617
+ run: |
618
+ if [ -f Makefile ] || [ -f makefile ] || [ -f GNUmakefile ]; then
619
+ echo "has_makefile=true" >> "$GITHUB_OUTPUT"
620
+ echo "Makefile present -- auto-release may proceed."
621
+ else
622
+ echo "has_makefile=false" >> "$GITHUB_OUTPUT"
623
+ echo "## Release Skipped -- no Makefile" >> "$GITHUB_STEP_SUMMARY"
624
+ echo "No Makefile at the repository root -- this repo does not build a release. Skipping the release pipeline (not a failure)." >> "$GITHUB_STEP_SUMMARY"
625
+ echo "No Makefile at repo root -- skipping auto-release (not a release-bearing repo)."
626
+ fi
627
+
628
+ - name: "Detect electron project"
629
+ id: electron
630
+ if: steps.gate.outputs.has_makefile == 'true'
631
+ # ADDITIVE + GATED: electron repos are detected by an electron-builder
632
+ # config at the repo root (mokocli platform detection does not know
633
+ # 'electron' yet -- see MokoGIT #1201). Every electron step below no-ops
634
+ # via `steps.electron.outputs.is_electron` so non-electron repos are
635
+ # 100% unaffected. Post-checkout (a job-level if: cannot see the tree).
636
+ run: |
637
+ if [ -f electron-builder.yml ] || [ -f electron-builder.yaml ]; then
638
+ echo "is_electron=true" >> "$GITHUB_OUTPUT"
639
+ echo "electron-builder config present -- electron installer build enabled."
640
+ else
641
+ echo "is_electron=false" >> "$GITHUB_OUTPUT"
642
+ fi
643
+
644
+ - name: Configure git for bot pushes
645
+ if: steps.gate.outputs.has_makefile == 'true'
646
+ env:
647
+ GITHUB_TOKEN: ${{ github.token }}
648
+ run: |
649
+ PUSH_TOK="$(printf '%s' "$GITHUB_TOKEN" | tr -d '[:space:]')"; [ -z "$PUSH_TOK" ] && PUSH_TOK="${{ github.token }}"; echo "::add-mask::$PUSH_TOK"
650
+ git config --local user.email "mokogit-actions[bot]@mokoconsulting.tech"
651
+ git config --local user.name "mokogit-actions[bot]"
652
+ git remote set-url origin "https://x-access-token:${PUSH_TOK}@git.mokoconsulting.tech/${{ github.repository }}.git"
653
+
654
+ - name: Check for merge conflict markers
655
+ if: steps.gate.outputs.has_makefile == 'true'
656
+ run: |
657
+ # Scan only git-tracked files so vendored/generated trees (e.g. phpcs'
658
+ # GitMergeConflictSniff fixtures under vendor/) and this workflow's own
659
+ # grep pattern can never be matched (back-port of Template-Joomla #177/#178).
660
+ FILES=$(git ls-files '*.php' '*.xml' '*.css' '*.js' '*.json' '*.md' '*.yml' '*.yaml' '*.ini' '*.txt')
661
+ CONFLICTS=$(printf '%s\n' "$FILES" | xargs -r grep -nE '^(<<<<<<< |>>>>>>> |=======$)' 2>/dev/null || true)
662
+ if [ -n "$CONFLICTS" ]; then
663
+ echo "::error::Merge conflict markers found -- aborting release"
664
+ echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
665
+ echo '```' >> $GITHUB_STEP_SUMMARY
666
+ echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
667
+ echo '```' >> $GITHUB_STEP_SUMMARY
668
+ exit 1
669
+ fi
670
+ echo "No conflict markers found"
671
+
672
+ - name: Setup MokoCLI tools
673
+ if: steps.gate.outputs.has_makefile == 'true'
674
+ run: |
675
+ # Install mokocli FRESH from the MokoGIT Composer registry on every run
676
+ # (PRIMARY) so CI always uses the latest published version. /opt/mokocli is
677
+ # a FAILSAFE only — its refresh unit (mokocli-sync.service) is disabled, so
678
+ # the on-disk snapshot rots and must NOT be preferred. Registry path
679
+ # /api/packages/** is anon-allowlisted (no GITHUB_TOKEN); it serves a dist
680
+ # zip (no git clone). --no-install: mokocli self-bootstraps via bundled lib/
681
+ # + a fallback PSR-4 autoloader (native ext-curl), so no vendor/ is needed.
682
+ if ! command -v composer > /dev/null 2>&1; then
683
+ 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
684
+ fi
685
+ rm -rf /tmp/mokocli
686
+ REG='{"type":"composer","url":"https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer"}'
687
+ if composer create-project --no-install --no-interaction --no-progress --quiet \
688
+ --repository="${REG}" mokoconsulting/mokocli /tmp/mokocli >/dev/null 2>&1 \
689
+ && [ -f /tmp/mokocli/cli/version_bump.php ]; then
690
+ echo "Installed mokocli fresh from the Composer registry (primary)"
691
+ # #1671: registry-publish LAG race. The registry install "succeeds" but can
692
+ # serve the PREVIOUS package for ~tens of seconds after a MokoCLI release
693
+ # (publish finishes AFTER the release run reports success). Meanwhile the
694
+ # /opt/mokocli deploy (pull-timer) may already carry the just-released,
695
+ # NEWER engine. So instead of blindly using the registry copy, compare the
696
+ # two on-disk versions and PREFER whichever is newer. The Makefile
697
+ # PROJECT_VERSION is the version source of truth and ships in both the
698
+ # composer dist and /opt/mokocli. Backward-compatible: with no /opt/mokocli
699
+ # (most consumer runners), the registry copy is used exactly as before.
700
+ mk_ver() { sed -n 's/^[[:space:]]*PROJECT_VERSION[[:space:]]*:*=[[:space:]]*//p' "$1" 2>/dev/null | head -n1 | tr -d '[:space:]'; }
701
+ ver_newer() { [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ]; }
702
+ REG_VER="$(mk_ver /tmp/mokocli/Makefile)"
703
+ OPT_VER="$(mk_ver /opt/mokocli/Makefile)"
704
+ MOKO_ROOT=/tmp/mokocli
705
+ if [ -f /opt/mokocli/cli/version_bump.php ] && [ -n "$OPT_VER" ] && [ -n "$REG_VER" ] \
706
+ && ver_newer "$OPT_VER" "$REG_VER"; then
707
+ echo "::notice::/opt/mokocli (${OPT_VER}) is newer than the registry copy (${REG_VER}) — using /opt (#1671 publish-lag race)."
708
+ MOKO_ROOT=/opt/mokocli
709
+ else
710
+ echo "Using registry copy (${REG_VER:-unknown}); /opt/mokocli=${OPT_VER:-none}"
711
+ fi
712
+ echo "MOKO_CLI=${MOKO_ROOT}/cli" >> $GITHUB_ENV
713
+ # #650: make the chosen moko executable + FIRST on PATH so bare `moko`
714
+ # never resolves to a stale/non-executable copy (exit 126).
715
+ for d in bin cli; do
716
+ if [ -f "${MOKO_ROOT}/$d/moko" ]; then
717
+ # #1263 defense-in-depth: strip CR bytes so a CRLF-tainted registry
718
+ # dist or rotted /opt snapshot can't yield `/usr/bin/env: 'php\r'`
719
+ # (exit 127). tr interprets \r as a real CR (unlike portable sed).
720
+ # /opt may be root-owned — write via a temp file + sudo when needed.
721
+ if tr -d '\r' < "${MOKO_ROOT}/$d/moko" > "/tmp/moko.lf" 2>/dev/null; then
722
+ if cp "/tmp/moko.lf" "${MOKO_ROOT}/$d/moko" 2>/dev/null; then :; \
723
+ else sudo cp "/tmp/moko.lf" "${MOKO_ROOT}/$d/moko" 2>/dev/null || true; fi
724
+ rm -f "/tmp/moko.lf"
725
+ fi
726
+ chmod +x "${MOKO_ROOT}/$d/moko" 2>/dev/null || sudo chmod +x "${MOKO_ROOT}/$d/moko" 2>/dev/null || true
727
+ echo "${MOKO_ROOT}/$d" >> "$GITHUB_PATH"
728
+ fi
729
+ done
730
+ sudo chmod +x /opt/mokocli/*/moko 2>/dev/null || true
731
+ # Opportunistically refresh the /opt failsafe from this fresh copy when it
732
+ # differs — keeps the disabled-sync snapshot current so the fallback is
733
+ # never far behind. Best-effort: needs sudo, gated on a diff to avoid
734
+ # fleet-wide churn, never fails the job. #1671: only refresh FORWARD — skip
735
+ # when /opt is the newer copy we just chose, so we never downgrade it.
736
+ if [ "$MOKO_ROOT" = /tmp/mokocli ] && command -v sudo >/dev/null 2>&1 && [ -d /opt/mokocli ] \
737
+ && ! cmp -s /tmp/mokocli/cli/version_reset_dev.php /opt/mokocli/cli/version_reset_dev.php 2>/dev/null; then
738
+ sudo cp -a /tmp/mokocli/. /opt/mokocli/ 2>/dev/null \
739
+ && echo "Refreshed /opt/mokocli failsafe from the fresh registry copy" \
740
+ || echo " (/opt failsafe refresh skipped — no sudo/perms)"
741
+ fi
742
+ elif [ -f /opt/mokocli/cli/version_bump.php ]; then
743
+ echo "::warning::Registry install failed — using /opt/mokocli FAILSAFE (may be stale)"
744
+ echo "MOKO_CLI=/opt/mokocli/cli" >> $GITHUB_ENV
745
+ else
746
+ echo "::error::mokocli unavailable: registry install failed and no /opt/mokocli failsafe present"
747
+ exit 1
748
+ fi
749
+
750
+ - name: "Detect platform"
751
+ id: platform
752
+ if: steps.gate.outputs.has_makefile == 'true'
753
+ run: |
754
+ # PILOT: native-manifest platform detection only -- the metadata-registry
755
+ # write-back (platform_detect.php --update) is REMOVED. The MokoCLI build
756
+ # agent no longer reads or mutates the metadata API; the platform is
757
+ # resolved from the in-tree manifest solely to drive the tag/branch logic
758
+ # below (joomla -> tag=stable; generic -> tag=v${VERSION}).
759
+ # Non-fatal (|| true) so a detection hiccup never blocks a release.
760
+ php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
761
+ php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
762
+
763
+ - name: "Resolve SRC_DIR bump gate (#469)"
764
+ id: srcgate
765
+ if: steps.gate.outputs.has_makefile == 'true'
766
+ # #469: decide version-BUMP vs NO-BUMP (update-existing) from whether this
767
+ # push touched the Makefile source directory (SRC_DIR). This replaces the old
768
+ # non-code paths-ignore denylist on the push trigger: a docs/workflow/sample
769
+ # push no longer skips the pipeline — it runs in no-bump "update the current
770
+ # stable release in place" mode. SRC_DIR is read from the Makefile exactly as
771
+ # apidocs.yml does (the de-facto resolve-source-dir contract in this template).
772
+ #
773
+ # Gate logic (per the #469 "Resulting gate logic"):
774
+ # 1. ELECTRON / opt-out -> BYPASS: electron repos have NO source dir, so a
775
+ # naive SRC_DIR test would classify every push as non-source and NEVER
776
+ # bump (a release-breaking trap). Electron (file-detected) and an explicit
777
+ # opt-out (vars.RELEASE_SRCDIR_GATE=off) keep the existing always-bump
778
+ # behaviour. Do NOT infer the exclusion from an empty SRC_DIR.
779
+ # 2. Else SRC_DIR is REQUIRED: a non-excluded repo with no resolvable SRC_DIR
780
+ # is a misconfig -> FAIL LOUD (::error:: + exit 1), never silently never-bump.
781
+ # 3. Else diff the push range (before..sha, with a HEAD^ / merge-base
782
+ # fallback) intersected with ${SRC_DIR}/ : any source path -> should_bump
783
+ # =true (existing bump+new-release path); none -> should_bump=false
784
+ # (no-bump update-existing path). workflow_dispatch always bumps (an
785
+ # explicit human re-cut), as does a run with no resolvable diff range.
786
+ run: |
787
+ set -uo pipefail
788
+ # 1. Electron / opt-out bypass -> always bump as today.
789
+ if [ "${{ steps.electron.outputs.is_electron }}" = "true" ] || [ "${{ vars.RELEASE_SRCDIR_GATE }}" = "off" ]; then
790
+ echo "should_bump=true" >> "$GITHUB_OUTPUT"
791
+ echo "::notice::SRC_DIR gate BYPASSED (electron repo or vars.RELEASE_SRCDIR_GATE=off) — keeping always-bump behaviour (#469)."
792
+ exit 0
793
+ fi
794
+ # A manual re-cut is always an intentional bump+release.
795
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
796
+ echo "should_bump=true" >> "$GITHUB_OUTPUT"
797
+ echo "workflow_dispatch — always bump (explicit human re-cut) (#469)."
798
+ exit 0
799
+ fi
800
+ # Resolve the push diff range (shared by both gate modes below).
801
+ BEFORE="${{ github.event.before }}"
802
+ SHA="${GITHUB_SHA:-}"
803
+ RANGE=""
804
+ ZERO="0000000000000000000000000000000000000000"
805
+ if [ -n "$BEFORE" ] && [ "$BEFORE" != "$ZERO" ] && git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
806
+ RANGE="${BEFORE}..${SHA}"
807
+ elif git rev-parse "${SHA}^1" >/dev/null 2>&1; then
808
+ # No usable before-sha (branch-create / forced / shallow): fall back to the
809
+ # first-parent diff of the pushed commit (the merged promotion's delta).
810
+ RANGE="${SHA}^1..${SHA}"
811
+ fi
812
+ if [ -z "$RANGE" ]; then
813
+ # No resolvable range (e.g. initial push): cannot prove a non-source push,
814
+ # so default to the safe existing behaviour — bump.
815
+ echo "should_bump=true" >> "$GITHUB_OUTPUT"
816
+ echo "::notice::#469 gate: no resolvable diff range (before=${BEFORE:-none}); defaulting to bump."
817
+ exit 0
818
+ fi
819
+ CHANGED="$(git diff --name-only "$RANGE" 2>/dev/null || true)"
820
+ if [ -z "$CHANGED" ]; then
821
+ echo "should_bump=true" >> "$GITHUB_OUTPUT"
822
+ echo "::notice::#469 gate: empty diff for ${RANGE}; defaulting to bump."
823
+ exit 0
824
+ fi
825
+ # 2. SRC_DIR INCLUSION mode — fleet standard is SRC_DIR=source. When the repo
826
+ # declares a Makefile SRC_DIR, bump iff the push touched ${SRC_DIR}/.
827
+ # Accept all three GNU make assignment forms: SRC_DIR=, SRC_DIR :=, SRC_DIR ?=.
828
+ # NOTE the trailing `|| true`: the step shell runs with `-e -o pipefail`,
829
+ # so on a repo with NO SRC_DIR the `grep` exits 1, pipefail propagates it,
830
+ # and the command-substitution assignment would abort the whole step under
831
+ # -e BEFORE the denylist fallback below. `|| true` keeps SRC_DIR empty and
832
+ # lets execution fall through to denylist mode. (#1831)
833
+ # #498 FIX: resolve SRC_DIR by asking `make` to EXPAND it. The fleet Makefile
834
+ # standard is `SRC_DIR := $(shell [ -d src ] && echo src || echo source)` — a
835
+ # GNU make function. A grep|sed read captures the LITERAL unexpanded
836
+ # "$(shell …)", an unmatchable directory that made INCLUSION mode classify
837
+ # EVERY push as non-source (should_bump=false forever: MokoCLI stuck
838
+ # re-publishing v10.20.00 while source changed). Ask make to evaluate it (runs
839
+ # the $(shell …) read-only); fall back to the grep|sed read only if make is
840
+ # unavailable, and NULL OUT a still-unexpanded $(...)/`...` value so it drops
841
+ # to the denylist mode below instead of self-trapping. (|| true keeps the step
842
+ # alive under -e -o pipefail — see #1831.)
843
+ SRC_DIR="$(make -s --no-print-directory --eval='__moko_srcdir__:; @printf "%s" "$(SRC_DIR)"' __moko_srcdir__ 2>/dev/null | tr -d '[:space:]' || true)"
844
+ if [ -z "$SRC_DIR" ]; then
845
+ SRC_DIR="$(grep -m1 -E '^SRC_DIR[[:space:]]*[:?]?=' Makefile 2>/dev/null | sed -E 's/.*[:?]?=[[:space:]]*//' | tr -d '[:space:]' || true)"
846
+ fi
847
+ case "$SRC_DIR" in *'$('*|*'`'*) SRC_DIR="" ;; esac
848
+ # A declared-but-NONEXISTENT SRC_DIR (e.g. expands to `source` on a repo whose
849
+ # code lives in cli/lib/bin) must NOT drive INCLUSION mode with an unmatchable
850
+ # prefix — fall through to the non-source denylist mode instead. (#498)
851
+ if [ -n "$SRC_DIR" ] && [ ! -d "$SRC_DIR" ]; then
852
+ echo "::notice::#469 gate: declared SRC_DIR='${SRC_DIR}' does not exist in the tree — using non-source denylist fallback (#498)."
853
+ SRC_DIR=""
854
+ fi
855
+ if [ -n "$SRC_DIR" ]; then
856
+ SRC_DIR="${SRC_DIR%/}"
857
+ SRC_HITS="$(printf '%s\n' "$CHANGED" | grep -E "^${SRC_DIR}/" || true)"
858
+ if [ -n "$SRC_HITS" ]; then
859
+ echo "should_bump=true" >> "$GITHUB_OUTPUT"
860
+ echo "#469 gate (SRC_DIR=${SRC_DIR}): push touched ${SRC_DIR}/ (range ${RANGE}) — BUMP + new release."
861
+ else
862
+ echo "should_bump=false" >> "$GITHUB_OUTPUT"
863
+ echo "::notice::#469 gate (SRC_DIR=${SRC_DIR}): push touched NO ${SRC_DIR}/ paths (range ${RANGE}) — NO bump; updating the current release in place."
864
+ fi
865
+ exit 0
866
+ fi
867
+ # 3. NON-SOURCE DENYLIST mode (#469/#1831) — repos with no single declarable
868
+ # SRC_DIR (e.g. MokoGIT, a Gitea fork with root-level Go source). NEVER
869
+ # hard-fail on a missing SRC_DIR: bump UNLESS the push touched ONLY
870
+ # non-source files (docs, CI/workflows, dotfiles). Any other change bumps.
871
+ NOBUMP='(^\.|(^|/)docs?/|\.(md|markdown|rst|adoc)$|^(README|CHANGELOG|LICENSE|COPYING|NOTICE|AUTHORS|CONTRIBUTORS|CODE_OF_CONDUCT|CONTRIBUTING|SECURITY|SUPPORT|GOVERNANCE)[^/]*$|^renovate\.json$)'
872
+ SRC_HITS="$(printf '%s\n' "$CHANGED" | grep -vE "$NOBUMP" || true)"
873
+ if [ -n "$SRC_HITS" ]; then
874
+ echo "should_bump=true" >> "$GITHUB_OUTPUT"
875
+ echo "#469 gate (denylist mode, no SRC_DIR): push touched source/non-doc files (range ${RANGE}) — BUMP + new release."
876
+ else
877
+ echo "should_bump=false" >> "$GITHUB_OUTPUT"
878
+ echo "::notice::#469 gate (denylist mode, no SRC_DIR): push touched ONLY non-source files (docs/CI/dotfiles) (range ${RANGE}) — NO bump; updating the current release in place."
879
+ fi
880
+
881
+ - name: "Determine version bump level"
882
+ id: bump
883
+ if: steps.gate.outputs.has_makefile == 'true'
884
+ env:
885
+ HEAD_REF: ${{ github.event.pull_request.head.ref }}
886
+ run: |
887
+ # Bump-on-dev: the base version is now bumped on DEV pre-promotion (the
888
+ # promote-rc job's release_bump_dev.php step), so main ALWAYS publishes
889
+ # the inherited version with NO bump. Historically a dev-headed promotion
890
+ # bumped minor here (on main) — that on-main bump is exactly the divergence
891
+ # this change removes. fix/patch/hotfix/bugfix were already 'none' (the
892
+ # per-merge patch bump happens on dev in pre-release.yml, not here). So
893
+ # every path is 'none' now.
894
+ HEAD_REF="${HEAD_REF:-dev}"
895
+ BUMP="none"
896
+ echo "level=${BUMP}" >> "$GITHUB_OUTPUT"
897
+ echo "Bump level: ${BUMP} (from branch: ${HEAD_REF}) — base bumped on dev pre-promotion"
898
+
899
+ - name: "Detect mokohub packaging platforms"
900
+ id: mokohub
901
+ if: steps.gate.outputs.has_makefile == 'true'
902
+ # ADDITIVE + GATED (Template-Generic#326): classify the mokohub-manifest /
903
+ # mokohub-recipes platforms from the detected platform key so their gated
904
+ # packaging steps below can fire. Purely additive — no existing platform
905
+ # (joomla/npm/mcp/client/generic/go/php/electron) reads these outputs, so
906
+ # their release path is 100% unchanged. Detection is read-only.
907
+ run: |
908
+ PLATFORM="${{ steps.platform.outputs.platform }}"
909
+ IS_MANIFEST=false
910
+ IS_RECIPES=false
911
+ case "$PLATFORM" in
912
+ mokohub-manifest*) IS_MANIFEST=true ;;
913
+ mokohub-recipes*) IS_RECIPES=true ;;
914
+ esac
915
+ echo "is_manifest=${IS_MANIFEST}" >> "$GITHUB_OUTPUT"
916
+ echo "is_recipes=${IS_RECIPES}" >> "$GITHUB_OUTPUT"
917
+ [ "$IS_MANIFEST" = "true" ] && echo "mokohub-manifest platform — manifest.json packaging enabled."
918
+ [ "$IS_RECIPES" = "true" ] && echo "mokohub-recipes platform — catalog.zip packaging enabled."
919
+ true
920
+
921
+ - name: "Gate npm publish (PLATFORM=npm + package.json + Makefile PROJECT_VERSION)"
922
+ id: npmpub
923
+ if: steps.gate.outputs.has_makefile == 'true'
924
+ # A repo is npm-publishable ONLY when its Makefile PLATFORM is npm/nodejs --
925
+ # the canonical platform declaration, NOT file presence. Joomla/Go/other repos
926
+ # that merely ship a build-tooling package.json (e.g. a Joomla component with a
927
+ # frontend package.json) must never be treated as npm packages. On top of the
928
+ # platform gate we still require a package.json AND a Makefile PROJECT_VERSION
929
+ # (the mokocli source of truth the publish step reads) -- belt-and-suspenders,
930
+ # so a genuine npm repo missing PROJECT_VERSION does not fail the release.
931
+ run: |
932
+ PUB=false
933
+ PLATFORM="${{ steps.platform.outputs.platform }}"
934
+ # Electron repos build + publish their installers LOCALLY (manual upload),
935
+ # so they must NEVER npm-publish from CI even though they detect as nodejs
936
+ # (they ship a package.json). Mirror pre-release.yml's electron publish gate
937
+ # (Template-Generic #390): disable unless explicitly opted in with
938
+ # vars.ELECTRON_CI_BUILD=true. Non-electron repos are unaffected.
939
+ if [ "${{ steps.electron.outputs.is_electron }}" = "true" ] && [ "${{ vars.ELECTRON_CI_BUILD }}" != "true" ]; then
940
+ echo "publishable=false" >> "$GITHUB_OUTPUT"
941
+ echo "npm-publishable=false (electron repo -- CI npm publish disabled; local build + manual upload)"
942
+ else
943
+ case "$PLATFORM" in
944
+ npm*|nodejs*)
945
+ if [ -f package.json ] && grep -qE '^[[:space:]]*PROJECT_VERSION[[:space:]]*:?=' Makefile; then PUB=true; fi
946
+ ;;
947
+ esac
948
+ echo "publishable=${PUB}" >> "$GITHUB_OUTPUT"
949
+ echo "npm-publishable=${PUB} (needs PLATFORM npm/nodejs + package.json + Makefile PROJECT_VERSION; PLATFORM=${PLATFORM})"
950
+ fi
951
+
952
+ - name: "Sterilize SVG assets (strip editor/Inkscape metadata) (#312)"
953
+ if: steps.gate.outputs.has_makefile == 'true'
954
+ continue-on-error: true
955
+ run: |
956
+ set -uo pipefail
957
+ mapfile -t SVGS < <(find . -type f -name '*.svg' \
958
+ -not -path './.git/*' -not -path './node_modules/*' -not -path './vendor/*' 2>/dev/null || true)
959
+ if [ "${#SVGS[@]}" -eq 0 ]; then echo "no SVGs -- skipping (#312)"; exit 0; fi
960
+ echo "Sterilizing ${#SVGS[@]} SVG(s) -- stripping editor/Inkscape metadata..."
961
+ if pip install --quiet --disable-pip-version-check scour >/dev/null 2>&1 && command -v scour >/dev/null 2>&1; then
962
+ for fsvg in "${SVGS[@]}"; do
963
+ scour -i "$fsvg" -o "$fsvg.st" --remove-metadata --strip-xml-prolog --enable-comment-stripping >/dev/null 2>&1 \
964
+ && mv "$fsvg.st" "$fsvg" || rm -f "$fsvg.st"
965
+ done
966
+ echo "SVG sterilization complete (scour, metadata-only)."
967
+ else
968
+ echo "::notice::scour unavailable -- SVG sterilization skipped (non-fatal, #312)."
969
+ fi
970
+
971
+ - name: Optimize assets (artifact-time)
972
+ if: steps.gate.outputs.has_makefile == 'true'
973
+ continue-on-error: true
974
+ run: |
975
+ moko assets:optimize --path . 2>/dev/null || echo "::notice::moko assets:optimize unavailable or no assets — skipping"
976
+
977
+ # Build-time PHP dependency vendoring (universal, guarded — mirrors
978
+ # pre-release.yml 07.15.00). If a sub-package ships a composer.json, fetch its
979
+ # runtime deps into that package's CONFIGURED vendor dir (e.g. admin/vendor —
980
+ # mPDF) BEFORE `moko publish` (release_publish.php → release_package.php) zips
981
+ # the working tree. No-op without a sub-package composer.json. FAILS LOUD when
982
+ # vendoring fails for a package whose Joomla manifest ships
983
+ # <folder>vendor</folder>: shipping that zip without vendor/ makes JInstaller
984
+ # abort the whole package install on-site ("File does not exist
985
+ # .../admin/vendor" — MokonyxCRMDCM/MokonyxCRM), strictly worse than a red run.
986
+ - name: Vendor PHP dependencies (build-time)
987
+ if: steps.gate.outputs.has_makefile == 'true'
988
+ run: |
989
+ set -uo pipefail
990
+ CJS="$(find source/packages src/packages -maxdepth 2 -name composer.json 2>/dev/null || true)"
991
+ if [ -z "$CJS" ]; then echo "No sub-package composer.json — nothing to vendor."; exit 0; fi
992
+ command -v composer >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq composer >/dev/null 2>&1 || true; }
993
+ # mPDF (and most PHP PDF/image libs) hard-require ext-gd; the runners'
994
+ # php-cli ships without it, which fails dependency RESOLUTION outright.
995
+ if ! php -m 2>/dev/null | grep -qi '^gd$'; then
996
+ PHPV="$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;' 2>/dev/null || true)"
997
+ sudo apt-get update -qq || true
998
+ 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})"
999
+ php -m 2>/dev/null | grep -qi '^gd$' || echo "::warning::ext-gd STILL missing after install attempt — vendoring will fail for gd-requiring packages"
1000
+ fi
1001
+ rc=0
1002
+ for cj in $CJS; do
1003
+ dir="$(dirname "$cj")"
1004
+ echo "::notice::composer install (no-dev) in ${dir}"
1005
+ ok=true
1006
+ composer install --no-dev --no-interaction --no-progress --optimize-autoloader --working-dir="$dir" || ok=false
1007
+ vd="$(composer config vendor-dir --working-dir="$dir" 2>/dev/null || true)"; vd="${vd:-vendor}"
1008
+ [ -f "${dir}/${vd}/autoload.php" ] || ok=false
1009
+ if [ "$ok" = "true" ]; then
1010
+ echo "vendored: ${dir}/${vd}"
1011
+ elif grep -qs '<folder>vendor</folder>' "${dir}"/*.xml; then
1012
+ 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."
1013
+ rc=1
1014
+ else
1015
+ echo "::warning::composer install failed in ${dir} — manifest does not ship vendor/; packaging the existing tree"
1016
+ fi
1017
+ done
1018
+ exit $rc
1019
+
1020
+ - name: "Guarantee monotonic release version (fast-merge bump guard)"
1021
+ id: monotonic
1022
+ # #469: in no-bump mode (a non-source push updating the existing release in
1023
+ # place) the version must stay PUT, so the self-healing minor bump must not
1024
+ # run — skip this guard when steps.srcgate decided should_bump=false. On the
1025
+ # bump path (should_bump=true, or electron/opt-out/dispatch where srcgate is
1026
+ # bypassed/true) it runs exactly as before.
1027
+ if: steps.gate.outputs.has_makefile == 'true' && steps.srcgate.outputs.should_bump != 'false'
1028
+ env:
1029
+ GITHUB_TOKEN: ${{ github.token }}
1030
+ run: |
1031
+ # P1-b (.mokogit-private#73): the base is normally minor-bumped on dev by
1032
+ # promote-rc pre-promotion. If the dev->stable PR is MERGED before
1033
+ # promote-rc runs (a fast merge), that bump is skipped and this job would
1034
+ # re-publish an ALREADY-RELEASED base (observed: stuck at 02.23.00). Enforce
1035
+ # "strictly above the last release": a no-op on the normal path, a self-
1036
+ # healing local bump when the inherited base did not advance.
1037
+ CUR=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
1038
+ [ -z "$CUR" ] && { echo "no base version; skip guard"; exit 0; }
1039
+ git fetch --tags --quiet origin 2>/dev/null || true
1040
+ # #813: the "last released version" is the MAX of two sources, not tags alone.
1041
+ # The rolling-release model publishes to a FIXED `stable` release whose NAME
1042
+ # ("... (VERSION: NN.NN.NN)") is the true last-published version, while the
1043
+ # immutable vNN.NN.NN tags can lag / fail to advance (#808/#386 stale-tag).
1044
+ # Trusting tags alone made this guard blind, so a direct fix/*->stable merge
1045
+ # RE-CUT an already-published base (observed 01.00.19 re-cut). Take the max of
1046
+ # (a) the highest vNN.NN.NN tag and (b) the rolling `stable` release version.
1047
+ TAG_LAST=$(git tag -l 'v[0-9][0-9].[0-9][0-9].[0-9][0-9]' | sed 's/^v//' | sort -V | tail -n1 || true)
1048
+ # #1702 / .mokogit-private#76: resolve the CURRENT stable release by its
1049
+ # CHANNEL POINTER, not by a fixed `tag_name=stable`. Under version-primary
1050
+ # releases the release tag_name is the immutable vNN, so GET
1051
+ # /releases/tags/stable no longer finds it; the channel endpoint
1052
+ # (releases/channel/stable) resolves whichever release the `stable`
1053
+ # channel currently points at via ReleaseStreamMap. Graceful fallback to
1054
+ # the legacy tags/stable call for forges predating #1702 (pre-rollout the
1055
+ # channel route 404s and the rolling `stable` tag still exists). The parsed
1056
+ # input is identical: the release NAME carries "VERSION: NN.NN.NN".
1057
+ RELEASE_JSON=$(curl -sf -H "Authorization: token ${GITHUB_TOKEN:-${{ github.token }}}" \
1058
+ "${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}/releases/channel/stable" 2>/dev/null || true)
1059
+ if [ -z "$RELEASE_JSON" ]; then
1060
+ RELEASE_JSON=$(curl -sf -H "Authorization: token ${GITHUB_TOKEN:-${{ github.token }}}" \
1061
+ "${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}/releases/tags/stable" 2>/dev/null || true)
1062
+ fi
1063
+ ROLLING_LAST=$(printf '%s' "${RELEASE_JSON:-}" \
1064
+ | grep -oE 'VERSION: [0-9]{2}\.[0-9]{2}\.[0-9]{2}' | grep -oE '[0-9]{2}\.[0-9]{2}\.[0-9]{2}' | head -n1 || true)
1065
+ LAST=$( { echo "$TAG_LAST"; echo "$ROLLING_LAST"; } | grep -E '^[0-9]{2}\.[0-9]{2}\.[0-9]{2}$' | sort -V | tail -n1 || true)
1066
+ if [ -z "$LAST" ]; then echo "no prior release (no vNN.NN.NN tag, no rolling stable); skip guard"; exit 0; fi
1067
+ echo "monotonic guard: CUR=${CUR} TAG_LAST=${TAG_LAST:-none} ROLLING_LAST=${ROLLING_LAST:-none} -> LAST=${LAST}"
1068
+ # #498 FIX: the release IDENTITY is the MINOR (tag vMAJOR.MINOR.00). Dev's
1069
+ # per-merge PATCH bumps advance CUR within the SAME minor (e.g. 10.20.00 ->
1070
+ # 10.20.04), which satisfied the old full-version `CUR <= LAST` test and
1071
+ # WRONGLY skipped the heal — so a fast dev->stable merge re-published the
1072
+ # already-released vMAJOR.MINOR.00 forever (observed: MokoCLI stuck at
1073
+ # v10.20.00 while dev reached 10.20.04), violating this guard's own "never
1074
+ # re-publish an already-released base" intent. Compare the MINOR identity
1075
+ # (MAJOR.MINOR): heal (minor-bump) whenever CUR's minor has NOT advanced past
1076
+ # the last release's minor. The normal promote-rc path (dev already
1077
+ # minor-bumped) still no-ops.
1078
+ CUR_MI=$(printf '%s' "$CUR" | awk -F. '{printf "%s.%s", $1, $2}')
1079
+ LAST_MI=$(printf '%s' "$LAST" | awk -F. '{printf "%s.%s", $1, $2}')
1080
+ GREATEST_MI=$( { echo "$CUR_MI"; echo "$LAST_MI"; } | sort -V | tail -n1 || true)
1081
+ if [ "$CUR_MI" = "$LAST_MI" ] || [ "$GREATEST_MI" = "$LAST_MI" ]; then
1082
+ echo "::warning::inherited minor ${CUR_MI} <= last release minor ${LAST_MI} (CUR=${CUR} LAST=${LAST}); minor-bump skipped -> self-healing"
1083
+ if ! php ${MOKO_CLI}/version_bump.php --minor --min-version "$LAST" --path .; then
1084
+ echo "::error::monotonic bump guard failed to advance the base"; exit 1
1085
+ fi
1086
+ NEW=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
1087
+ echo "healed ${CUR} -> ${NEW} (publishing fresh; carrying forward to dev)"
1088
+ php ${MOKO_CLI}/release_bump_dev.php --path . --branch dev --bump minor \n --repo "${{ github.repository }}" --token "${GITHUB_TOKEN:-${{ github.token }}}" \n || echo "::warning::dev carry-forward failed (non-fatal; release still publishes the healed version)"
1089
+ else
1090
+ echo "base ${CUR} > last release ${LAST}: normal path, no heal"
1091
+ fi
1092
+
1093
+ # -- Release integrity: refuse to publish a STALE (pre-merge) tree ---------
1094
+ # #428 / MokoCLI #901: the release builds GITHUB_SHA (the triggering commit).
1095
+ # If a concurrent merge advanced the branch AFTER this run's trigger, we would
1096
+ # package a PRE-merge tree while stamping the new version -- a green release
1097
+ # that silently ships old code (reproduced: built ef8c1a5d while the tip was
1098
+ # the ladder-merge 2740342, missing only that merge's blobs). The mokocli
1099
+ # engine asserts packaged-tree == GITHUB_SHA, but it CANNOT see that GITHUB_SHA
1100
+ # itself trails the tip -- that is this guard's job. Fail-OPEN on any ambiguity
1101
+ # (unresolved tip / replica lag) so a good release is never falsely blocked; a
1102
+ # confident "built commit is an ancestor of the live tip" is a hard FAIL.
1103
+ - name: "Release integrity: no stale (pre-merge) tree"
1104
+ id: stale_guard
1105
+ if: steps.gate.outputs.has_makefile == 'true' && (steps.electron.outputs.is_electron != 'true' || vars.ELECTRON_CI_BUILD == 'true')
1106
+ run: |
1107
+ set -uo pipefail
1108
+ BUILT="${GITHUB_SHA:-}"
1109
+ BRANCH="${GITHUB_REF_NAME:-}"
1110
+ if [ -z "$BUILT" ] || [ -z "$BRANCH" ]; then
1111
+ echo "::warning::stale-tree guard: no GITHUB_SHA/branch to check (event=${GITHUB_EVENT_NAME:-?}); skipping (fail-open)."; exit 0
1112
+ fi
1113
+ # Fetch the CURRENT branch tip into a private tracking ref. Pin refs/heads/
1114
+ # explicitly: the channel-named-branch model lets a `stable` TAG and `stable`
1115
+ # BRANCH coexist, so a bare `git fetch origin stable` is ambiguous. Retry for
1116
+ # replica lag; a lagged read can only look EQUAL/behind -> never a false fail.
1117
+ TIP=""
1118
+ for i in 1 2 3 4; do
1119
+ if git fetch --quiet origin "+refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}-tipcheck" 2>/dev/null; then
1120
+ TIP="$(git rev-parse "refs/remotes/origin/${BRANCH}-tipcheck" 2>/dev/null || echo '')"
1121
+ [ -n "$TIP" ] && break
1122
+ fi
1123
+ sleep $((i * 2))
1124
+ done
1125
+ if [ -z "$TIP" ]; then
1126
+ echo "::warning::stale-tree guard: could not resolve tip of '${BRANCH}'; skipping (fail-open)."; exit 0
1127
+ fi
1128
+ if [ "$TIP" = "$BUILT" ]; then
1129
+ echo "Release integrity OK: building the current ${BRANCH} tip ${BUILT}."; exit 0
1130
+ fi
1131
+ if git merge-base --is-ancestor "$BUILT" "$TIP" 2>/dev/null; then
1132
+ # $BUILT is behind $TIP. Distinguish a genuine concurrent CODE merge
1133
+ # from this run's (or a prior release run's) OWN version-stamp commit
1134
+ # (#433). The "Publish stable release" step commits
1135
+ # "chore(release): build NN.NN.NN [skip ci]" onto the branch, which
1136
+ # advances the tip past $BUILT -- that is self-generated, NOT a
1137
+ # concurrent merge, and must not block (otherwise a re-run / the next
1138
+ # run false-fails forever and the release stalls: branch bumped, artifact
1139
+ # never published, [skip ci] blocking self-heal). If EVERY commit in
1140
+ # BUILT..TIP is such a release-stamp commit, proceed; if ANY other
1141
+ # commit is present, a real merge landed -> hard fail as before.
1142
+ FOREIGN=0
1143
+ for c in $(git rev-list "${BUILT}..${TIP}" 2>/dev/null); do
1144
+ SUBJ="$(git log -1 --format='%s' "$c" 2>/dev/null || echo '?')"
1145
+ case "$SUBJ" in
1146
+ "chore(release): build "*) : ;; # our own [skip ci] version stamp
1147
+ *) FOREIGN=1; break ;;
1148
+ esac
1149
+ done
1150
+ if [ "$FOREIGN" = "0" ]; then
1151
+ echo "::notice::stale-tree guard: '${BRANCH}' tip ${TIP} is ahead of ${BUILT} only by this run's own release-stamp commit(s) ('chore(release): build …'); not a concurrent merge -- proceeding (#433)."
1152
+ else
1153
+ echo "::error::Stale-tree guard: the triggering commit ${BUILT} is BEHIND the current '${BRANCH}' tip ${TIP} -- a concurrent merge landed during this run. Refusing to publish a pre-merge tree that would ship old code under the new version (#428 / MokoCLI #901). The newer commit gets its own release run; re-run this job against ${TIP} if a dedicated release for ${BUILT} is required."
1154
+ {
1155
+ echo "### ❌ Release blocked -- stale (pre-merge) tree"
1156
+ echo ""
1157
+ echo "Triggering commit \`${BUILT}\` is an **ancestor** of the current \`${BRANCH}\` tip \`${TIP}\`: a concurrent merge advanced the branch during this run. Publishing now would package code **missing that merge** while stamping the new version (the #901 bug). Blocked. See **#428 / MokoCLI #901**."
1158
+ } >> "$GITHUB_STEP_SUMMARY"
1159
+ exit 1
1160
+ fi
1161
+ fi
1162
+ echo "::warning::stale-tree guard: built ${BUILT} != tip ${TIP} but is not its ancestor (diverged/unrelated); no pre-merge-tree risk detected -- proceeding."
1163
+
1164
+ - name: "Publish stable release"
1165
+ id: publish
1166
+ if: steps.gate.outputs.has_makefile == 'true' && (steps.electron.outputs.is_electron != 'true' || vars.ELECTRON_CI_BUILD == 'true')
1167
+ run: |
1168
+ # PILOT: call the MokoCLI build agent's publish verb (generic git-release).
1169
+ # `moko publish` maps to release_publish.php UNCHANGED -- same
1170
+ # stability/token contract, standards preserved. No --branch:
1171
+ # release_publish auto-detects the branch (here main).
1172
+ #
1173
+ # Bump-on-dev: --bump none + --no-commit. The base was bumped on dev
1174
+ # pre-promotion and inherited into main via the merge, so main neither
1175
+ # bumps NOR commits a version change. release_publish still stamps the
1176
+ # (unchanged) version in-tree so the built artifact is correct, but Step
1177
+ # 2c's commit/push to main is suppressed -- this is what removes the
1178
+ # main-only `chore(release): build` commit that diverged main from dev.
1179
+ # GITHUB_TOKEN is no longer needed here (nothing is persisted to main).
1180
+ # #469: this SAME --bump none --no-commit publish serves BOTH gate paths.
1181
+ # On the BUMP path it publishes the newly-advanced inherited version; on the
1182
+ # NO-BUMP path (steps.srcgate.should_bump=false — a non-source push) it
1183
+ # re-stamps the UNCHANGED current version onto the existing stable channel
1184
+ # release, re-issuing it in place with the refreshed (non-source) tree. The
1185
+ # version does not advance and the self-heal bump + new vNN tag are skipped
1186
+ # upstream, so no new version identity is minted — exactly the #469 contract.
1187
+ if [ "${{ steps.srcgate.outputs.should_bump }}" = "false" ]; then
1188
+ echo "::notice::#469 no-bump mode — re-issuing the existing stable release in place (same version, refreshed tree/assets/notes), no version advance, no new vNN tag."
1189
+ fi
1190
+ # #289: stamp <creationDate> (build date, UTC) + <author> (Makefile
1191
+ # PROJECT_AUTHOR) into Joomla manifests before publish, so the released
1192
+ # stable manifest carries them. release_publish (below) re-stamps <version>
1193
+ # idempotently. No-op on non-Joomla and on an older MokoCLI lacking the args.
1194
+ MG_BUILD_DATE="$(date -u +%Y-%m-%d)"
1195
+ MG_AUTHOR="$(grep -m1 -E '^PROJECT_AUTHOR[[:space:]]*:?=' Makefile 2>/dev/null | sed -E 's/.*:?=[[:space:]]*//' | sed 's/[[:space:]]*$//' || true)"
1196
+ MG_VER="$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '')"
1197
+ [ -n "$MG_VER" ] && php ${MOKO_CLI}/version_set_platform.php \
1198
+ --path . --version "$MG_VER" \
1199
+ --date "$MG_BUILD_DATE" ${MG_AUTHOR:+--author "$MG_AUTHOR"} 2>/dev/null || true
1200
+
1201
+ moko publish \
1202
+ --path . --stability stable --bump none --no-commit \
1203
+ --token "${{ github.token }}"
1204
+
1205
+ - name: "Read published version"
1206
+ id: version
1207
+ if: steps.gate.outputs.has_makefile == 'true'
1208
+ run: |
1209
+ # #741: consume the version the build agent already published (steps.publish
1210
+ # writes version=/base_version=/semver= to $GITHUB_OUTPUT via release_publish.php).
1211
+ # Falling back to version_read.php only if that output is absent (older
1212
+ # /opt/mokocli) keeps this backward-compatible while removing the second,
1213
+ # divergent resolution that silently false-skipped the npm publish gate.
1214
+ VERSION="${{ steps.publish.outputs.version }}"
1215
+ [ -z "$VERSION" ] && VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
1216
+ VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
1217
+ [ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
1218
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
1219
+ PLATFORM="${{ steps.platform.outputs.platform }}"
1220
+ # Feed-tag selection: joomla AND electron need a STABLE, ROLLING tag so
1221
+ # their update feed lives at a constant URL. electron (Template-Generic#333,
1222
+ # coordinates MokoGIT#1342): electron-updater's latest.yml / latest-linux.yml /
1223
+ # latest-mac.yml must sit at a fixed path so the MokoGIT electron-yml generator
1224
+ # can poll a constant URL; a moving v${VERSION} tag would relocate the feed every
1225
+ # release and break the updater. Everything else keeps the immutable v${VERSION} tag.
1226
+ if [[ "$PLATFORM" == joomla* || "$PLATFORM" == electron* ]]; then
1227
+ echo "tag=stable" >> "$GITHUB_OUTPUT"
1228
+ echo "release_tag=stable" >> "$GITHUB_OUTPUT"
1229
+ else
1230
+ echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
1231
+ echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
1232
+ fi
1233
+ echo "branch=main" >> "$GITHUB_OUTPUT"
1234
+ echo "Published version: ${VERSION}"
1235
+
1236
+ - name: "Deploy to prod (inline — replaces standalone deploy.yml, #1427)"
1237
+ if: steps.gate.outputs.has_makefile == 'true' && steps.version.outputs.skip != 'true' && vars.PROD_SSH_USERNAME != ''
1238
+ env:
1239
+ DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
1240
+ run: |
1241
+ # #1427: prod deploy inlined here (release job runs on push to stable).
1242
+ # Gate = PROD_SSH_USERNAME set → only SSH-deploy repos deploy; others skip
1243
+ # green. Targets from repo VARIABLES. mokocli deploy.php → restricted
1244
+ # moko-deploy (build → push-guarded → recreate → health-check → rollback;
1245
+ # advances the floating :stable tag only after a healthy deploy). Must be
1246
+ # atomic with deleting deploy.yml — otherwise deploy.yml's push:[stable]
1247
+ # trigger would double-deploy prod.
1248
+ set -euo pipefail
1249
+ if [ -z "${DEPLOY_KEY:-}" ]; then
1250
+ echo "::warning::PROD_SSH_USERNAME set but DEPLOY_SSH_KEY secret missing — skipping prod deploy"; exit 0
1251
+ fi
1252
+ mkdir -p ~/.ssh
1253
+ # DEPLOY_SSH_KEY is base64-encoded (single line) so act_runner masks it in logs
1254
+ # and the multi-line key is never echoed. Decode when it base64-decodes to a
1255
+ # private key; else treat as a legacy raw PEM (back-compat during rollout).
1256
+ install -m600 /dev/null ~/.ssh/deploy_key # 0600 before any bytes (no umask window)
1257
+ trap 'rm -f ~/.ssh/deploy_key' EXIT # never leave the key on a shared runner
1258
+ if printf '%s' "$DEPLOY_KEY" | base64 -d 2>/dev/null | grep -q 'PRIVATE KEY'; then
1259
+ printf '%s' "$DEPLOY_KEY" | base64 -d > ~/.ssh/deploy_key
1260
+ else
1261
+ echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
1262
+ fi
1263
+ # #1637: deploy the IMMUTABLE pushed commit, not a git-describe of the
1264
+ # runner's HEAD. release_publish's internal `checkout -B FETCH_HEAD` can
1265
+ # re-poison HEAD with a replica-lagged commit by this step, so `git describe`
1266
+ # / `git rev-parse HEAD` here can name an ANCESTOR — moko-deploy then built a
1267
+ # stale image while the release reported success (the favicon incident).
1268
+ # $GITHUB_SHA is the pushed stable commit (BUMP-ON-DEV: it already carries the
1269
+ # released VERSION + code) and cannot be re-poisoned. moko-deploy checks out
1270
+ # this exact 40-hex commit (falling back to the branch tip for non-SHA tags).
1271
+ TAG="${GITHUB_SHA}"
1272
+ echo "Deploying prod commit ${TAG} to ${{ vars.PROD_SSH_USERNAME }}@${{ vars.PROD_SSH_HOST }}"
1273
+ php "${MOKO_CLI}/deploy.php" --tier prod --tag "$TAG" \
1274
+ --ssh-host "${{ vars.PROD_SSH_HOST }}" --ssh-port "${{ vars.PROD_SSH_PORT }}" \
1275
+ --ssh-user "${{ vars.PROD_SSH_USERNAME }}" --ssh-key ~/.ssh/deploy_key
1276
+
1277
+ # -- Electron: build installers (Windows via Wine + Linux) and attach ------
1278
+ # No Windows/macOS runner: the `release` runner is Ubuntu, so we cross-build
1279
+ # the Windows target with Wine and build Linux natively. macOS is skipped
1280
+ # (cannot be cross-built off a Mac). All four steps no-op on non-electron repos.
1281
+ - name: "Setup Node (electron)"
1282
+ if: steps.gate.outputs.has_makefile == 'true' && steps.electron.outputs.is_electron == 'true' && steps.version.outputs.skip != 'true' && vars.ELECTRON_CI_BUILD == 'true'
1283
+ uses: actions/setup-node@v4
1284
+ with:
1285
+ node-version: 24.18.1
1286
+
1287
+ - name: "Setup pnpm (electron)"
1288
+ if: steps.gate.outputs.has_makefile == 'true' && steps.electron.outputs.is_electron == 'true' && steps.version.outputs.skip != 'true' && vars.ELECTRON_CI_BUILD == 'true'
1289
+ uses: pnpm/action-setup@v4
1290
+ with:
1291
+ version: 11.20.0
1292
+
1293
+ - name: "Build electron installers (Windows via Wine + Linux)"
1294
+ if: steps.gate.outputs.has_makefile == 'true' && steps.electron.outputs.is_electron == 'true' && steps.version.outputs.skip != 'true' && vars.ELECTRON_CI_BUILD == 'true'
1295
+ env:
1296
+ HUSKY: "0"
1297
+ run: |
1298
+ # electron-builder's NSIS target RUNS the built installer under Wine to
1299
+ # generate + sign the uninstaller, which needs 32-bit Wine libs (the NSIS
1300
+ # stub is a WoW64 exe) AND an X display. A bare `wine64` on an
1301
+ # under-provisioned `release` runner aborts with "wine process failed 1
1302
+ # ... ntdll.dll c0000135 / no driver could be loaded" (intermittent: only
1303
+ # when a job lands on a runner without wine32/xvfb pre-seeded). Provision
1304
+ # i386 Wine + Xvfb here so the Windows leg builds on ANY runner, and init
1305
+ # the prefix once, headless, so electron-builder's first Wine call does
1306
+ # not race prefix creation.
1307
+ sudo dpkg --add-architecture i386 || true
1308
+ sudo apt-get update -qq
1309
+ sudo apt-get install -y -qq xvfb >/dev/null 2>&1 || echo "::warning::xvfb install failed"
1310
+ 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"
1311
+ export WINEDEBUG=-all
1312
+ export WINEPREFIX="${WINEPREFIX:-$HOME/.wine}"
1313
+ xvfb-run -a wineboot --init >/dev/null 2>&1 || true
1314
+ pnpm install --frozen-lockfile
1315
+ # Compile the app into electron-builder's app dir (directories.app, e.g. ./build)
1316
+ # BEFORE packaging -- electron-builder validates the app dir up-front and aborts
1317
+ # with "Application directory ./build doesn't exist" otherwise. Prefer an explicit
1318
+ # repo script (compile:app); fall back to the esbuild entrypoint.
1319
+ if pnpm run 2>/dev/null | grep -qiE '(^|[[:space:]])compile:app([[:space:]]|$)'; then
1320
+ pnpm run compile:app
1321
+ elif [ -f esbuild.mjs ]; then
1322
+ npx --no-install preval-build-info-cli >/dev/null 2>&1 || true
1323
+ node esbuild.mjs
1324
+ else
1325
+ 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"
1326
+ fi
1327
+ # xvfb-run gives Wine a virtual X display for the installer-run pass that
1328
+ # generates the NSIS uninstaller; without it Wine cannot create a window
1329
+ # and the build fails ("no driver could be loaded / $DISPLAY").
1330
+ xvfb-run -a npx --no-install electron-builder --win --linux --publish never || xvfb-run -a npx electron-builder --win --linux --publish never
1331
+
1332
+ - name: "Attach electron installers to the release"
1333
+ if: steps.gate.outputs.has_makefile == 'true' && steps.electron.outputs.is_electron == 'true' && steps.version.outputs.skip != 'true' && vars.ELECTRON_CI_BUILD == 'true'
1334
+ continue-on-error: true
1335
+ run: |
1336
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1337
+ TOKEN="${{ github.token }}"
1338
+ TAG="${{ steps.version.outputs.tag }}"
1339
+ 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)
1340
+ if [ -z "$RID" ]; then echo "::warning::release ${TAG} not found -- skipping electron asset upload"; else
1341
+ n=0
1342
+ 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
1343
+ name=$(basename "$f")
1344
+ curl -sf -X POST -H "Authorization: token ${TOKEN}" "${API_BASE}/releases/${RID}/assets?name=${name}" -F "attachment=@${f}" >/dev/null && { echo "attached ${name}"; n=$((n+1)); } || echo "::warning::failed to upload ${name}"
1345
+ done
1346
+ echo "Electron installers attached to ${TAG}: ${n} file(s)"
1347
+ fi
1348
+
1349
+ # -- mokohub-manifest: zip manifest.json -> attach to the stable release -----
1350
+ # ADDITIVE + GATED (Template-Generic#326). Gated on is_manifest so every other
1351
+ # platform is untouched. Best-effort attach (continue-on-error): the release is
1352
+ # already published, so a packaging hiccup must not red the pipeline.
1353
+ - name: "Package mokohub-manifest (zip manifest.json)"
1354
+ if: >-
1355
+ steps.gate.outputs.has_makefile == 'true' &&
1356
+ steps.mokohub.outputs.is_manifest == 'true' &&
1357
+ steps.version.outputs.skip != 'true'
1358
+ continue-on-error: true
1359
+ run: |
1360
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1361
+ TOKEN="${{ github.token }}"
1362
+ TAG="${{ steps.version.outputs.tag }}"
1363
+ VERSION="${{ steps.version.outputs.version }}"
1364
+ if [ ! -f manifest.json ]; then
1365
+ echo "::warning::mokohub-manifest: no manifest.json at repo root — nothing to package"
1366
+ exit 0
1367
+ fi
1368
+ ZIP="${RUNNER_TEMP:-/tmp}/manifest-${VERSION}.zip"
1369
+ ( cd "$(dirname manifest.json)" && zip -j "$ZIP" manifest.json ) \
1370
+ || { echo "::warning::mokohub-manifest: zip failed"; exit 0; }
1371
+ 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)
1372
+ if [ -z "$RID" ]; then echo "::warning::release ${TAG} not found — skipping manifest asset upload"; exit 0; fi
1373
+ name="$(basename "$ZIP")"
1374
+ curl -sf -X POST -H "Authorization: token ${TOKEN}" "${API_BASE}/releases/${RID}/assets?name=${name}" -F "attachment=@${ZIP}" >/dev/null \
1375
+ && echo "attached ${name} to ${TAG}" || echo "::warning::manifest asset upload failed (${name})"
1376
+
1377
+ # -- mokohub-recipes: build catalog.zip (via MokoCLI) -> release per stream --
1378
+ # ADDITIVE + GATED (Template-Generic#326). Delegate the per-stream signed
1379
+ # catalog.zip (all recipes + all.json) to the MokoCLI build agent (`moko
1380
+ # package`, MokoCLI #684 / epic mokohub-recipes#1). Gated on is_recipes;
1381
+ # best-effort so a not-yet-shipped MokoCLI verb can't red an already-published
1382
+ # release and no existing platform is affected.
1383
+ - name: "Package mokohub-recipes (catalog.zip via MokoCLI)"
1384
+ if: >-
1385
+ steps.gate.outputs.has_makefile == 'true' &&
1386
+ steps.mokohub.outputs.is_recipes == 'true' &&
1387
+ steps.version.outputs.skip != 'true'
1388
+ continue-on-error: true
1389
+ run: |
1390
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1391
+ VERSION="${{ steps.version.outputs.version }}"
1392
+ TAG="${{ steps.version.outputs.tag }}"
1393
+ # `moko package` for the mokohub-recipes platform builds the stable-stream
1394
+ # catalog.zip and attaches it to the release. --stability stable matches the
1395
+ # auto-release (stable) channel; the platform shim selects the catalog builder.
1396
+ moko package \
1397
+ --path . --version "$VERSION" --tag "$TAG" \
1398
+ --token "${{ github.token }}" --api-base "$API_BASE" \
1399
+ --repo "${GIT_REPO}" --out "${RUNNER_TEMP:-/tmp}" \
1400
+ --stability stable \
1401
+ || echo "::warning::mokohub-recipes: moko package (catalog.zip) failed or unavailable — non-fatal (MokoCLI #684)"
1402
+
1403
+ - name: "Create semver tag for non-Joomla repos"
1404
+ id: semver
1405
+ # #469: a NEW immutable vNN tag is a version-bump artifact — skip it in
1406
+ # no-bump mode (should_bump=false). The version did not advance, so the
1407
+ # existing vNN tag already points at the current release; minting a new one
1408
+ # would imply a new version. On the bump path this is unchanged.
1409
+ if: |
1410
+ steps.gate.outputs.has_makefile == 'true' &&
1411
+ steps.srcgate.outputs.should_bump != 'false' &&
1412
+ steps.version.outputs.skip != 'true' &&
1413
+ !startsWith(steps.platform.outputs.platform, 'joomla') &&
1414
+ (steps.electron.outputs.is_electron != 'true' || vars.ELECTRON_CI_BUILD == 'true')
1415
+ run: |
1416
+ VERSION="${{ steps.version.outputs.version }}"
1417
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1418
+ TOKEN="${{ github.token }}"
1419
+ SEMVER_TAG="v${VERSION}"
1420
+ # #386: pin the semver tag to the actual built/published commit (the
1421
+ # post-publish HEAD the release/channel-tag is pinned to), NOT the
1422
+ # triggering commit -- the publish step commits the CHANGELOG entry, so
1423
+ # GITHUB_SHA is one commit stale.
1424
+ BUILT_SHA="$(git rev-parse HEAD 2>/dev/null || echo "${GITHUB_SHA}")"
1425
+
1426
+ echo "Creating semver tag: ${SEMVER_TAG}"
1427
+
1428
+ # Create the git tag via API
1429
+ HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
1430
+ -X POST -H "Authorization: token ${TOKEN}" \
1431
+ -H "Content-Type: application/json" \
1432
+ "${API_BASE}/tags" \
1433
+ -d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"${BUILT_SHA}\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
1434
+
1435
+ if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
1436
+ echo "Created semver tag: ${SEMVER_TAG}"
1437
+ elif [ "$HTTP_CODE" = "409" ]; then
1438
+ echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
1439
+ else
1440
+ echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
1441
+ fi
1442
+
1443
+ echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
1444
+
1445
+ # -- "Advance rolling channel tag via git" REMOVED (#1621 Phase 6) ---------
1446
+ # This step force-pushed refs/tags/stable to the built HEAD so the #386 gate
1447
+ # below would pass. It is now REDUNDANT: the publish already pins the `stable`
1448
+ # channel ref via release_create's forceTagRef() (#808), and #1621 has the
1449
+ # forge attach/move the channel tag onto the dual-tagged release server-side
1450
+ # (channel_tags[] on create -> AttachChannelTag -> moveGitTag at the release
1451
+ # commit). The #386 gate still resolves `tags/stable` to the built commit, so
1452
+ # it stays valid without this duplicate git force-push.
1453
+
1454
+ - name: "Release integrity gate: artifact commit == tag commit (#386)"
1455
+ if: steps.gate.outputs.has_makefile == 'true' && steps.version.outputs.skip != 'true' && (steps.electron.outputs.is_electron != 'true' || vars.ELECTRON_CI_BUILD == 'true')
1456
+ # #386: assert the published tag's commit == the commit we actually built &
1457
+ # published, and FAIL on a definite mismatch (a tag pinned to a commit we did
1458
+ # NOT build -- e.g. a LATER branch HEAD advanced by a concurrent merge). The
1459
+ # publish step commits the CHANGELOG entry ("build NN.NN.NN [skip ci]") and
1460
+ # mokocli pins the tag to that POST-PUBLISH HEAD, which is one commit past the
1461
+ # triggering GITHUB_SHA -- so BUILT must be the current HEAD, not GITHUB_SHA
1462
+ # (using GITHUB_SHA false-failed every cut whose CHANGELOG wasn't pre-seeded on
1463
+ # dev). Fail-OPEN on any ambiguity (no tag / unresolvable) so a good release is
1464
+ # never falsely blocked.
1465
+ run: |
1466
+ set -uo pipefail
1467
+ BUILT="$(git rev-parse HEAD 2>/dev/null || echo "${GITHUB_SHA}")"
1468
+ # #386: assert against the MOVED channel pointer (the rolling "stable"
1469
+ # tag, force-pinned to the built commit by release_create forceTagRef),
1470
+ # NOT the immutable vNN.NN.NN minor tag. The version-primary /
1471
+ # minor-identity model (#1621/#76/#1725) deliberately never re-points
1472
+ # vNN.NN.NN on an in-place (#469/#772) cut, so checking
1473
+ # steps.version.outputs.tag (= v${VERSION} on generic/non-Joomla)
1474
+ # false-failed every no-bump release. joomla/electron already feed-tag
1475
+ # "stable", which is why they never hit this.
1476
+ TAG="stable"
1477
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1478
+ if [ -z "$TAG" ] || [ -z "$BUILT" ]; then
1479
+ echo "::notice::integrity gate: missing tag or built SHA - skipping (fail-open)."; exit 0
1480
+ fi
1481
+ TAG_SHA=$(curl -sf -H "Authorization: token ${{ github.token }}" "${API_BASE}/tags/${TAG}" \
1482
+ | python3 -c "import sys,json; print((json.load(sys.stdin).get('commit') or {}).get('sha',''))" 2>/dev/null || true)
1483
+ if [ -z "$TAG_SHA" ]; then
1484
+ echo "::notice::integrity gate: cannot resolve tag ${TAG} to a commit - skipping (fail-open)."; exit 0
1485
+ fi
1486
+ if [ "$TAG_SHA" != "$BUILT" ]; then
1487
+ echo "::error::Release integrity FAIL (#386): built commit ${BUILT} != tag ${TAG} commit ${TAG_SHA}. Published artifact may not match its tag (pre-merge/advanced-branch race). Failing so a stale release cannot ship silently."
1488
+ exit 1
1489
+ fi
1490
+ echo "Release integrity OK: tag ${TAG} -> ${BUILT}"
1491
+
1492
+ - name: Update release notes and reconcile changelog
1493
+ if: steps.gate.outputs.has_makefile == 'true' && (steps.electron.outputs.is_electron != 'true' || vars.ELECTRON_CI_BUILD == 'true')
1494
+ env:
1495
+ # Admin-bypass token for the idempotent Contents-API changelog reconcile
1496
+ # below (#510) — needed only on the fix/hotfix->main path (see comment).
1497
+ GITHUB_TOKEN: ${{ github.token }}
1498
+ run: |
1499
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1500
+ TOKEN="${{ github.token }}"
1501
+
1502
+ # Get the stable release info (version and ID).
1503
+ # #1702 / .mokogit-private#76: resolve the current stable release by its
1504
+ # CHANNEL POINTER (releases/channel/stable -> ReleaseStreamMap), not by a
1505
+ # fixed tag_name=stable. Version-primary releases carry tag_name=vNN, so
1506
+ # GET /releases/tags/stable no longer resolves the current stable release.
1507
+ # Graceful fallback to the legacy tags/stable call for forges predating
1508
+ # #1702 (the channel route 404s pre-rollout). Both return the same release
1509
+ # JSON shape (id + name "... (VERSION: NN.NN.NN)").
1510
+ RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
1511
+ "${API_BASE}/releases/channel/stable" 2>/dev/null || true)
1512
+ [ -z "$RELEASE_JSON" ] && RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
1513
+ "${API_BASE}/releases/tags/stable" 2>/dev/null || true)
1514
+ [ -z "$RELEASE_JSON" ] && RELEASE_JSON='{}'
1515
+ RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true)
1516
+ # Extract version from release name (e.g. "06.17.00" or "v06.17.00")
1517
+ VERSION=$(python3 -c "
1518
+ import json, sys, re
1519
+ r = json.load(sys.stdin)
1520
+ name = r.get('name', '')
1521
+ m = re.search(r'(\d+\.\d+\.\d+)', name)
1522
+ print(m.group(1) if m else '')
1523
+ " <<< "$RELEASE_JSON" 2>/dev/null || true)
1524
+
1525
+ # Extract [Unreleased] section via the shared mokocli command (#364 centralization).
1526
+ NOTES=""
1527
+ if [ -f "release_notes.md" ] && [ -s "release_notes.md" ]; then
1528
+ NOTES="$(cat release_notes.md)"
1529
+ fi
1530
+ # #71 / #1725: fall back to the MINOR section of CHANGELOG when no curated
1531
+ # release_notes.md. A stable release is identified by its MINOR (the VERSION
1532
+ # resolved above from the release name is NN.NN.00 — the minor is the unit of
1533
+ # release identity), and within-minor patch notes are ACCUMULATED under that
1534
+ # one ## [NN.NN.00] heading. So read $VERSION — NOT [Unreleased], which the
1535
+ # accumulate step just emptied — to get the whole minor's stacked notes.
1536
+ # --channel stable makes an empty section fall back to the "Stable release"
1537
+ # label rather than a bare "Release NN.NN.00".
1538
+ if [ -z "$NOTES" ] && [ -f "CHANGELOG.md" ]; then
1539
+ NOTES=$(php ${MOKO_CLI}/release_notes.php --path . --version "${VERSION:-Unreleased}" --channel stable 2>/dev/null || true)
1540
+ fi
1541
+ [ -z "$NOTES" ] && NOTES="Stable release"
1542
+
1543
+ # Update release body via API
1544
+ if [ -n "$RELEASE_ID" ]; then
1545
+ python3 -c "
1546
+ import json, urllib.request
1547
+ body = open('/dev/stdin').read()
1548
+ payload = json.dumps({'body': body}).encode()
1549
+ req = urllib.request.Request(
1550
+ '${API_BASE}/releases/${RELEASE_ID}',
1551
+ data=payload, method='PATCH',
1552
+ headers={
1553
+ 'Authorization': 'token ${TOKEN}',
1554
+ 'Content-Type': 'application/json'
1555
+ })
1556
+ urllib.request.urlopen(req)
1557
+ " <<< "$NOTES"
1558
+ echo "Release notes updated (release_notes.md if present, else CHANGELOG.md)"
1559
+ fi
1560
+
1561
+ # Bump-on-dev: the CHANGELOG [Unreleased] -> [version] promotion now
1562
+ # happens on DEV pre-promotion (promote-rc's release_bump_dev.php step),
1563
+ # so main INHERITS the already-promoted changelog via the merge and this
1564
+ # step normally has nothing to do. We keep an idempotent reconcile here as
1565
+ # a safety-net for the fix/hotfix->main path (a fix/* PR merges straight to
1566
+ # main WITHOUT going through dev/promote-rc, so its [Unreleased] section was
1567
+ # never promoted). changelog_promote.php --branch is idempotent (won't
1568
+ # re-promote an existing version) and merges/normalizes, so on the dev path
1569
+ # it is a no-op; on the fix path it promotes. Non-fatal (`|| true`): the
1570
+ # release is already published, and on the dev path there is nothing to do.
1571
+ if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then
1572
+ php ${MOKO_CLI}/changelog_promote.php --path . --version "$VERSION" --accumulate \
1573
+ --branch "${{ github.event.repository.default_branch }}" --token "${GITHUB_TOKEN}" --api-base "${API_BASE}" \
1574
+ || echo "::notice::changelog reconcile on main was a no-op or non-fatal (already promoted on dev)"
1575
+ echo "Changelog reconciled on main for [${VERSION}] (idempotent, accumulate)"
1576
+ fi
1577
+
1578
+ # Publish the stable release notes to mokolive (#470 / .mokogit-private#85).
1579
+ # INERT BY DEFAULT: skips unless BOTH the MOKOLIVE_ENDPOINT and
1580
+ # MOKOLIVE_RELEASE_NOTES_CATEGORY_ID repo/org variables are set, so the
1581
+ # step is a no-op on every repo until an owner opts in. BEST-EFFORT:
1582
+ # continue-on-error + an ntfy alert on failure, so it never red-fails a
1583
+ # release that already published. Delegates to the MokoCLI companion
1584
+ # subcommand release_publish_notes.php (MokoCLI#951, built in parallel) —
1585
+ # the step is dependency-safe: if the token is unset it exits 0, and if the
1586
+ # subcommand is absent the php call simply fails into the best-effort ntfy.
1587
+ - name: Publish release notes to mokolive
1588
+ if: steps.gate.outputs.has_makefile == 'true' && steps.version.outputs.skip != 'true' &&
1589
+ vars.MOKOLIVE_ENDPOINT != '' && vars.MOKOLIVE_RELEASE_NOTES_CATEGORY_ID != ''
1590
+ continue-on-error: true
1591
+ env:
1592
+ MOKOLIVE_API_TOKEN: ${{ secrets.MOKOLIVE_API_TOKEN }}
1593
+ NTFY_TOPIC: ${{ vars.MOKOLIVE_NTFY_TOPIC || vars.NTFY_TOPIC || 'git-releases' }}
1594
+ run: |
1595
+ if [ -z "${MOKOLIVE_API_TOKEN:-}" ]; then echo "::notice::mokolive token unset; skipping release-notes publish"; exit 0; fi
1596
+ # Resolve the PREVIOUS stable version so the subcommand can compute the
1597
+ # bump against --min-bump. There is no steps.*.outputs for the prior
1598
+ # version (the monotonic guard computes it locally but does not export
1599
+ # it), so resolve it the same channel-first way the rest of this file
1600
+ # does: GET releases/channel/stable -> release NAME "VERSION: NN.NN.NN",
1601
+ # with the legacy tags/stable fallback for forges predating #1702. The
1602
+ # `stable` channel still points at the PRIOR release at this point —
1603
+ # the new release's channel-tag move happens in the publish step above,
1604
+ # but this lookup runs on a fresh curl so empty/unresolved is fine
1605
+ # (subcommand treats empty --prev-version as first-release -> publishes).
1606
+ PREV_VERSION=""
1607
+ PREV_JSON=$(curl -sf -H "Authorization: token ${{ github.token }}" \
1608
+ "${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}/releases/channel/stable" 2>/dev/null || true)
1609
+ [ -z "$PREV_JSON" ] && PREV_JSON=$(curl -sf -H "Authorization: token ${{ github.token }}" \
1610
+ "${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}/releases/tags/stable" 2>/dev/null || true)
1611
+ PREV_VERSION=$(printf '%s' "${PREV_JSON:-}" \
1612
+ | grep -oE 'VERSION: [0-9]{2}\.[0-9]{2}\.[0-9]{2}' | grep -oE '[0-9]{2}\.[0-9]{2}\.[0-9]{2}' | head -n1 || true)
1613
+ php ${MOKO_CLI}/release_publish_notes.php --path . \
1614
+ --version "${{ steps.version.outputs.version }}" \
1615
+ --prev-version "${PREV_VERSION}" \
1616
+ --min-bump "${{ vars.MOKOLIVE_PUBLISH_BUMP || 'patch' }}" \
1617
+ --endpoint "${{ vars.MOKOLIVE_ENDPOINT }}" \
1618
+ --category-id "${{ vars.MOKOLIVE_RELEASE_NOTES_CATEGORY_ID }}" \
1619
+ --token "${MOKOLIVE_API_TOKEN}" \
1620
+ --software-slug "${{ vars.MOKOLIVE_SOFTWARE_SLUG || github.event.repository.name }}" \
1621
+ --release-url "${MOKOGIT_URL}/${GIT_ORG}/${GIT_REPO}/releases/tag/${{ steps.version.outputs.tag }}" \
1622
+ || curl -sS -H "Title: mokolive release-notes publish failed: ${GIT_REPO} ${{ steps.version.outputs.version }}" \
1623
+ -H "Tags: warning" -H "Priority: high" \
1624
+ -d "release:publish-notes failed for ${GIT_REPO} ${{ steps.version.outputs.version }}" \
1625
+ "${NTFY_URL}/${NTFY_TOPIC}" || true
1626
+
1627
+ # -- npm stable publish: ABSORBED from Template-NPM's retired npm-publish.yml
1628
+ # Replaces the former "Dispatch npm publish" fan-out to the standalone
1629
+ # npm-publish.yml. Dual-registry, idempotent, gated on the npm platform so
1630
+ # every non-npm platform is 100% unaffected. MokoGIT npm registry uses the
1631
+ # per-run Actions token (packages:write, least-privilege, no shared secret);
1632
+ # npmjs.org uses NPM_TOKEN (best-effort, skipped if absent). A version already
1633
+ # present on a registry is a no-op. Version is the Makefile PROJECT_VERSION
1634
+ # (single source of truth), stamped into package.json in-tree only.
1635
+ - name: "Setup Node (npm publish)"
1636
+ if: steps.gate.outputs.has_makefile == 'true' && steps.npmpub.outputs.publishable == 'true' && (startsWith(steps.platform.outputs.platform, 'npm') || startsWith(steps.platform.outputs.platform, 'nodejs'))
1637
+ uses: actions/setup-node@v4
1638
+ with:
1639
+ node-version: '20'
1640
+ - name: "npm: resolve version, stamp package.json, build"
1641
+ if: steps.gate.outputs.has_makefile == 'true' && steps.npmpub.outputs.publishable == 'true' && (startsWith(steps.platform.outputs.platform, 'npm') || startsWith(steps.platform.outputs.platform, 'nodejs'))
1642
+ run: |
1643
+ set -euo pipefail
1644
+ VERSION=$(grep -E '^[[:space:]]*PROJECT_VERSION[[:space:]]*:?=' Makefile | head -1 | sed 's/.*=[[:space:]]*//' | tr -d '[:space:]')
1645
+ [ -z "$VERSION" ] && { echo "::error::No PROJECT_VERSION in Makefile - cannot determine npm publish version."; exit 1; }
1646
+ # Makefile PROJECT_VERSION is zero-padded NN.NN.NN (e.g. 01.00.01); npm needs
1647
+ # strict semver (no leading zeros), so normalize before stamping.
1648
+ SEMVER=$(printf '%s' "$VERSION" | awk -F. '{printf "%d.%d.%d",$1,$2,$3}')
1649
+ npm version "$SEMVER" --no-git-tag-version --allow-same-version
1650
+ npm ci --no-audit --no-fund || npm install --no-audit --no-fund
1651
+ npm run build --if-present
1652
+ echo "Prepared npm publish for ${SEMVER} (source: Makefile PROJECT_VERSION ${VERSION})"
1653
+ - name: "npm: publish to the MokoGIT registry (per-run Actions token)"
1654
+ if: steps.gate.outputs.has_makefile == 'true' && steps.npmpub.outputs.publishable == 'true' && (startsWith(steps.platform.outputs.platform, 'npm') || startsWith(steps.platform.outputs.platform, 'nodejs'))
1655
+ env:
1656
+ NODE_AUTH_TOKEN: ${{ github.token }}
1657
+ run: |
1658
+ set -euo pipefail
1659
+ HOST="${MOKOGIT_URL#https://}"; OWNER="${GIT_ORG}"
1660
+ REG="https://${HOST}/api/packages/${OWNER}/npm/"
1661
+ echo "//${HOST}/api/packages/${OWNER}/npm/:_authToken=${NODE_AUTH_TOKEN}" > .npmrc
1662
+ set +e; out="$(npm publish --ignore-scripts --registry "${REG}" 2>&1)"; rc=$?; set -e
1663
+ rm -f .npmrc; echo "$out"
1664
+ if [ "$rc" -eq 0 ]; then echo "Published to the MokoGIT registry."
1665
+ elif printf '%s' "$out" | grep -qiE 'already exists|cannot publish over|EPUBLISHCONFLICT|409 Conflict'; then echo "::notice::MokoGIT registry already has this version - no-op (idempotent)."
1666
+ else echo "::error::MokoGIT-registry publish FAILED (npm exit ${rc}) - NOT 'already published'. See the npm error above."; exit "$rc"; fi
1667
+ - name: "npm: publish to npmjs.org (NPM_TOKEN, best-effort)"
1668
+ if: steps.gate.outputs.has_makefile == 'true' && steps.npmpub.outputs.publishable == 'true' && (startsWith(steps.platform.outputs.platform, 'npm') || startsWith(steps.platform.outputs.platform, 'nodejs'))
1669
+ env:
1670
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
1671
+ run: |
1672
+ set -euo pipefail
1673
+ if [ -z "${NPM_TOKEN:-}" ]; then echo "::notice::NPM_TOKEN not set - skipping the public npmjs.org publish (MokoGIT registry is primary)."; exit 0; fi
1674
+ echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc
1675
+ set +e; out="$(npm publish --ignore-scripts --registry https://registry.npmjs.org/ --access public 2>&1)"; rc=$?; set -e
1676
+ rm -f .npmrc; echo "$out"
1677
+ if [ "$rc" -eq 0 ]; then echo "Published to npmjs.org."
1678
+ elif printf '%s' "$out" | grep -qiE 'already exists|cannot publish over|EPUBLISHCONFLICT|409 Conflict'; then echo "::notice::npmjs.org already has this version - no-op."
1679
+ elif printf '%s' "$out" | grep -qiE 'not in this registry|ENEEDAUTH|EOTP|need auth|E401|E403|401 Unauthorized|403 Forbidden|404 Not Found|Forbidden'; then echo "::error::npmjs.org publish FAILED — authorization error (npm exit ${rc}). NPM_TOKEN lacks publish/create rights on the @mokoconsulting scope (new-scoped-name first publish, an unauthorized token, or a revoked/expired token). Failing loud so a broken publish token cannot hide behind a green release — a 404 PUT is how npm masks an unauthorized scoped write."; exit "$rc"
1680
+ else echo "::warning::npmjs.org publish failed (npm exit ${rc}) — transient/5xx/network issue; npmjs is the secondary mirror (MokoGIT registry is primary), not failing the run."; fi
1681
+
1682
+ # GitHub mirroring (release assets + main branch) moved to an out-of-band
1683
+ # OAuth push-mirror (Gitea repo setting) -- former Steps 9 & 10 removed.
1684
+
1685
+ - name: "Step 11: Delete rc branch (dev reset moved to cascade.yml)"
1686
+ if: steps.gate.outputs.has_makefile == 'true' && steps.version.outputs.skip != 'true'
1687
+ continue-on-error: true
1688
+ run: |
1689
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1690
+ TOKEN="${{ github.token }}"
1691
+
1692
+ # Delete rc branch (ephemeral -- created by promote-rc)
1693
+ curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
1694
+ "${API_BASE}/branches/rc" 2>/dev/null \
1695
+ && echo "Deleted rc branch" || echo "rc branch not found"
1696
+
1697
+ # dev is reset from main by the fleet cascade workflow (cascade.yml, which
1698
+ # rolled up the retired cascade-dev.yml): a push to main cascades down the
1699
+ # ladder, recreating/resetting dev from main after this release completes.
1700
+ echo "rc cleaned; dev reset handled by cascade.yml" >> $GITHUB_STEP_SUMMARY
1701
+
1702
+ # -- Version identity: #1621 Phase 6 retired the per-version branches ------
1703
+ # The old "Step 12: Create version branch from main" and "Step 13: Prune
1704
+ # superseded-major version branches" are REMOVED. A release's permanent
1705
+ # version identity is now a git TAG (v<version>) attached by the publish as a
1706
+ # movable channel tag on the dual-tagged release (MokoCLI release_publish ->
1707
+ # release_create channel_tags[]/update_stream; MokoGIT #1621), not a
1708
+ # version/* branch. No version/* consumer exists, so nothing creates them any
1709
+ # more. Pruning EXISTING version/* branches is a SEPARATE one-time manual
1710
+ # reconcile (do not prune here) — e.g. stop protecting version/* in
1711
+ # cleanup.yml and let the stale-branch sweep retire them, or a one-shot fleet
1712
+ # delete. See issue #1621 Phase 6.
1713
+
1714
+ # -- Post-release: Reset dev version (safety-net) ----------------
1715
+ - name: "Post-release: Reset dev version"
1716
+ if: steps.gate.outputs.has_makefile == 'true' && steps.version.outputs.skip != 'true'
1717
+ continue-on-error: true
1718
+ run: |
1719
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1720
+ # Post-release SAFETY-NET: advance dev ABOVE the just-released version so the
1721
+ # dev update channel always leads stable (a dev build X.Y.Z-dev sorts BELOW
1722
+ # stable X.Y.Z, so dev MUST be bumped past the release or dev-channel updates
1723
+ # vanish). #309/#900: this was previously a SILENT swallow — a failed advance
1724
+ # under continue-on-error left the run green while dev drifted below stable.
1725
+ # Now the failure is LOUD (::error:: + job summary), and on success we also
1726
+ # explicitly (re)publish the leading dev build, because the advance commit is
1727
+ # `[skip ci]` and matches pre-release.yml's paths-ignore, so it never auto-fires.
1728
+ if php ${MOKO_CLI}/version_reset_dev.php \
1729
+ --token "${{ github.token }}" --api-base "${API_BASE}" \
1730
+ --branch dev --path . 2>&1; then
1731
+ # Advance landed → publish the leading dev build (best-effort; the release
1732
+ # already succeeded, so a publish hiccup must not red the pipeline).
1733
+ curl -sf -X POST \
1734
+ -H "Authorization: token ${{ github.token }}" \
1735
+ -H "Content-Type: application/json" \
1736
+ "${API_BASE}/actions/workflows/pre-release.yml/dispatches" \
1737
+ -d '{"ref":"dev","inputs":{"stability":"development","ref":"dev"}}' \
1738
+ && echo "Triggered pre-release (development) to publish the leading dev build." \
1739
+ || echo "::warning::Could not dispatch pre-release; the leading dev build will publish on the next real dev push."
1740
+ else
1741
+ # Do NOT silently swallow: a failed advance means the dev channel falls
1742
+ # BEHIND stable and dev-channel updates vanish fleet-wide. Surface loudly
1743
+ # (the stable release already published, so this stays green via
1744
+ # continue-on-error — but it is now impossible to miss).
1745
+ echo "::error::Post-release dev-advance FAILED — dev did not move above the released version; the dev update channel will trail stable until fixed. Most likely the CI Actions token lacks MERGE rights on protected 'dev' (org merge-whitelist). See MokoCLI #900 / Template-Joomla #309."
1746
+ {
1747
+ echo "### ⚠️ Dev-advance FAILED (dev-channel will trail stable)"
1748
+ echo ""
1749
+ echo "\`version_reset_dev.php\` could not advance \`dev\` above the released version, so the dev update channel falls behind stable and dev-channel sites stop seeing updates."
1750
+ echo ""
1751
+ echo "Likely cause: the CI Actions token lacks **MERGE** rights on protected \`dev\` (org merge-whitelist). See **MokoCLI #900** / **Template-Joomla #309**."
1752
+ } >> "$GITHUB_STEP_SUMMARY"
1753
+ fi
1754
+
1755
+ # -- Summary --------------------------------------------------------------
1756
+ - name: Pipeline Summary
1757
+ if: always()
1758
+ run: |
1759
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
1760
+ PLATFORM="${{ steps.platform.outputs.platform }}"
1761
+ if [ "${{ steps.gate.outputs.has_makefile }}" != "true" ]; then
1762
+ echo "## Release Skipped -- no Makefile" >> $GITHUB_STEP_SUMMARY
1763
+ echo "No Makefile at the repository root -- this repo does not build a release." >> $GITHUB_STEP_SUMMARY
1764
+ elif [ "${{ steps.version.outputs.skip }}" = "true" ]; then
1765
+ echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
1766
+ echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
1767
+ elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
1768
+ echo "## Already Released -- ${VERSION}" >> $GITHUB_STEP_SUMMARY
1769
+ else
1770
+ # #469: label the run bump vs no-bump (update-in-place) for clarity.
1771
+ MODE="Build & Release Complete"
1772
+ [ "${{ steps.srcgate.outputs.should_bump }}" = "false" ] && MODE="Release Updated In Place (no bump, #469)"
1773
+ echo "" >> $GITHUB_STEP_SUMMARY
1774
+ echo "## ${MODE} (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
1775
+ if [ "${{ steps.srcgate.outputs.should_bump }}" = "false" ]; then
1776
+ echo "" >> $GITHUB_STEP_SUMMARY
1777
+ echo "Non-source push (no \`SRC_DIR/\` change): refreshed the existing stable release in place — version unchanged, no new \`vNN\` tag (#469)." >> $GITHUB_STEP_SUMMARY
1778
+ fi
1779
+ echo "" >> $GITHUB_STEP_SUMMARY
1780
+ echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
1781
+ echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
1782
+ echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
1783
+ echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
1784
+ echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
1785
+ echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
1786
+ echo "| Release | [View](${MOKOGIT_URL}/${GIT_ORG}/${GIT_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
1787
+ fi