@mokoenterprises/mokoabs-mcp 0.0.0-stage → 4.2.1

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 (53) 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 +141 -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 +1972 -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 +365 -0
  26. package/.mokogit/workflows/npm-build-test.yml +544 -0
  27. package/.mokogit/workflows/npm-sdk-check.yml +110 -0
  28. package/.mokogit/workflows/npm-tool-inventory.yml +82 -0
  29. package/.mokogit/workflows/pr-branch-check.yml +92 -0
  30. package/.mokogit/workflows/pre-release.yml +1192 -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 +159 -0
  43. package/package.json +34 -4
  44. package/sample/config.example.json +15 -0
  45. package/scripts/deploy-vendor-mcps.ps1 +103 -0
  46. package/source/client.ts +162 -0
  47. package/source/config.ts +139 -0
  48. package/source/credentialStore.ts +216 -0
  49. package/source/index.ts +242 -0
  50. package/source/tokenReader.ts +120 -0
  51. package/source/types.ts +63 -0
  52. package/tools/deploy.mjs +27 -0
  53. package/tsconfig.json +19 -0
@@ -0,0 +1,1972 @@
1
+ # Copyright (C) 2026 Moko Enterprises <hello@mokoenterprises.us>
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.mokoenterprises.us/MokoEnterprises/Template-Generic
9
+ # PATH: /.mokogit/workflows/auto-release.yml
10
+ # VERSION: 07.25.01
11
+ # BRIEF: NEW (07.25.01): consistency follow-up to 07.25.00 — the monotonic self-heal's DEV CARRY-FORWARD now also bumps PATCH, not MINOR. After the self-heal patch-bumps the stable base, it carries that bump forward to dev via `release_bump_dev.php --path . --branch dev --bump patch` (was `--bump minor`), so a direct-push / fast-merge hotfix does NOT inflate the dev base by a full minor — dev and stable stay aligned on the PATCH heal. UNCHANGED: the promote-rc job's planned-release dev PRE-bump (`release_bump_dev.php --path . --branch dev --bump minor`) still stays MINOR; only the self-heal carry-forward inside the fast-merge bump guard changes minor->patch. PREVIOUS (07.25.00): the "Guarantee monotonic release version (fast-merge bump guard)" self-heal now bumps PATCH, not MINOR. On a push to stable whose version was NOT pre-bumped on dev — a direct admin push, or a fast dev->stable merge that landed before promote-rc ran its dev pre-bump — the monotonic guard previously self-healed with a MINOR bump (`version_bump.php --minor --min-version "$LAST"`), minting a new MINOR for an unplanned change. It now self-heals with a PATCH bump (`version_bump.php --patch --min-version "$LAST"`), matching hotfix / direct-admin-push semantics: `--min-version "$LAST"` still guarantees the healed version is strictly above the last release, so monotonicity is preserved. PLANNED ladder releases are UNCHANGED — the promote-rc dev pre-bump (`release_bump_dev.php --bump minor`) and the stable `moko publish --bump none --no-commit` are untouched; only the non-pre-bumped self-heal path changes minor->patch. The dev side already patch-bumps per push (pre-release.yml `release_bump_dev.php --branch dev --bump patch`), so the healed PATCH carries forward consistently. PREVIOUS (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.mokoenterprises.us' }}
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.mokoenterprises.us}"
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.mokoenterprises.us/api/packages/MokoEnterprises/composer"}'
181
+ if composer create-project --no-install --no-interaction --no-progress --quiet \
182
+ --repository="${REG}" mokoenterprises/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@mokoenterprises.us"
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]@mokoenterprises.us"
359
+ git config --local user.name "mokogit-actions[bot]"
360
+ git remote set-url origin "https://x-access-token:${PUSH_TOK}@git.mokoenterprises.us/${{ 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
+ # Resolve the curated notes file CASE-INSENSITIVELY: prefer RELEASE_NOTES.md,
468
+ # then release_notes.md, else the first case-insensitive match in the repo root.
469
+ RN_FILE=""
470
+ if [ -f "RELEASE_NOTES.md" ]; then RN_FILE="RELEASE_NOTES.md"
471
+ elif [ -f "release_notes.md" ]; then RN_FILE="release_notes.md"
472
+ else RN_FILE="$(find . -maxdepth 1 -type f -iname 'release_notes.md' -printf '%P\n' 2>/dev/null | head -n1 || true)"; fi
473
+ NOTES=""
474
+ if [ -n "$RN_FILE" ] && [ -f "$RN_FILE" ] && [ -s "$RN_FILE" ]; then
475
+ NOTES="$(cat "$RN_FILE")"
476
+ fi
477
+ # #71: fall back to CHANGELOG [Unreleased] when no curated release-notes file
478
+ if [ -z "$NOTES" ] && [ -f "CHANGELOG.md" ]; then
479
+ NOTES=$(php ${MOKO_CLI}/release_notes.php --path . --version Unreleased 2>/dev/null || true)
480
+ # release_notes.php echoes "Release Unreleased" when the section is empty; normalize
481
+ # to empty so the platform fallback below applies.
482
+ [ "$NOTES" = "Release Unreleased" ] && NOTES=""
483
+ fi
484
+ [ -z "$NOTES" ] && NOTES="Release candidate"
485
+
486
+ # Find the RC release and update its body
487
+ RELEASE_ID=$(curl -sf -H "Authorization: token ${TOKEN}" \
488
+ "${API_BASE}/releases/tags/release-candidate" \
489
+ | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
490
+
491
+ if [ -n "$RELEASE_ID" ]; then
492
+ python3 -c "
493
+ import json, urllib.request
494
+ body = open('/dev/stdin').read()
495
+ payload = json.dumps({'body': body}).encode()
496
+ req = urllib.request.Request(
497
+ '${API_BASE}/releases/${RELEASE_ID}',
498
+ data=payload, method='PATCH',
499
+ headers={
500
+ 'Authorization': 'token ${TOKEN}',
501
+ 'Content-Type': 'application/json'
502
+ })
503
+ urllib.request.urlopen(req)
504
+ " <<< "$NOTES"
505
+ echo "RC release notes updated from CHANGELOG.md"
506
+ fi
507
+
508
+ - name: Summary
509
+ if: always()
510
+ run: |
511
+ echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
512
+ echo "Base bumped + changelog promoted on dev (pre-promotion); rc cut from dev; RC release built" >> $GITHUB_STEP_SUMMARY
513
+
514
+ # -- Merged PR -> Build & Release (or promote RC to stable) -------------------------
515
+ release:
516
+ name: Build & Release Pipeline
517
+ # LITERAL runner label — see #366/#134. Gitea does not resolve the
518
+ # `${{ vars.RELEASE_RUNNER_LABEL || 'release' }}` fallback for an unset var in
519
+ # `runs-on`, so the job aborts with no runner. The registered label is `release`.
520
+ runs-on: release
521
+ # Release-create (vNN tag + release entry), rc-tag move, push, npm-registry publish,
522
+ # and the post-release dispatch are all contents/packages/actions WRITES. Gitea's
523
+ # auto-token derives perms from the JOB-level permissions block ONLY (workflow-level
524
+ # `permissions:` is NOT inherited into a job's token); without this block the token
525
+ # falls to the org default (empty) and POST /releases 404s fleet-wide (#1712).
526
+ permissions:
527
+ contents: write
528
+ packages: write
529
+ actions: write
530
+ # Skip on template repos (Template-*) -- they scaffold other repos and do not release.
531
+ # Stable release fires on a merged PR into main (pull_request:closed + merged) OR a
532
+ # direct PUSH to main. The pull_request path is the reliable one -- a PR-merge does not
533
+ # always emit a base-branch push event to Actions, so relying on push alone silently
534
+ # skipped releases (a nightly scheduled safety-net used to be the band-aid; it and its
535
+ # cron have been removed -- the pull_request path is the durable fix). The release
536
+ # steps already read github.event.pull_request.head.ref, so they handle both events.
537
+ # Loop guard: skip a self-push that carries
538
+ # [skip ci]/[skip sync] (mokocli's build commit + the changelog-promote commit).
539
+ # #469: the non-code paths-ignore was REMOVED, so a docs/CI-only push now reaches
540
+ # this job and runs in no-bump update mode (gated by steps.srcgate below) instead
541
+ # of being dropped; the [skip ci]/[skip sync] re-checks here remain the loop guard.
542
+ # workflow_dispatch stays exempt (explicit human run).
543
+ # STABLE-RELEASE GATE (default_branch == 'main'): a stable release only fires when
544
+ # the repo's DEFAULT branch is main. main's mere existence no longer arms stable
545
+ # releases — so every repo can pre-create all channel branches (dev/rc/beta/alpha/main)
546
+ # and have them sit idle. A repo promotes itself to "stable-ready" by setting its
547
+ # default to main; new/in-development repos keep default 'dev' and never auto-release.
548
+ if: >-
549
+ !startsWith(github.event.repository.name, 'Template-') && github.event.repository.name != '.vault' &&
550
+ (github.event.repository.default_branch == 'main' || github.event.repository.default_branch == 'stable') &&
551
+ (
552
+ (
553
+ github.event_name == 'push' &&
554
+ !contains(github.event.head_commit.message, '[skip ci]') &&
555
+ !contains(github.event.head_commit.message, '[skip sync]')
556
+ ) ||
557
+ (
558
+ github.event_name == 'pull_request' &&
559
+ github.event.action == 'closed' &&
560
+ github.event.pull_request.merged == true &&
561
+ github.event.pull_request.base.ref == github.event.repository.default_branch &&
562
+ !contains(github.event.pull_request.title, '[skip ci]') &&
563
+ !contains(github.event.pull_request.title, '[skip sync]')
564
+ ) ||
565
+ (github.event_name == 'workflow_dispatch' && inputs.action != 'promote-rc')
566
+ )
567
+
568
+ steps:
569
+ - name: Checkout repository
570
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
571
+ with:
572
+ token: ${{ github.token }}
573
+ fetch-depth: 0
574
+ submodules: false
575
+
576
+ # #1672: transient step-0 checkout aborts on the shared `release` runners make
577
+ # every stable release a dice-roll. This fallback fires ONLY when the checkout
578
+ # above failed (step-level `if: failure()` — a LITERAL, not a discarded
579
+ # continue-on-error expression) and re-materialises the workspace with a
580
+ # bounded 3-attempt git clone + backoff. fetch-depth: 0 is preserved with a
581
+ # FULL clone (no --depth) + tags, because the monotonic-guard and bump steps
582
+ # below need the parent commit and tag history. Fails loudly (exit 1) if all
583
+ # three attempts fail, so a real (non-transient) breakage is never masked.
584
+ # Independent step — no needs:+always() fan-in (act_runner would strand it).
585
+ - name: "Checkout fallback (retry transient flake) (#1672)"
586
+ if: failure()
587
+ env:
588
+ GITHUB_TOKEN: ${{ github.token }}
589
+ run: |
590
+ set -u
591
+ TOK="$(printf '%s' "$GITHUB_TOKEN" | tr -d '[:space:]')"; echo "::add-mask::$TOK"
592
+ SERVER="${GITHUB_SERVER_URL:-https://git.mokoenterprises.us}"
593
+ CLONE_URL="https://x-access-token:${TOK}@${SERVER#https://}/${GITHUB_REPOSITORY}.git"
594
+ REF="${GITHUB_SHA:-${GITHUB_REF_NAME:-}}"
595
+ echo "Retrying full-history checkout of ${GITHUB_REPOSITORY}@${REF:-<HEAD>} into ${GITHUB_WORKSPACE}"
596
+ ok=0
597
+ for attempt in 1 2 3; do
598
+ rm -rf "${GITHUB_WORKSPACE:?}/.git" 2>/dev/null || true
599
+ find "${GITHUB_WORKSPACE}" -mindepth 1 -maxdepth 1 -exec rm -rf {} + 2>/dev/null || true
600
+ if git clone "$CLONE_URL" "${GITHUB_WORKSPACE}" \
601
+ && git -C "${GITHUB_WORKSPACE}" fetch --tags origin \
602
+ && { [ -z "$REF" ] || git -C "${GITHUB_WORKSPACE}" checkout -f "$REF"; }; then
603
+ ok=1; echo "Fallback checkout succeeded on attempt ${attempt}."; break
604
+ fi
605
+ echo "::warning::Fallback checkout attempt ${attempt} failed; backing off $((attempt * 5))s"
606
+ sleep $((attempt * 5))
607
+ done
608
+ if [ "$ok" != "1" ]; then
609
+ echo "::error::Checkout failed after 3 retry attempts (#1672) — this is not a transient flake, failing loudly."
610
+ exit 1
611
+ fi
612
+
613
+ - name: "Detect Makefile (auto-release gate)"
614
+ id: gate
615
+ # auto-release drives its build/package targets through make, so a repo
616
+ # WITHOUT a root Makefile has nothing to release. This is not an error --
617
+ # many universal repos (deploy handlers, docs, config, e.g. .vault) sync
618
+ # this workflow but never release. Treat "no Makefile" as a clean SKIP:
619
+ # emit has_makefile=false and let every downstream build/release step
620
+ # no-op via its if:, so the job goes GREEN with "Release Skipped" instead
621
+ # of reding every main push on a non-release repo. Must be a post-checkout
622
+ # step (a job-level if: cannot inspect the workspace).
623
+ run: |
624
+ if [ -f Makefile ] || [ -f makefile ] || [ -f GNUmakefile ]; then
625
+ echo "has_makefile=true" >> "$GITHUB_OUTPUT"
626
+ echo "Makefile present -- auto-release may proceed."
627
+ else
628
+ echo "has_makefile=false" >> "$GITHUB_OUTPUT"
629
+ echo "## Release Skipped -- no Makefile" >> "$GITHUB_STEP_SUMMARY"
630
+ echo "No Makefile at the repository root -- this repo does not build a release. Skipping the release pipeline (not a failure)." >> "$GITHUB_STEP_SUMMARY"
631
+ echo "No Makefile at repo root -- skipping auto-release (not a release-bearing repo)."
632
+ fi
633
+
634
+ - name: "Detect electron project"
635
+ id: electron
636
+ if: steps.gate.outputs.has_makefile == 'true'
637
+ # ADDITIVE + GATED: electron repos are detected by an electron-builder
638
+ # config at the repo root (mokocli platform detection does not know
639
+ # 'electron' yet -- see MokoGIT #1201). Every electron step below no-ops
640
+ # via `steps.electron.outputs.is_electron` so non-electron repos are
641
+ # 100% unaffected. Post-checkout (a job-level if: cannot see the tree).
642
+ run: |
643
+ if [ -f electron-builder.yml ] || [ -f electron-builder.yaml ]; then
644
+ echo "is_electron=true" >> "$GITHUB_OUTPUT"
645
+ echo "electron-builder config present -- electron installer build enabled."
646
+ else
647
+ echo "is_electron=false" >> "$GITHUB_OUTPUT"
648
+ fi
649
+
650
+ - name: Configure git for bot pushes
651
+ if: steps.gate.outputs.has_makefile == 'true'
652
+ env:
653
+ GITHUB_TOKEN: ${{ github.token }}
654
+ run: |
655
+ PUSH_TOK="$(printf '%s' "$GITHUB_TOKEN" | tr -d '[:space:]')"; [ -z "$PUSH_TOK" ] && PUSH_TOK="${{ github.token }}"; echo "::add-mask::$PUSH_TOK"
656
+ git config --local user.email "mokogit-actions[bot]@mokoenterprises.us"
657
+ git config --local user.name "mokogit-actions[bot]"
658
+ git remote set-url origin "https://x-access-token:${PUSH_TOK}@git.mokoenterprises.us/${{ github.repository }}.git"
659
+
660
+ - name: Check for merge conflict markers
661
+ if: steps.gate.outputs.has_makefile == 'true'
662
+ run: |
663
+ # Scan only git-tracked files so vendored/generated trees (e.g. phpcs'
664
+ # GitMergeConflictSniff fixtures under vendor/) and this workflow's own
665
+ # grep pattern can never be matched (back-port of Template-Joomla #177/#178).
666
+ FILES=$(git ls-files '*.php' '*.xml' '*.css' '*.js' '*.json' '*.md' '*.yml' '*.yaml' '*.ini' '*.txt')
667
+ CONFLICTS=$(printf '%s\n' "$FILES" | xargs -r grep -nE '^(<<<<<<< |>>>>>>> |=======$)' 2>/dev/null || true)
668
+ if [ -n "$CONFLICTS" ]; then
669
+ echo "::error::Merge conflict markers found -- aborting release"
670
+ echo "## Release Blocked: Conflict Markers" >> $GITHUB_STEP_SUMMARY
671
+ echo '```' >> $GITHUB_STEP_SUMMARY
672
+ echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
673
+ echo '```' >> $GITHUB_STEP_SUMMARY
674
+ exit 1
675
+ fi
676
+ echo "No conflict markers found"
677
+
678
+ - name: Setup MokoCLI tools
679
+ if: steps.gate.outputs.has_makefile == 'true'
680
+ run: |
681
+ # Install mokocli FRESH from the MokoGIT Composer registry on every run
682
+ # (PRIMARY) so CI always uses the latest published version. /opt/mokocli is
683
+ # a FAILSAFE only — its refresh unit (mokocli-sync.service) is disabled, so
684
+ # the on-disk snapshot rots and must NOT be preferred. Registry path
685
+ # /api/packages/** is anon-allowlisted (no GITHUB_TOKEN); it serves a dist
686
+ # zip (no git clone). --no-install: mokocli self-bootstraps via bundled lib/
687
+ # + a fallback PSR-4 autoloader (native ext-curl), so no vendor/ is needed.
688
+ if ! command -v composer > /dev/null 2>&1; then
689
+ 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
690
+ fi
691
+ rm -rf /tmp/mokocli
692
+ REG='{"type":"composer","url":"https://git.mokoenterprises.us/api/packages/MokoEnterprises/composer"}'
693
+ if composer create-project --no-install --no-interaction --no-progress --quiet \
694
+ --repository="${REG}" mokoenterprises/mokocli /tmp/mokocli >/dev/null 2>&1 \
695
+ && [ -f /tmp/mokocli/cli/version_bump.php ]; then
696
+ echo "Installed mokocli fresh from the Composer registry (primary)"
697
+ # #1671: registry-publish LAG race. The registry install "succeeds" but can
698
+ # serve the PREVIOUS package for ~tens of seconds after a MokoCLI release
699
+ # (publish finishes AFTER the release run reports success). Meanwhile the
700
+ # /opt/mokocli deploy (pull-timer) may already carry the just-released,
701
+ # NEWER engine. So instead of blindly using the registry copy, compare the
702
+ # two on-disk versions and PREFER whichever is newer. The Makefile
703
+ # PROJECT_VERSION is the version source of truth and ships in both the
704
+ # composer dist and /opt/mokocli. Backward-compatible: with no /opt/mokocli
705
+ # (most consumer runners), the registry copy is used exactly as before.
706
+ mk_ver() { sed -n 's/^[[:space:]]*PROJECT_VERSION[[:space:]]*:*=[[:space:]]*//p' "$1" 2>/dev/null | head -n1 | tr -d '[:space:]'; }
707
+ ver_newer() { [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ]; }
708
+ REG_VER="$(mk_ver /tmp/mokocli/Makefile)"
709
+ OPT_VER="$(mk_ver /opt/mokocli/Makefile)"
710
+ MOKO_ROOT=/tmp/mokocli
711
+ if [ -f /opt/mokocli/cli/version_bump.php ] && [ -n "$OPT_VER" ] && [ -n "$REG_VER" ] \
712
+ && ver_newer "$OPT_VER" "$REG_VER"; then
713
+ echo "::notice::/opt/mokocli (${OPT_VER}) is newer than the registry copy (${REG_VER}) — using /opt (#1671 publish-lag race)."
714
+ MOKO_ROOT=/opt/mokocli
715
+ else
716
+ echo "Using registry copy (${REG_VER:-unknown}); /opt/mokocli=${OPT_VER:-none}"
717
+ fi
718
+ echo "MOKO_CLI=${MOKO_ROOT}/cli" >> $GITHUB_ENV
719
+ # #650: make the chosen moko executable + FIRST on PATH so bare `moko`
720
+ # never resolves to a stale/non-executable copy (exit 126).
721
+ for d in bin cli; do
722
+ if [ -f "${MOKO_ROOT}/$d/moko" ]; then
723
+ # #1263 defense-in-depth: strip CR bytes so a CRLF-tainted registry
724
+ # dist or rotted /opt snapshot can't yield `/usr/bin/env: 'php\r'`
725
+ # (exit 127). tr interprets \r as a real CR (unlike portable sed).
726
+ # /opt may be root-owned — write via a temp file + sudo when needed.
727
+ if tr -d '\r' < "${MOKO_ROOT}/$d/moko" > "/tmp/moko.lf" 2>/dev/null; then
728
+ if cp "/tmp/moko.lf" "${MOKO_ROOT}/$d/moko" 2>/dev/null; then :; \
729
+ else sudo cp "/tmp/moko.lf" "${MOKO_ROOT}/$d/moko" 2>/dev/null || true; fi
730
+ rm -f "/tmp/moko.lf"
731
+ fi
732
+ chmod +x "${MOKO_ROOT}/$d/moko" 2>/dev/null || sudo chmod +x "${MOKO_ROOT}/$d/moko" 2>/dev/null || true
733
+ echo "${MOKO_ROOT}/$d" >> "$GITHUB_PATH"
734
+ fi
735
+ done
736
+ sudo chmod +x /opt/mokocli/*/moko 2>/dev/null || true
737
+ # Opportunistically refresh the /opt failsafe from this fresh copy when it
738
+ # differs — keeps the disabled-sync snapshot current so the fallback is
739
+ # never far behind. Best-effort: needs sudo, gated on a diff to avoid
740
+ # fleet-wide churn, never fails the job. #1671: only refresh FORWARD — skip
741
+ # when /opt is the newer copy we just chose, so we never downgrade it.
742
+ if [ "$MOKO_ROOT" = /tmp/mokocli ] && command -v sudo >/dev/null 2>&1 && [ -d /opt/mokocli ] \
743
+ && ! cmp -s /tmp/mokocli/cli/version_reset_dev.php /opt/mokocli/cli/version_reset_dev.php 2>/dev/null; then
744
+ sudo cp -a /tmp/mokocli/. /opt/mokocli/ 2>/dev/null \
745
+ && echo "Refreshed /opt/mokocli failsafe from the fresh registry copy" \
746
+ || echo " (/opt failsafe refresh skipped — no sudo/perms)"
747
+ fi
748
+ elif [ -f /opt/mokocli/cli/version_bump.php ]; then
749
+ echo "::warning::Registry install failed — using /opt/mokocli FAILSAFE (may be stale)"
750
+ echo "MOKO_CLI=/opt/mokocli/cli" >> $GITHUB_ENV
751
+ else
752
+ echo "::error::mokocli unavailable: registry install failed and no /opt/mokocli failsafe present"
753
+ exit 1
754
+ fi
755
+
756
+ - name: "Detect platform"
757
+ id: platform
758
+ if: steps.gate.outputs.has_makefile == 'true'
759
+ run: |
760
+ # PILOT: native-manifest platform detection only -- the metadata-registry
761
+ # write-back (platform_detect.php --update) is REMOVED. The MokoCLI build
762
+ # agent no longer reads or mutates the metadata API; the platform is
763
+ # resolved from the in-tree manifest solely to drive the tag/branch logic
764
+ # below (joomla -> tag=stable; generic -> tag=v${VERSION}).
765
+ # Non-fatal (|| true) so a detection hiccup never blocks a release.
766
+ php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
767
+ php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
768
+
769
+ - name: "Resolve SRC_DIR bump gate (#469)"
770
+ id: srcgate
771
+ if: steps.gate.outputs.has_makefile == 'true'
772
+ # #469: decide version-BUMP vs NO-BUMP (update-existing) from whether this
773
+ # push touched the Makefile source directory (SRC_DIR). This replaces the old
774
+ # non-code paths-ignore denylist on the push trigger: a docs/workflow/sample
775
+ # push no longer skips the pipeline — it runs in no-bump "update the current
776
+ # stable release in place" mode. SRC_DIR is read from the Makefile exactly as
777
+ # apidocs.yml does (the de-facto resolve-source-dir contract in this template).
778
+ #
779
+ # Gate logic (per the #469 "Resulting gate logic"):
780
+ # 1. ELECTRON / opt-out -> BYPASS: electron repos have NO source dir, so a
781
+ # naive SRC_DIR test would classify every push as non-source and NEVER
782
+ # bump (a release-breaking trap). Electron (file-detected) and an explicit
783
+ # opt-out (vars.RELEASE_SRCDIR_GATE=off) keep the existing always-bump
784
+ # behaviour. Do NOT infer the exclusion from an empty SRC_DIR.
785
+ # 2. Else SRC_DIR is REQUIRED: a non-excluded repo with no resolvable SRC_DIR
786
+ # is a misconfig -> FAIL LOUD (::error:: + exit 1), never silently never-bump.
787
+ # 3. Else diff the push range (before..sha, with a HEAD^ / merge-base
788
+ # fallback) intersected with ${SRC_DIR}/ : any source path -> should_bump
789
+ # =true (existing bump+new-release path); none -> should_bump=false
790
+ # (no-bump update-existing path). workflow_dispatch always bumps (an
791
+ # explicit human re-cut), as does a run with no resolvable diff range.
792
+ run: |
793
+ set -uo pipefail
794
+ # 1. Electron / opt-out bypass -> always bump as today.
795
+ if [ "${{ steps.electron.outputs.is_electron }}" = "true" ] || [ "${{ vars.RELEASE_SRCDIR_GATE }}" = "off" ]; then
796
+ echo "should_bump=true" >> "$GITHUB_OUTPUT"
797
+ echo "::notice::SRC_DIR gate BYPASSED (electron repo or vars.RELEASE_SRCDIR_GATE=off) — keeping always-bump behaviour (#469)."
798
+ exit 0
799
+ fi
800
+ # A manual re-cut is always an intentional bump+release.
801
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
802
+ echo "should_bump=true" >> "$GITHUB_OUTPUT"
803
+ echo "workflow_dispatch — always bump (explicit human re-cut) (#469)."
804
+ exit 0
805
+ fi
806
+ # Resolve the push diff range (shared by both gate modes below).
807
+ BEFORE="${{ github.event.before }}"
808
+ SHA="${GITHUB_SHA:-}"
809
+ RANGE=""
810
+ ZERO="0000000000000000000000000000000000000000"
811
+ if [ -n "$BEFORE" ] && [ "$BEFORE" != "$ZERO" ] && git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
812
+ RANGE="${BEFORE}..${SHA}"
813
+ elif git rev-parse "${SHA}^1" >/dev/null 2>&1; then
814
+ # No usable before-sha (branch-create / forced / shallow): fall back to the
815
+ # first-parent diff of the pushed commit (the merged promotion's delta).
816
+ RANGE="${SHA}^1..${SHA}"
817
+ fi
818
+ if [ -z "$RANGE" ]; then
819
+ # No resolvable range (e.g. initial push): cannot prove a non-source push,
820
+ # so default to the safe existing behaviour — bump.
821
+ echo "should_bump=true" >> "$GITHUB_OUTPUT"
822
+ echo "::notice::#469 gate: no resolvable diff range (before=${BEFORE:-none}); defaulting to bump."
823
+ exit 0
824
+ fi
825
+ CHANGED="$(git diff --name-only "$RANGE" 2>/dev/null || true)"
826
+ if [ -z "$CHANGED" ]; then
827
+ echo "should_bump=true" >> "$GITHUB_OUTPUT"
828
+ echo "::notice::#469 gate: empty diff for ${RANGE}; defaulting to bump."
829
+ exit 0
830
+ fi
831
+ # 2. SRC_DIR INCLUSION mode — fleet standard is SRC_DIR=source. When the repo
832
+ # declares a Makefile SRC_DIR, bump iff the push touched ${SRC_DIR}/.
833
+ # Accept all three GNU make assignment forms: SRC_DIR=, SRC_DIR :=, SRC_DIR ?=.
834
+ # NOTE the trailing `|| true`: the step shell runs with `-e -o pipefail`,
835
+ # so on a repo with NO SRC_DIR the `grep` exits 1, pipefail propagates it,
836
+ # and the command-substitution assignment would abort the whole step under
837
+ # -e BEFORE the denylist fallback below. `|| true` keeps SRC_DIR empty and
838
+ # lets execution fall through to denylist mode. (#1831)
839
+ # #498 FIX: resolve SRC_DIR by asking `make` to EXPAND it. The fleet Makefile
840
+ # standard is `SRC_DIR := $(shell [ -d src ] && echo src || echo source)` — a
841
+ # GNU make function. A grep|sed read captures the LITERAL unexpanded
842
+ # "$(shell …)", an unmatchable directory that made INCLUSION mode classify
843
+ # EVERY push as non-source (should_bump=false forever: MokoCLI stuck
844
+ # re-publishing v10.20.00 while source changed). Ask make to evaluate it (runs
845
+ # the $(shell …) read-only); fall back to the grep|sed read only if make is
846
+ # unavailable, and NULL OUT a still-unexpanded $(...)/`...` value so it drops
847
+ # to the denylist mode below instead of self-trapping. (|| true keeps the step
848
+ # alive under -e -o pipefail — see #1831.)
849
+ SRC_DIR="$(make -s --no-print-directory --eval='__moko_srcdir__:; @printf "%s" "$(SRC_DIR)"' __moko_srcdir__ 2>/dev/null | tr -d '[:space:]' || true)"
850
+ if [ -z "$SRC_DIR" ]; then
851
+ SRC_DIR="$(grep -m1 -E '^SRC_DIR[[:space:]]*[:?]?=' Makefile 2>/dev/null | sed -E 's/.*[:?]?=[[:space:]]*//' | tr -d '[:space:]' || true)"
852
+ fi
853
+ case "$SRC_DIR" in *'$('*|*'`'*) SRC_DIR="" ;; esac
854
+ # A declared-but-NONEXISTENT SRC_DIR (e.g. expands to `source` on a repo whose
855
+ # code lives in cli/lib/bin) must NOT drive INCLUSION mode with an unmatchable
856
+ # prefix — fall through to the non-source denylist mode instead. (#498)
857
+ if [ -n "$SRC_DIR" ] && [ ! -d "$SRC_DIR" ]; then
858
+ echo "::notice::#469 gate: declared SRC_DIR='${SRC_DIR}' does not exist in the tree — using non-source denylist fallback (#498)."
859
+ SRC_DIR=""
860
+ fi
861
+ if [ -n "$SRC_DIR" ]; then
862
+ SRC_DIR="${SRC_DIR%/}"
863
+ SRC_HITS="$(printf '%s\n' "$CHANGED" | grep -E "^${SRC_DIR}/" || true)"
864
+ if [ -n "$SRC_HITS" ]; then
865
+ echo "should_bump=true" >> "$GITHUB_OUTPUT"
866
+ echo "#469 gate (SRC_DIR=${SRC_DIR}): push touched ${SRC_DIR}/ (range ${RANGE}) — BUMP + new release."
867
+ else
868
+ echo "should_bump=false" >> "$GITHUB_OUTPUT"
869
+ echo "::notice::#469 gate (SRC_DIR=${SRC_DIR}): push touched NO ${SRC_DIR}/ paths (range ${RANGE}) — NO bump; updating the current release in place."
870
+ fi
871
+ exit 0
872
+ fi
873
+ # 3. NON-SOURCE DENYLIST mode (#469/#1831) — repos with no single declarable
874
+ # SRC_DIR (e.g. MokoGIT, a Gitea fork with root-level Go source). NEVER
875
+ # hard-fail on a missing SRC_DIR: bump UNLESS the push touched ONLY
876
+ # non-source files (docs, CI/workflows, dotfiles). Any other change bumps.
877
+ NOBUMP='(^\.|(^|/)docs?/|\.(md|markdown|rst|adoc)$|^(README|CHANGELOG|LICENSE|COPYING|NOTICE|AUTHORS|CONTRIBUTORS|CODE_OF_CONDUCT|CONTRIBUTING|SECURITY|SUPPORT|GOVERNANCE)[^/]*$|^renovate\.json$)'
878
+ SRC_HITS="$(printf '%s\n' "$CHANGED" | grep -vE "$NOBUMP" || true)"
879
+ if [ -n "$SRC_HITS" ]; then
880
+ echo "should_bump=true" >> "$GITHUB_OUTPUT"
881
+ echo "#469 gate (denylist mode, no SRC_DIR): push touched source/non-doc files (range ${RANGE}) — BUMP + new release."
882
+ else
883
+ echo "should_bump=false" >> "$GITHUB_OUTPUT"
884
+ 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."
885
+ fi
886
+
887
+ - name: "Determine version bump level"
888
+ id: bump
889
+ if: steps.gate.outputs.has_makefile == 'true'
890
+ env:
891
+ HEAD_REF: ${{ github.event.pull_request.head.ref }}
892
+ run: |
893
+ # Bump-on-dev: the base version is now bumped on DEV pre-promotion (the
894
+ # promote-rc job's release_bump_dev.php step), so main ALWAYS publishes
895
+ # the inherited version with NO bump. Historically a dev-headed promotion
896
+ # bumped minor here (on main) — that on-main bump is exactly the divergence
897
+ # this change removes. fix/patch/hotfix/bugfix were already 'none' (the
898
+ # per-merge patch bump happens on dev in pre-release.yml, not here). So
899
+ # every path is 'none' now.
900
+ HEAD_REF="${HEAD_REF:-dev}"
901
+ BUMP="none"
902
+ echo "level=${BUMP}" >> "$GITHUB_OUTPUT"
903
+ echo "Bump level: ${BUMP} (from branch: ${HEAD_REF}) — base bumped on dev pre-promotion"
904
+
905
+ - name: "Detect mokohub packaging platforms"
906
+ id: mokohub
907
+ if: steps.gate.outputs.has_makefile == 'true'
908
+ # ADDITIVE + GATED (Template-Generic#326): classify the mokohub-manifest /
909
+ # mokohub-recipes platforms from the detected platform key so their gated
910
+ # packaging steps below can fire. Purely additive — no existing platform
911
+ # (joomla/npm/mcp/client/generic/go/php/electron) reads these outputs, so
912
+ # their release path is 100% unchanged. Detection is read-only.
913
+ run: |
914
+ PLATFORM="${{ steps.platform.outputs.platform }}"
915
+ IS_MANIFEST=false
916
+ IS_RECIPES=false
917
+ case "$PLATFORM" in
918
+ mokohub-manifest*) IS_MANIFEST=true ;;
919
+ mokohub-recipes*) IS_RECIPES=true ;;
920
+ esac
921
+ echo "is_manifest=${IS_MANIFEST}" >> "$GITHUB_OUTPUT"
922
+ echo "is_recipes=${IS_RECIPES}" >> "$GITHUB_OUTPUT"
923
+ [ "$IS_MANIFEST" = "true" ] && echo "mokohub-manifest platform — manifest.json packaging enabled."
924
+ [ "$IS_RECIPES" = "true" ] && echo "mokohub-recipes platform — catalog.zip packaging enabled."
925
+ true
926
+
927
+ - name: "Gate npm publish (PLATFORM=npm + package.json + Makefile PROJECT_VERSION)"
928
+ id: npmpub
929
+ if: steps.gate.outputs.has_makefile == 'true'
930
+ # A repo is npm-publishable ONLY when its Makefile PLATFORM is npm/nodejs --
931
+ # the canonical platform declaration, NOT file presence. Joomla/Go/other repos
932
+ # that merely ship a build-tooling package.json (e.g. a Joomla component with a
933
+ # frontend package.json) must never be treated as npm packages. On top of the
934
+ # platform gate we still require a package.json AND a Makefile PROJECT_VERSION
935
+ # (the mokocli source of truth the publish step reads) -- belt-and-suspenders,
936
+ # so a genuine npm repo missing PROJECT_VERSION does not fail the release.
937
+ run: |
938
+ PUB=false
939
+ PLATFORM="${{ steps.platform.outputs.platform }}"
940
+ # Electron repos build + publish their installers LOCALLY (manual upload),
941
+ # so they must NEVER npm-publish from CI even though they detect as nodejs
942
+ # (they ship a package.json). Mirror pre-release.yml's electron publish gate
943
+ # (Template-Generic #390): disable unless explicitly opted in with
944
+ # vars.ELECTRON_CI_BUILD=true. Non-electron repos are unaffected.
945
+ if [ "${{ steps.electron.outputs.is_electron }}" = "true" ] && [ "${{ vars.ELECTRON_CI_BUILD }}" != "true" ]; then
946
+ echo "publishable=false" >> "$GITHUB_OUTPUT"
947
+ echo "npm-publishable=false (electron repo -- CI npm publish disabled; local build + manual upload)"
948
+ else
949
+ case "$PLATFORM" in
950
+ npm*|nodejs*)
951
+ if [ -f package.json ] && grep -qE '^[[:space:]]*PROJECT_VERSION[[:space:]]*:?=' Makefile; then PUB=true; fi
952
+ ;;
953
+ esac
954
+ echo "publishable=${PUB}" >> "$GITHUB_OUTPUT"
955
+ echo "npm-publishable=${PUB} (needs PLATFORM npm/nodejs + package.json + Makefile PROJECT_VERSION; PLATFORM=${PLATFORM})"
956
+ fi
957
+
958
+ - name: "Sterilize SVG assets (strip editor/Inkscape metadata) (#312)"
959
+ if: steps.gate.outputs.has_makefile == 'true'
960
+ continue-on-error: true
961
+ run: |
962
+ set -uo pipefail
963
+ mapfile -t SVGS < <(find . -type f -name '*.svg' \
964
+ -not -path './.git/*' -not -path './node_modules/*' -not -path './vendor/*' 2>/dev/null || true)
965
+ if [ "${#SVGS[@]}" -eq 0 ]; then echo "no SVGs -- skipping (#312)"; exit 0; fi
966
+ echo "Sterilizing ${#SVGS[@]} SVG(s) -- stripping editor/Inkscape metadata..."
967
+ if pip install --quiet --disable-pip-version-check scour >/dev/null 2>&1 && command -v scour >/dev/null 2>&1; then
968
+ for fsvg in "${SVGS[@]}"; do
969
+ scour -i "$fsvg" -o "$fsvg.st" --remove-metadata --strip-xml-prolog --enable-comment-stripping >/dev/null 2>&1 \
970
+ && mv "$fsvg.st" "$fsvg" || rm -f "$fsvg.st"
971
+ done
972
+ echo "SVG sterilization complete (scour, metadata-only)."
973
+ else
974
+ echo "::notice::scour unavailable -- SVG sterilization skipped (non-fatal, #312)."
975
+ fi
976
+
977
+ - name: Optimize assets (artifact-time)
978
+ if: steps.gate.outputs.has_makefile == 'true'
979
+ continue-on-error: true
980
+ run: |
981
+ moko assets:optimize --path . 2>/dev/null || echo "::notice::moko assets:optimize unavailable or no assets — skipping"
982
+
983
+ # Build-time PHP dependency vendoring (universal, guarded — mirrors
984
+ # pre-release.yml 07.15.00). If a sub-package ships a composer.json, fetch its
985
+ # runtime deps into that package's CONFIGURED vendor dir (e.g. admin/vendor —
986
+ # mPDF) BEFORE `moko publish` (release_publish.php → release_package.php) zips
987
+ # the working tree. No-op without a sub-package composer.json. FAILS LOUD when
988
+ # vendoring fails for a package whose Joomla manifest ships
989
+ # <folder>vendor</folder>: shipping that zip without vendor/ makes JInstaller
990
+ # abort the whole package install on-site ("File does not exist
991
+ # .../admin/vendor" — MokonyxCRMDCM/MokonyxCRM), strictly worse than a red run.
992
+ - name: Vendor PHP dependencies (build-time)
993
+ if: steps.gate.outputs.has_makefile == 'true'
994
+ run: |
995
+ set -uo pipefail
996
+ CJS="$(find source/packages src/packages -maxdepth 2 -name composer.json 2>/dev/null || true)"
997
+ if [ -z "$CJS" ]; then echo "No sub-package composer.json — nothing to vendor."; exit 0; fi
998
+ command -v composer >/dev/null 2>&1 || { sudo apt-get update -qq && sudo apt-get install -y -qq composer >/dev/null 2>&1 || true; }
999
+ # mPDF (and most PHP PDF/image libs) hard-require ext-gd; the runners'
1000
+ # php-cli ships without it, which fails dependency RESOLUTION outright.
1001
+ if ! php -m 2>/dev/null | grep -qi '^gd$'; then
1002
+ PHPV="$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;' 2>/dev/null || true)"
1003
+ sudo apt-get update -qq || true
1004
+ 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})"
1005
+ php -m 2>/dev/null | grep -qi '^gd$' || echo "::warning::ext-gd STILL missing after install attempt — vendoring will fail for gd-requiring packages"
1006
+ fi
1007
+ rc=0
1008
+ for cj in $CJS; do
1009
+ dir="$(dirname "$cj")"
1010
+ echo "::notice::composer install (no-dev) in ${dir}"
1011
+ ok=true
1012
+ composer install --no-dev --no-interaction --no-progress --optimize-autoloader --working-dir="$dir" || ok=false
1013
+ vd="$(composer config vendor-dir --working-dir="$dir" 2>/dev/null || true)"; vd="${vd:-vendor}"
1014
+ [ -f "${dir}/${vd}/autoload.php" ] || ok=false
1015
+ if [ "$ok" = "true" ]; then
1016
+ echo "vendored: ${dir}/${vd}"
1017
+ elif grep -qs '<folder>vendor</folder>' "${dir}"/*.xml; then
1018
+ 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."
1019
+ rc=1
1020
+ else
1021
+ echo "::warning::composer install failed in ${dir} — manifest does not ship vendor/; packaging the existing tree"
1022
+ fi
1023
+ done
1024
+ exit $rc
1025
+
1026
+ - name: "Guarantee monotonic release version (fast-merge bump guard)"
1027
+ id: monotonic
1028
+ # #469: in no-bump mode (a non-source push updating the existing release in
1029
+ # place) the version must stay PUT, so the self-healing patch bump must not
1030
+ # run — skip this guard when steps.srcgate decided should_bump=false. On the
1031
+ # bump path (should_bump=true, or electron/opt-out/dispatch where srcgate is
1032
+ # bypassed/true) it runs exactly as before.
1033
+ if: steps.gate.outputs.has_makefile == 'true' && steps.srcgate.outputs.should_bump != 'false'
1034
+ env:
1035
+ GITHUB_TOKEN: ${{ github.token }}
1036
+ run: |
1037
+ # P1-b (.mokogit-private#73): the base is normally minor-bumped on dev by
1038
+ # promote-rc pre-promotion. If the dev->stable PR is MERGED before
1039
+ # promote-rc runs (a fast merge), that bump is skipped and this job would
1040
+ # re-publish an ALREADY-RELEASED base (observed: stuck at 02.23.00). Enforce
1041
+ # "strictly above the last release": a no-op on the normal path, a self-
1042
+ # healing local bump when the inherited base did not advance.
1043
+ CUR=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
1044
+ [ -z "$CUR" ] && { echo "no base version; skip guard"; exit 0; }
1045
+ git fetch --tags --quiet origin 2>/dev/null || true
1046
+ # #813: the "last released version" is the MAX of two sources, not tags alone.
1047
+ # The rolling-release model publishes to a FIXED `stable` release whose NAME
1048
+ # ("... (VERSION: NN.NN.NN)") is the true last-published version, while the
1049
+ # immutable vNN.NN.NN tags can lag / fail to advance (#808/#386 stale-tag).
1050
+ # Trusting tags alone made this guard blind, so a direct fix/*->stable merge
1051
+ # RE-CUT an already-published base (observed 01.00.19 re-cut). Take the max of
1052
+ # (a) the highest vNN.NN.NN tag and (b) the rolling `stable` release version.
1053
+ 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)
1054
+ # #1702 / .mokogit-private#76: resolve the CURRENT stable release by its
1055
+ # CHANNEL POINTER, not by a fixed `tag_name=stable`. Under version-primary
1056
+ # releases the release tag_name is the immutable vNN, so GET
1057
+ # /releases/tags/stable no longer finds it; the channel endpoint
1058
+ # (releases/channel/stable) resolves whichever release the `stable`
1059
+ # channel currently points at via ReleaseStreamMap. Graceful fallback to
1060
+ # the legacy tags/stable call for forges predating #1702 (pre-rollout the
1061
+ # channel route 404s and the rolling `stable` tag still exists). The parsed
1062
+ # input is identical: the release NAME carries "VERSION: NN.NN.NN".
1063
+ RELEASE_JSON=$(curl -sf -H "Authorization: token ${GITHUB_TOKEN:-${{ github.token }}}" \
1064
+ "${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}/releases/channel/stable" 2>/dev/null || true)
1065
+ if [ -z "$RELEASE_JSON" ]; then
1066
+ RELEASE_JSON=$(curl -sf -H "Authorization: token ${GITHUB_TOKEN:-${{ github.token }}}" \
1067
+ "${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}/releases/tags/stable" 2>/dev/null || true)
1068
+ fi
1069
+ ROLLING_LAST=$(printf '%s' "${RELEASE_JSON:-}" \
1070
+ | 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)
1071
+ LAST=$( { echo "$TAG_LAST"; echo "$ROLLING_LAST"; } | grep -E '^[0-9]{2}\.[0-9]{2}\.[0-9]{2}$' | sort -V | tail -n1 || true)
1072
+ if [ -z "$LAST" ]; then echo "no prior release (no vNN.NN.NN tag, no rolling stable); skip guard"; exit 0; fi
1073
+ echo "monotonic guard: CUR=${CUR} TAG_LAST=${TAG_LAST:-none} ROLLING_LAST=${ROLLING_LAST:-none} -> LAST=${LAST}"
1074
+ # #498 FIX: the release IDENTITY is the MINOR (tag vMAJOR.MINOR.00). Dev's
1075
+ # per-merge PATCH bumps advance CUR within the SAME minor (e.g. 10.20.00 ->
1076
+ # 10.20.04), which satisfied the old full-version `CUR <= LAST` test and
1077
+ # WRONGLY skipped the heal — so a fast dev->stable merge re-published the
1078
+ # already-released vMAJOR.MINOR.00 forever (observed: MokoCLI stuck at
1079
+ # v10.20.00 while dev reached 10.20.04), violating this guard's own "never
1080
+ # re-publish an already-released base" intent. Compare the MINOR identity
1081
+ # (MAJOR.MINOR): heal (PATCH-bump above --min-version LAST) whenever CUR's
1082
+ # minor has NOT advanced past the last release's minor. An unplanned /
1083
+ # non-pre-bumped stable change (a direct admin push, or a fast dev->stable
1084
+ # merge that skipped the promote-rc dev pre-bump) is a hotfix-shaped event,
1085
+ # so it bumps PATCH, not MINOR — --min-version "$LAST" still guarantees the
1086
+ # healed version is strictly above the last release, and the dev carry-forward
1087
+ # below is ALSO --bump patch (consistent with the stable patch heal, so a
1088
+ # direct-push hotfix does not inflate dev by a full minor). The normal
1089
+ # promote-rc path (dev already minor-bumped) still no-ops.
1090
+ CUR_MI=$(printf '%s' "$CUR" | awk -F. '{printf "%s.%s", $1, $2}')
1091
+ LAST_MI=$(printf '%s' "$LAST" | awk -F. '{printf "%s.%s", $1, $2}')
1092
+ GREATEST_MI=$( { echo "$CUR_MI"; echo "$LAST_MI"; } | sort -V | tail -n1 || true)
1093
+ if [ "$CUR_MI" = "$LAST_MI" ] || [ "$GREATEST_MI" = "$LAST_MI" ]; then
1094
+ echo "::warning::inherited minor ${CUR_MI} <= last release minor ${LAST_MI} (CUR=${CUR} LAST=${LAST}); pre-bump skipped -> self-healing PATCH bump"
1095
+ if ! php ${MOKO_CLI}/version_bump.php --patch --min-version "$LAST" --path .; then
1096
+ echo "::error::monotonic bump guard failed to advance the base"; exit 1
1097
+ fi
1098
+ NEW=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
1099
+ echo "healed ${CUR} -> ${NEW} (publishing fresh; carrying the PATCH forward to dev)"
1100
+ php ${MOKO_CLI}/release_bump_dev.php --path . --branch dev --bump patch \n --repo "${{ github.repository }}" --token "${GITHUB_TOKEN:-${{ github.token }}}" \n || echo "::warning::dev carry-forward failed (non-fatal; release still publishes the healed version)"
1101
+ else
1102
+ echo "base ${CUR} > last release ${LAST}: normal path, no heal"
1103
+ fi
1104
+
1105
+ # -- Release integrity: refuse to publish a STALE (pre-merge) tree ---------
1106
+ # #428 / MokoCLI #901: the release builds GITHUB_SHA (the triggering commit).
1107
+ # If a concurrent merge advanced the branch AFTER this run's trigger, we would
1108
+ # package a PRE-merge tree while stamping the new version -- a green release
1109
+ # that silently ships old code (reproduced: built ef8c1a5d while the tip was
1110
+ # the ladder-merge 2740342, missing only that merge's blobs). The mokocli
1111
+ # engine asserts packaged-tree == GITHUB_SHA, but it CANNOT see that GITHUB_SHA
1112
+ # itself trails the tip -- that is this guard's job. Fail-OPEN on any ambiguity
1113
+ # (unresolved tip / replica lag) so a good release is never falsely blocked; a
1114
+ # confident "built commit is an ancestor of the live tip" is a hard FAIL.
1115
+ - name: "Release integrity: no stale (pre-merge) tree"
1116
+ id: stale_guard
1117
+ if: steps.gate.outputs.has_makefile == 'true' && (steps.electron.outputs.is_electron != 'true' || vars.ELECTRON_CI_BUILD == 'true')
1118
+ run: |
1119
+ set -uo pipefail
1120
+ BUILT="${GITHUB_SHA:-}"
1121
+ BRANCH="${GITHUB_REF_NAME:-}"
1122
+ if [ -z "$BUILT" ] || [ -z "$BRANCH" ]; then
1123
+ echo "::warning::stale-tree guard: no GITHUB_SHA/branch to check (event=${GITHUB_EVENT_NAME:-?}); skipping (fail-open)."; exit 0
1124
+ fi
1125
+ # Fetch the CURRENT branch tip into a private tracking ref. Pin refs/heads/
1126
+ # explicitly: the channel-named-branch model lets a `stable` TAG and `stable`
1127
+ # BRANCH coexist, so a bare `git fetch origin stable` is ambiguous. Retry for
1128
+ # replica lag; a lagged read can only look EQUAL/behind -> never a false fail.
1129
+ TIP=""
1130
+ for i in 1 2 3 4; do
1131
+ if git fetch --quiet origin "+refs/heads/${BRANCH}:refs/remotes/origin/${BRANCH}-tipcheck" 2>/dev/null; then
1132
+ TIP="$(git rev-parse "refs/remotes/origin/${BRANCH}-tipcheck" 2>/dev/null || echo '')"
1133
+ [ -n "$TIP" ] && break
1134
+ fi
1135
+ sleep $((i * 2))
1136
+ done
1137
+ if [ -z "$TIP" ]; then
1138
+ echo "::warning::stale-tree guard: could not resolve tip of '${BRANCH}'; skipping (fail-open)."; exit 0
1139
+ fi
1140
+ if [ "$TIP" = "$BUILT" ]; then
1141
+ echo "Release integrity OK: building the current ${BRANCH} tip ${BUILT}."; exit 0
1142
+ fi
1143
+ if git merge-base --is-ancestor "$BUILT" "$TIP" 2>/dev/null; then
1144
+ # $BUILT is behind $TIP. Distinguish a genuine concurrent CODE merge
1145
+ # from this run's (or a prior release run's) OWN version-stamp commit
1146
+ # (#433). The "Publish stable release" step commits
1147
+ # "chore(release): build NN.NN.NN [skip ci]" onto the branch, which
1148
+ # advances the tip past $BUILT -- that is self-generated, NOT a
1149
+ # concurrent merge, and must not block (otherwise a re-run / the next
1150
+ # run false-fails forever and the release stalls: branch bumped, artifact
1151
+ # never published, [skip ci] blocking self-heal). If EVERY commit in
1152
+ # BUILT..TIP is such a release-stamp commit, proceed; if ANY other
1153
+ # commit is present, a real merge landed -> hard fail as before.
1154
+ FOREIGN=0
1155
+ for c in $(git rev-list "${BUILT}..${TIP}" 2>/dev/null); do
1156
+ SUBJ="$(git log -1 --format='%s' "$c" 2>/dev/null || echo '?')"
1157
+ case "$SUBJ" in
1158
+ "chore(release): build "*) : ;; # our own [skip ci] version stamp
1159
+ *) FOREIGN=1; break ;;
1160
+ esac
1161
+ done
1162
+ if [ "$FOREIGN" = "0" ]; then
1163
+ 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)."
1164
+ else
1165
+ 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."
1166
+ {
1167
+ echo "### ❌ Release blocked -- stale (pre-merge) tree"
1168
+ echo ""
1169
+ 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**."
1170
+ } >> "$GITHUB_STEP_SUMMARY"
1171
+ exit 1
1172
+ fi
1173
+ fi
1174
+ echo "::warning::stale-tree guard: built ${BUILT} != tip ${TIP} but is not its ancestor (diverged/unrelated); no pre-merge-tree risk detected -- proceeding."
1175
+
1176
+ - name: "Publish stable release"
1177
+ id: publish
1178
+ if: steps.gate.outputs.has_makefile == 'true' && (steps.electron.outputs.is_electron != 'true' || vars.ELECTRON_CI_BUILD == 'true')
1179
+ run: |
1180
+ # PILOT: call the MokoCLI build agent's publish verb (generic git-release).
1181
+ # `moko publish` maps to release_publish.php UNCHANGED -- same
1182
+ # stability/token contract, standards preserved. No --branch:
1183
+ # release_publish auto-detects the branch (here main).
1184
+ #
1185
+ # Bump-on-dev: --bump none + --no-commit. The base was bumped on dev
1186
+ # pre-promotion and inherited into main via the merge, so main neither
1187
+ # bumps NOR commits a version change. release_publish still stamps the
1188
+ # (unchanged) version in-tree so the built artifact is correct, but Step
1189
+ # 2c's commit/push to main is suppressed -- this is what removes the
1190
+ # main-only `chore(release): build` commit that diverged main from dev.
1191
+ # GITHUB_TOKEN is no longer needed here (nothing is persisted to main).
1192
+ # #469: this SAME --bump none --no-commit publish serves BOTH gate paths.
1193
+ # On the BUMP path it publishes the newly-advanced inherited version; on the
1194
+ # NO-BUMP path (steps.srcgate.should_bump=false — a non-source push) it
1195
+ # re-stamps the UNCHANGED current version onto the existing stable channel
1196
+ # release, re-issuing it in place with the refreshed (non-source) tree. The
1197
+ # version does not advance and the self-heal bump + new vNN tag are skipped
1198
+ # upstream, so no new version identity is minted — exactly the #469 contract.
1199
+ if [ "${{ steps.srcgate.outputs.should_bump }}" = "false" ]; then
1200
+ 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."
1201
+ fi
1202
+ # #289: stamp <creationDate> (build date, UTC) + <author> (Makefile
1203
+ # PROJECT_AUTHOR) into Joomla manifests before publish, so the released
1204
+ # stable manifest carries them. release_publish (below) re-stamps <version>
1205
+ # idempotently. No-op on non-Joomla and on an older MokoCLI lacking the args.
1206
+ MG_BUILD_DATE="$(date -u +%Y-%m-%d)"
1207
+ MG_AUTHOR="$(grep -m1 -E '^PROJECT_AUTHOR[[:space:]]*:?=' Makefile 2>/dev/null | sed -E 's/.*:?=[[:space:]]*//' | sed 's/[[:space:]]*$//' || true)"
1208
+ MG_VER="$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo '')"
1209
+ [ -n "$MG_VER" ] && php ${MOKO_CLI}/version_set_platform.php \
1210
+ --path . --version "$MG_VER" \
1211
+ --date "$MG_BUILD_DATE" ${MG_AUTHOR:+--author "$MG_AUTHOR"} 2>/dev/null || true
1212
+
1213
+ moko publish \
1214
+ --path . --stability stable --bump none --no-commit \
1215
+ --token "${{ github.token }}"
1216
+
1217
+ - name: "Read published version"
1218
+ id: version
1219
+ if: steps.gate.outputs.has_makefile == 'true'
1220
+ run: |
1221
+ # #741: consume the version the build agent already published (steps.publish
1222
+ # writes version=/base_version=/semver= to $GITHUB_OUTPUT via release_publish.php).
1223
+ # Falling back to version_read.php only if that output is absent (older
1224
+ # /opt/mokocli) keeps this backward-compatible while removing the second,
1225
+ # divergent resolution that silently false-skipped the npm publish gate.
1226
+ VERSION="${{ steps.publish.outputs.version }}"
1227
+ [ -z "$VERSION" ] && VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
1228
+ VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
1229
+ [ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
1230
+ echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
1231
+ PLATFORM="${{ steps.platform.outputs.platform }}"
1232
+ # Feed-tag selection: joomla AND electron need a STABLE, ROLLING tag so
1233
+ # their update feed lives at a constant URL. electron (Template-Generic#333,
1234
+ # coordinates MokoGIT#1342): electron-updater's latest.yml / latest-linux.yml /
1235
+ # latest-mac.yml must sit at a fixed path so the MokoGIT electron-yml generator
1236
+ # can poll a constant URL; a moving v${VERSION} tag would relocate the feed every
1237
+ # release and break the updater. Everything else keeps the immutable v${VERSION} tag.
1238
+ if [[ "$PLATFORM" == joomla* || "$PLATFORM" == electron* ]]; then
1239
+ echo "tag=stable" >> "$GITHUB_OUTPUT"
1240
+ echo "release_tag=stable" >> "$GITHUB_OUTPUT"
1241
+ else
1242
+ echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
1243
+ echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
1244
+ fi
1245
+ echo "branch=main" >> "$GITHUB_OUTPUT"
1246
+ echo "Published version: ${VERSION}"
1247
+
1248
+ - name: Deploy over SFTP to stable/prod (generic, presence-gated)
1249
+ if: ${{ steps.version.outputs.skip != 'true' && vars.SFTP_STABLE_HOST != '' }}
1250
+ env:
1251
+ DEPLOY_KEY: ${{ secrets.SFTP_STABLE_KEY }}
1252
+ KNOWN_HOSTS: ${{ vars.SFTP_STABLE_KNOWN_HOSTS }}
1253
+ SSH_USER: ${{ vars.SFTP_STABLE_USER }}
1254
+ SSH_HOST: ${{ vars.SFTP_STABLE_HOST }}
1255
+ SSH_PORT: ${{ vars.SFTP_STABLE_PORT }}
1256
+ DEST: ${{ vars.SFTP_STABLE_PATH }}
1257
+ run: |
1258
+ set -euo pipefail
1259
+ [ -n "$DEST" ] && [ "$DEST" != "/" ] || { echo "SFTP_STABLE_PATH empty/root; refusing"; exit 1; }
1260
+ install -d -m 700 ~/.ssh
1261
+ printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_sftp_deploy; chmod 600 ~/.ssh/id_sftp_deploy
1262
+ printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts_sftp; chmod 644 ~/.ssh/known_hosts_sftp
1263
+ SSH="ssh -i $HOME/.ssh/id_sftp_deploy -p ${SSH_PORT:-22} -o UserKnownHostsFile=$HOME/.ssh/known_hosts_sftp -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes"
1264
+ $SSH "$SSH_USER@$SSH_HOST" "rm -rf \"$DEST\" && mkdir -p \"$DEST\""
1265
+ tar czf - --exclude=.git --exclude=.mokogit --exclude=.github --exclude='*.token' --exclude='*.token.json' . | $SSH "$SSH_USER@$SSH_HOST" "tar xzf - -C \"$DEST\""
1266
+ echo "Deployed to stable: $SSH_HOST:$DEST"
1267
+
1268
+ - name: "Deploy to prod (inline — replaces standalone deploy.yml, #1427)"
1269
+ if: steps.gate.outputs.has_makefile == 'true' && steps.version.outputs.skip != 'true' && vars.PROD_SSH_USERNAME != ''
1270
+ env:
1271
+ DEPLOY_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
1272
+ run: |
1273
+ # #1427: prod deploy inlined here (release job runs on push to stable).
1274
+ # Gate = PROD_SSH_USERNAME set → only SSH-deploy repos deploy; others skip
1275
+ # green. Targets from repo VARIABLES. mokocli deploy.php → restricted
1276
+ # moko-deploy (build → push-guarded → recreate → health-check → rollback;
1277
+ # advances the floating :stable tag only after a healthy deploy). Must be
1278
+ # atomic with deleting deploy.yml — otherwise deploy.yml's push:[stable]
1279
+ # trigger would double-deploy prod.
1280
+ set -euo pipefail
1281
+ if [ -z "${DEPLOY_KEY:-}" ]; then
1282
+ echo "::warning::PROD_SSH_USERNAME set but DEPLOY_SSH_KEY secret missing — skipping prod deploy"; exit 0
1283
+ fi
1284
+ mkdir -p ~/.ssh
1285
+ # DEPLOY_SSH_KEY is base64-encoded (single line) so act_runner masks it in logs
1286
+ # and the multi-line key is never echoed. Decode when it base64-decodes to a
1287
+ # private key; else treat as a legacy raw PEM (back-compat during rollout).
1288
+ install -m600 /dev/null ~/.ssh/deploy_key # 0600 before any bytes (no umask window)
1289
+ trap 'rm -f ~/.ssh/deploy_key' EXIT # never leave the key on a shared runner
1290
+ if printf '%s' "$DEPLOY_KEY" | base64 -d 2>/dev/null | grep -q 'PRIVATE KEY'; then
1291
+ printf '%s' "$DEPLOY_KEY" | base64 -d > ~/.ssh/deploy_key
1292
+ else
1293
+ echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
1294
+ fi
1295
+ # #1637: deploy the IMMUTABLE pushed commit, not a git-describe of the
1296
+ # runner's HEAD. release_publish's internal `checkout -B FETCH_HEAD` can
1297
+ # re-poison HEAD with a replica-lagged commit by this step, so `git describe`
1298
+ # / `git rev-parse HEAD` here can name an ANCESTOR — moko-deploy then built a
1299
+ # stale image while the release reported success (the favicon incident).
1300
+ # $GITHUB_SHA is the pushed stable commit (BUMP-ON-DEV: it already carries the
1301
+ # released VERSION + code) and cannot be re-poisoned. moko-deploy checks out
1302
+ # this exact 40-hex commit (falling back to the branch tip for non-SHA tags).
1303
+ TAG="${GITHUB_SHA}"
1304
+ echo "Deploying prod commit ${TAG} to ${{ vars.PROD_SSH_USERNAME }}@${{ vars.PROD_SSH_HOST }}"
1305
+ php "${MOKO_CLI}/deploy.php" --tier prod --tag "$TAG" \
1306
+ --ssh-host "${{ vars.PROD_SSH_HOST }}" --ssh-port "${{ vars.PROD_SSH_PORT }}" \
1307
+ --ssh-user "${{ vars.PROD_SSH_USERNAME }}" --ssh-key ~/.ssh/deploy_key
1308
+
1309
+ # -- Electron: build installers (Windows via Wine + Linux) and attach ------
1310
+ # No Windows/macOS runner: the `release` runner is Ubuntu, so we cross-build
1311
+ # the Windows target with Wine and build Linux natively. macOS is skipped
1312
+ # (cannot be cross-built off a Mac). All four steps no-op on non-electron repos.
1313
+ - name: "Setup Node (electron)"
1314
+ if: steps.gate.outputs.has_makefile == 'true' && steps.electron.outputs.is_electron == 'true' && steps.version.outputs.skip != 'true' && vars.ELECTRON_CI_BUILD == 'true'
1315
+ uses: actions/setup-node@v4
1316
+ with:
1317
+ node-version: 24.18.1
1318
+
1319
+ - name: "Setup pnpm (electron)"
1320
+ if: steps.gate.outputs.has_makefile == 'true' && steps.electron.outputs.is_electron == 'true' && steps.version.outputs.skip != 'true' && vars.ELECTRON_CI_BUILD == 'true'
1321
+ uses: pnpm/action-setup@v4
1322
+ with:
1323
+ version: 11.20.0
1324
+
1325
+ - name: "Build electron installers (Windows via Wine + Linux)"
1326
+ if: steps.gate.outputs.has_makefile == 'true' && steps.electron.outputs.is_electron == 'true' && steps.version.outputs.skip != 'true' && vars.ELECTRON_CI_BUILD == 'true'
1327
+ env:
1328
+ HUSKY: "0"
1329
+ run: |
1330
+ # electron-builder's NSIS target RUNS the built installer under Wine to
1331
+ # generate + sign the uninstaller, which needs 32-bit Wine libs (the NSIS
1332
+ # stub is a WoW64 exe) AND an X display. A bare `wine64` on an
1333
+ # under-provisioned `release` runner aborts with "wine process failed 1
1334
+ # ... ntdll.dll c0000135 / no driver could be loaded" (intermittent: only
1335
+ # when a job lands on a runner without wine32/xvfb pre-seeded). Provision
1336
+ # i386 Wine + Xvfb here so the Windows leg builds on ANY runner, and init
1337
+ # the prefix once, headless, so electron-builder's first Wine call does
1338
+ # not race prefix creation.
1339
+ sudo dpkg --add-architecture i386 || true
1340
+ sudo apt-get update -qq
1341
+ sudo apt-get install -y -qq xvfb >/dev/null 2>&1 || echo "::warning::xvfb install failed"
1342
+ 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"
1343
+ export WINEDEBUG=-all
1344
+ export WINEPREFIX="${WINEPREFIX:-$HOME/.wine}"
1345
+ xvfb-run -a wineboot --init >/dev/null 2>&1 || true
1346
+ pnpm install --frozen-lockfile
1347
+ # Compile the app into electron-builder's app dir (directories.app, e.g. ./build)
1348
+ # BEFORE packaging -- electron-builder validates the app dir up-front and aborts
1349
+ # with "Application directory ./build doesn't exist" otherwise. Prefer an explicit
1350
+ # repo script (compile:app); fall back to the esbuild entrypoint.
1351
+ if pnpm run 2>/dev/null | grep -qiE '(^|[[:space:]])compile:app([[:space:]]|$)'; then
1352
+ pnpm run compile:app
1353
+ elif [ -f esbuild.mjs ]; then
1354
+ npx --no-install preval-build-info-cli >/dev/null 2>&1 || true
1355
+ node esbuild.mjs
1356
+ else
1357
+ 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"
1358
+ fi
1359
+ # xvfb-run gives Wine a virtual X display for the installer-run pass that
1360
+ # generates the NSIS uninstaller; without it Wine cannot create a window
1361
+ # and the build fails ("no driver could be loaded / $DISPLAY").
1362
+ xvfb-run -a npx --no-install electron-builder --win --linux --publish never || xvfb-run -a npx electron-builder --win --linux --publish never
1363
+
1364
+ - name: "Attach electron installers to the release"
1365
+ if: steps.gate.outputs.has_makefile == 'true' && steps.electron.outputs.is_electron == 'true' && steps.version.outputs.skip != 'true' && vars.ELECTRON_CI_BUILD == 'true'
1366
+ continue-on-error: true
1367
+ run: |
1368
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1369
+ TOKEN="${{ github.token }}"
1370
+ TAG="${{ steps.version.outputs.tag }}"
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 electron asset upload"; else
1373
+ n=0
1374
+ 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
1375
+ name=$(basename "$f")
1376
+ 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}"
1377
+ done
1378
+ echo "Electron installers attached to ${TAG}: ${n} file(s)"
1379
+ fi
1380
+
1381
+ # -- mokohub-manifest: zip manifest.json -> attach to the stable release -----
1382
+ # ADDITIVE + GATED (Template-Generic#326). Gated on is_manifest so every other
1383
+ # platform is untouched. Best-effort attach (continue-on-error): the release is
1384
+ # already published, so a packaging hiccup must not red the pipeline.
1385
+ - name: "Package mokohub-manifest (zip manifest.json)"
1386
+ if: >-
1387
+ steps.gate.outputs.has_makefile == 'true' &&
1388
+ steps.mokohub.outputs.is_manifest == 'true' &&
1389
+ steps.version.outputs.skip != 'true'
1390
+ continue-on-error: true
1391
+ run: |
1392
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1393
+ TOKEN="${{ github.token }}"
1394
+ TAG="${{ steps.version.outputs.tag }}"
1395
+ VERSION="${{ steps.version.outputs.version }}"
1396
+ if [ ! -f manifest.json ]; then
1397
+ echo "::warning::mokohub-manifest: no manifest.json at repo root — nothing to package"
1398
+ exit 0
1399
+ fi
1400
+ ZIP="${RUNNER_TEMP:-/tmp}/manifest-${VERSION}.zip"
1401
+ ( cd "$(dirname manifest.json)" && zip -j "$ZIP" manifest.json ) \
1402
+ || { echo "::warning::mokohub-manifest: zip failed"; exit 0; }
1403
+ 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)
1404
+ if [ -z "$RID" ]; then echo "::warning::release ${TAG} not found — skipping manifest asset upload"; exit 0; fi
1405
+ name="$(basename "$ZIP")"
1406
+ curl -sf -X POST -H "Authorization: token ${TOKEN}" "${API_BASE}/releases/${RID}/assets?name=${name}" -F "attachment=@${ZIP}" >/dev/null \
1407
+ && echo "attached ${name} to ${TAG}" || echo "::warning::manifest asset upload failed (${name})"
1408
+
1409
+ # -- mokohub-recipes: build catalog.zip (via MokoCLI) -> release per stream --
1410
+ # ADDITIVE + GATED (Template-Generic#326). Delegate the per-stream signed
1411
+ # catalog.zip (all recipes + all.json) to the MokoCLI build agent (`moko
1412
+ # package`, MokoCLI #684 / epic mokohub-recipes#1). Gated on is_recipes;
1413
+ # best-effort so a not-yet-shipped MokoCLI verb can't red an already-published
1414
+ # release and no existing platform is affected.
1415
+ - name: "Package mokohub-recipes (catalog.zip via MokoCLI)"
1416
+ if: >-
1417
+ steps.gate.outputs.has_makefile == 'true' &&
1418
+ steps.mokohub.outputs.is_recipes == 'true' &&
1419
+ steps.version.outputs.skip != 'true'
1420
+ continue-on-error: true
1421
+ run: |
1422
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1423
+ VERSION="${{ steps.version.outputs.version }}"
1424
+ TAG="${{ steps.version.outputs.tag }}"
1425
+ # `moko package` for the mokohub-recipes platform builds the stable-stream
1426
+ # catalog.zip and attaches it to the release. --stability stable matches the
1427
+ # auto-release (stable) channel; the platform shim selects the catalog builder.
1428
+ moko package \
1429
+ --path . --version "$VERSION" --tag "$TAG" \
1430
+ --token "${{ github.token }}" --api-base "$API_BASE" \
1431
+ --repo "${GIT_REPO}" --out "${RUNNER_TEMP:-/tmp}" \
1432
+ --stability stable \
1433
+ || echo "::warning::mokohub-recipes: moko package (catalog.zip) failed or unavailable — non-fatal (MokoCLI #684)"
1434
+
1435
+ - name: "Create semver tag for non-Joomla repos"
1436
+ id: semver
1437
+ # #469: a NEW immutable vNN tag is a version-bump artifact — skip it in
1438
+ # no-bump mode (should_bump=false). The version did not advance, so the
1439
+ # existing vNN tag already points at the current release; minting a new one
1440
+ # would imply a new version. On the bump path this is unchanged.
1441
+ if: |
1442
+ steps.gate.outputs.has_makefile == 'true' &&
1443
+ steps.srcgate.outputs.should_bump != 'false' &&
1444
+ steps.version.outputs.skip != 'true' &&
1445
+ !startsWith(steps.platform.outputs.platform, 'joomla') &&
1446
+ (steps.electron.outputs.is_electron != 'true' || vars.ELECTRON_CI_BUILD == 'true')
1447
+ run: |
1448
+ VERSION="${{ steps.version.outputs.version }}"
1449
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1450
+ TOKEN="${{ github.token }}"
1451
+ SEMVER_TAG="v${VERSION}"
1452
+ # #386: pin the semver tag to the actual built/published commit (the
1453
+ # post-publish HEAD the release/channel-tag is pinned to), NOT the
1454
+ # triggering commit -- the publish step commits the CHANGELOG entry, so
1455
+ # GITHUB_SHA is one commit stale.
1456
+ BUILT_SHA="$(git rev-parse HEAD 2>/dev/null || echo "${GITHUB_SHA}")"
1457
+
1458
+ echo "Creating semver tag: ${SEMVER_TAG}"
1459
+
1460
+ # Create the git tag via API
1461
+ HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
1462
+ -X POST -H "Authorization: token ${TOKEN}" \
1463
+ -H "Content-Type: application/json" \
1464
+ "${API_BASE}/tags" \
1465
+ -d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"${BUILT_SHA}\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
1466
+
1467
+ if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
1468
+ echo "Created semver tag: ${SEMVER_TAG}"
1469
+ elif [ "$HTTP_CODE" = "409" ]; then
1470
+ echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
1471
+ else
1472
+ echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
1473
+ fi
1474
+
1475
+ echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
1476
+
1477
+ # -- "Advance rolling channel tag via git" REMOVED (#1621 Phase 6) ---------
1478
+ # This step force-pushed refs/tags/stable to the built HEAD so the #386 gate
1479
+ # below would pass. It is now REDUNDANT: the publish already pins the `stable`
1480
+ # channel ref via release_create's forceTagRef() (#808), and #1621 has the
1481
+ # forge attach/move the channel tag onto the dual-tagged release server-side
1482
+ # (channel_tags[] on create -> AttachChannelTag -> moveGitTag at the release
1483
+ # commit). The #386 gate still resolves `tags/stable` to the built commit, so
1484
+ # it stays valid without this duplicate git force-push.
1485
+
1486
+ - name: "Release integrity gate: artifact commit == tag commit (#386)"
1487
+ if: steps.gate.outputs.has_makefile == 'true' && steps.version.outputs.skip != 'true' && (steps.electron.outputs.is_electron != 'true' || vars.ELECTRON_CI_BUILD == 'true')
1488
+ # #386: assert the published tag's commit == the commit we actually built &
1489
+ # published, and FAIL on a definite mismatch (a tag pinned to a commit we did
1490
+ # NOT build -- e.g. a LATER branch HEAD advanced by a concurrent merge). The
1491
+ # publish step commits the CHANGELOG entry ("build NN.NN.NN [skip ci]") and
1492
+ # mokocli pins the tag to that POST-PUBLISH HEAD, which is one commit past the
1493
+ # triggering GITHUB_SHA -- so BUILT must be the current HEAD, not GITHUB_SHA
1494
+ # (using GITHUB_SHA false-failed every cut whose CHANGELOG wasn't pre-seeded on
1495
+ # dev). Fail-OPEN on any ambiguity (no tag / unresolvable) so a good release is
1496
+ # never falsely blocked.
1497
+ run: |
1498
+ set -uo pipefail
1499
+ BUILT="$(git rev-parse HEAD 2>/dev/null || echo "${GITHUB_SHA}")"
1500
+ # #386: assert against the MOVED channel pointer (the rolling "stable"
1501
+ # tag, force-pinned to the built commit by release_create forceTagRef),
1502
+ # NOT the immutable vNN.NN.NN minor tag. The version-primary /
1503
+ # minor-identity model (#1621/#76/#1725) deliberately never re-points
1504
+ # vNN.NN.NN on an in-place (#469/#772) cut, so checking
1505
+ # steps.version.outputs.tag (= v${VERSION} on generic/non-Joomla)
1506
+ # false-failed every no-bump release. joomla/electron already feed-tag
1507
+ # "stable", which is why they never hit this.
1508
+ TAG="stable"
1509
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1510
+ if [ -z "$TAG" ] || [ -z "$BUILT" ]; then
1511
+ echo "::notice::integrity gate: missing tag or built SHA - skipping (fail-open)."; exit 0
1512
+ fi
1513
+ TAG_SHA=$(curl -sf -H "Authorization: token ${{ github.token }}" "${API_BASE}/tags/${TAG}" \
1514
+ | python3 -c "import sys,json; print((json.load(sys.stdin).get('commit') or {}).get('sha',''))" 2>/dev/null || true)
1515
+ if [ -z "$TAG_SHA" ]; then
1516
+ echo "::notice::integrity gate: cannot resolve tag ${TAG} to a commit - skipping (fail-open)."; exit 0
1517
+ fi
1518
+ if [ "$TAG_SHA" != "$BUILT" ]; then
1519
+ 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."
1520
+ exit 1
1521
+ fi
1522
+ echo "Release integrity OK: tag ${TAG} -> ${BUILT}"
1523
+
1524
+ - name: Update release notes and reconcile changelog
1525
+ if: steps.gate.outputs.has_makefile == 'true' && (steps.electron.outputs.is_electron != 'true' || vars.ELECTRON_CI_BUILD == 'true')
1526
+ env:
1527
+ # Admin-bypass token for the idempotent Contents-API changelog reconcile
1528
+ # below (#510) — needed only on the fix/hotfix->main path (see comment).
1529
+ GITHUB_TOKEN: ${{ github.token }}
1530
+ run: |
1531
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1532
+ TOKEN="${{ github.token }}"
1533
+
1534
+ # Get the stable release info (version and ID).
1535
+ # #1702 / .mokogit-private#76: resolve the current stable release by its
1536
+ # CHANNEL POINTER (releases/channel/stable -> ReleaseStreamMap), not by a
1537
+ # fixed tag_name=stable. Version-primary releases carry tag_name=vNN, so
1538
+ # GET /releases/tags/stable no longer resolves the current stable release.
1539
+ # Graceful fallback to the legacy tags/stable call for forges predating
1540
+ # #1702 (the channel route 404s pre-rollout). Both return the same release
1541
+ # JSON shape (id + name "... (VERSION: NN.NN.NN)").
1542
+ RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
1543
+ "${API_BASE}/releases/channel/stable" 2>/dev/null || true)
1544
+ [ -z "$RELEASE_JSON" ] && RELEASE_JSON=$(curl -sf -H "Authorization: token ${TOKEN}" \
1545
+ "${API_BASE}/releases/tags/stable" 2>/dev/null || true)
1546
+ [ -z "$RELEASE_JSON" ] && RELEASE_JSON='{}'
1547
+ RELEASE_ID=$(python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))" <<< "$RELEASE_JSON" 2>/dev/null || true)
1548
+ # Extract version from release name (e.g. "06.17.00" or "v06.17.00")
1549
+ VERSION=$(python3 -c "
1550
+ import json, sys, re
1551
+ r = json.load(sys.stdin)
1552
+ name = r.get('name', '')
1553
+ m = re.search(r'(\d+\.\d+\.\d+)', name)
1554
+ print(m.group(1) if m else '')
1555
+ " <<< "$RELEASE_JSON" 2>/dev/null || true)
1556
+
1557
+ # Extract [Unreleased] section via the shared mokocli command (#364 centralization).
1558
+ # Resolve the curated notes file CASE-INSENSITIVELY: prefer RELEASE_NOTES.md,
1559
+ # then release_notes.md, else the first case-insensitive match in the repo root.
1560
+ RN_FILE=""
1561
+ if [ -f "RELEASE_NOTES.md" ]; then RN_FILE="RELEASE_NOTES.md"
1562
+ elif [ -f "release_notes.md" ]; then RN_FILE="release_notes.md"
1563
+ else RN_FILE="$(find . -maxdepth 1 -type f -iname 'release_notes.md' -printf '%P\n' 2>/dev/null | head -n1 || true)"; fi
1564
+ NOTES=""
1565
+ if [ -n "$RN_FILE" ] && [ -f "$RN_FILE" ] && [ -s "$RN_FILE" ]; then
1566
+ NOTES="$(cat "$RN_FILE")"
1567
+ fi
1568
+ # #71 / #1725: fall back to the MINOR section of CHANGELOG when no curated
1569
+ # release-notes file. A stable release is identified by its MINOR (the VERSION
1570
+ # resolved above from the release name is NN.NN.00 — the minor is the unit of
1571
+ # release identity), and within-minor patch notes are ACCUMULATED under that
1572
+ # one ## [NN.NN.00] heading. So read $VERSION — NOT [Unreleased], which the
1573
+ # accumulate step just emptied — to get the whole minor's stacked notes.
1574
+ # --channel stable makes an empty section fall back to the "Stable release"
1575
+ # label rather than a bare "Release NN.NN.00".
1576
+ if [ -z "$NOTES" ] && [ -f "CHANGELOG.md" ]; then
1577
+ NOTES=$(php ${MOKO_CLI}/release_notes.php --path . --version "${VERSION:-Unreleased}" --channel stable 2>/dev/null || true)
1578
+ fi
1579
+ [ -z "$NOTES" ] && NOTES="Stable release"
1580
+
1581
+ # Update release body via API
1582
+ if [ -n "$RELEASE_ID" ]; then
1583
+ python3 -c "
1584
+ import json, urllib.request
1585
+ body = open('/dev/stdin').read()
1586
+ payload = json.dumps({'body': body}).encode()
1587
+ req = urllib.request.Request(
1588
+ '${API_BASE}/releases/${RELEASE_ID}',
1589
+ data=payload, method='PATCH',
1590
+ headers={
1591
+ 'Authorization': 'token ${TOKEN}',
1592
+ 'Content-Type': 'application/json'
1593
+ })
1594
+ urllib.request.urlopen(req)
1595
+ " <<< "$NOTES"
1596
+ echo "Release notes updated (curated release-notes file if present, else CHANGELOG.md)"
1597
+ fi
1598
+
1599
+ # Bump-on-dev: the CHANGELOG [Unreleased] -> [version] promotion now
1600
+ # happens on DEV pre-promotion (promote-rc's release_bump_dev.php step),
1601
+ # so main INHERITS the already-promoted changelog via the merge and this
1602
+ # step normally has nothing to do. We keep an idempotent reconcile here as
1603
+ # a safety-net for the fix/hotfix->main path (a fix/* PR merges straight to
1604
+ # main WITHOUT going through dev/promote-rc, so its [Unreleased] section was
1605
+ # never promoted). changelog_promote.php --branch is idempotent (won't
1606
+ # re-promote an existing version) and merges/normalizes, so on the dev path
1607
+ # it is a no-op; on the fix path it promotes. Non-fatal (`|| true`): the
1608
+ # release is already published, and on the dev path there is nothing to do.
1609
+ if [ -n "$VERSION" ] && [ -f "CHANGELOG.md" ]; then
1610
+ php ${MOKO_CLI}/changelog_promote.php --path . --version "$VERSION" --accumulate \
1611
+ --branch "${{ github.event.repository.default_branch }}" --token "${GITHUB_TOKEN}" --api-base "${API_BASE}" \
1612
+ || echo "::notice::changelog reconcile on main was a no-op or non-fatal (already promoted on dev)"
1613
+ echo "Changelog reconciled on main for [${VERSION}] (idempotent, accumulate)"
1614
+ fi
1615
+
1616
+ # Publish the stable release notes to the release-notes site (#470 / .mokogit-private#85).
1617
+ # INERT BY DEFAULT: skips unless BOTH the RELEASENOTES_URL and
1618
+ # RELEASENOTES_CATEGORY_ID repo/org variables are set, so the
1619
+ # step is a no-op on every repo until an owner opts in. BEST-EFFORT:
1620
+ # continue-on-error + an ntfy alert on failure, so it never red-fails a
1621
+ # release that already published. Delegates to the MokoCLI companion
1622
+ # subcommand release_publish_notes.php (MokoCLI#951, built in parallel) —
1623
+ # the step is dependency-safe: if the token is unset it exits 0, and if the
1624
+ # subcommand is absent the php call simply fails into the best-effort ntfy.
1625
+ - name: Publish release notes to the release-notes site
1626
+ if: steps.gate.outputs.has_makefile == 'true' && steps.version.outputs.skip != 'true' &&
1627
+ vars.RELEASENOTES_URL != '' && vars.RELEASENOTES_CATEGORY_ID != ''
1628
+ continue-on-error: true
1629
+ env:
1630
+ RELEASENOTES_TOKEN: ${{ secrets.RELEASENOTES_TOKEN }}
1631
+ NTFY_TOPIC: ${{ vars.RELEASENOTES_NTFY_TOPIC || vars.NTFY_TOPIC || 'git-releases' }}
1632
+ run: |
1633
+ if [ -z "${RELEASENOTES_TOKEN:-}" ]; then echo "::notice::release-notes token unset; skipping release-notes publish"; exit 0; fi
1634
+ # Resolve the PREVIOUS stable version so the subcommand can compute the
1635
+ # bump against --min-bump. There is no steps.*.outputs for the prior
1636
+ # version (the monotonic guard computes it locally but does not export
1637
+ # it), so resolve it the same channel-first way the rest of this file
1638
+ # does: GET releases/channel/stable -> release NAME "VERSION: NN.NN.NN",
1639
+ # with the legacy tags/stable fallback for forges predating #1702. The
1640
+ # `stable` channel still points at the PRIOR release at this point —
1641
+ # the new release's channel-tag move happens in the publish step above,
1642
+ # but this lookup runs on a fresh curl so empty/unresolved is fine
1643
+ # (subcommand treats empty --prev-version as first-release -> publishes).
1644
+ PREV_VERSION=""
1645
+ PREV_JSON=$(curl -sf -H "Authorization: token ${{ github.token }}" \
1646
+ "${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}/releases/channel/stable" 2>/dev/null || true)
1647
+ [ -z "$PREV_JSON" ] && PREV_JSON=$(curl -sf -H "Authorization: token ${{ github.token }}" \
1648
+ "${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}/releases/tags/stable" 2>/dev/null || true)
1649
+ PREV_VERSION=$(printf '%s' "${PREV_JSON:-}" \
1650
+ | 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)
1651
+ DISPLAY_NAME=$(grep -oE '^[[:space:]]*DISPLAY_NAME[[:space:]]*:?=.*' Makefile 2>/dev/null \
1652
+ | sed -E 's/^[[:space:]]*DISPLAY_NAME[[:space:]]*:?=[[:space:]]*//; s/[[:space:]]+$//' | head -n1 || true)
1653
+ php ${MOKO_CLI}/release_publish_notes.php --path . \
1654
+ --version "${{ steps.version.outputs.version }}" \
1655
+ --prev-version "${PREV_VERSION}" \
1656
+ --min-bump "${{ vars.MOKOLIVE_PUBLISH_BUMP || 'patch' }}" \
1657
+ --endpoint "${{ vars.MOKOLIVE_ENDPOINT }}" \
1658
+ --category-id "${{ vars.MOKOLIVE_RELEASE_NOTES_CATEGORY_ID }}" \
1659
+ --token "${MOKOLIVE_API_TOKEN}" \
1660
+ --software-slug "${{ vars.MOKOLIVE_SOFTWARE_SLUG || github.event.repository.name }}" \
1661
+ --product "${DISPLAY_NAME:-${{ github.event.repository.name }}}" \
1662
+ --release-url "${MOKOGIT_URL}/${GIT_ORG}/${GIT_REPO}/releases/tag/${{ steps.version.outputs.tag }}" \
1663
+ || curl -sS -H "Title: release-notes publish failed: ${GIT_REPO} ${{ steps.version.outputs.version }}" \
1664
+ -H "Tags: warning" -H "Priority: high" \
1665
+ -d "release:publish-notes failed for ${GIT_REPO} ${{ steps.version.outputs.version }}" \
1666
+ "${NTFY_URL}/${NTFY_TOPIC}" || true
1667
+
1668
+ # Publish the release notes to this repo's OWN wiki (Release-Notes.md).
1669
+ # BEST-EFFORT + idempotent: overwrites the single Release-Notes.md page each
1670
+ # stable release so the wiki always shows the latest notes. Guards: resolves
1671
+ # the curated notes file case-insensitively and EXITS 0 (notice) when absent,
1672
+ # and the whole step is continue-on-error (LITERAL true) so a wiki hiccup or a
1673
+ # missing file NEVER fails the release. The wiki's served branch is `main`
1674
+ # (NOT master). Uses the MOKOGIT_TOKEN org secret via x-access-token for the
1675
+ # cross-wiki push; github.token cannot write the wiki repo. NO gpg signing,
1676
+ # NO external attribution in the commit.
1677
+ # SECURITY: the release-notes file is attacker-influenceable repo content, so it
1678
+ # is treated as untrusted. It is NEVER copied with a symlink-following tool: a
1679
+ # symlinked RELEASE_NOTES.md (-> a secret file, or anything outside the workspace)
1680
+ # would otherwise have its TARGET content copied into the public-ish wiki and
1681
+ # pushed, exfiltrating it. We extract the notes as a git BLOB (git show) after
1682
+ # asserting the tracked mode is a regular file (100644/100755, NEVER 120000 =
1683
+ # symlink / 160000 = gitlink); for the rare untracked case we reject symlinks
1684
+ # (! -L) and assert the realpath stays inside GITHUB_WORKSPACE. The push token is
1685
+ # ALSO scoped to the push alone -- it is never placed in the step `env:` (so it is
1686
+ # absent from the environment while the notes file is read) and is interpolated
1687
+ # inline only on the final `git push` URL.
1688
+ - name: Publish release notes to repo wiki
1689
+ if: steps.gate.outputs.has_makefile == 'true' && steps.version.outputs.skip != 'true'
1690
+ continue-on-error: true
1691
+ run: |
1692
+ set -euo pipefail
1693
+ # Resolve the curated notes file CASE-INSENSITIVELY (same rule as above).
1694
+ RN_FILE=""
1695
+ if [ -f "RELEASE_NOTES.md" ]; then RN_FILE="RELEASE_NOTES.md"
1696
+ elif [ -f "release_notes.md" ]; then RN_FILE="release_notes.md"
1697
+ else RN_FILE="$(find . -maxdepth 1 -type f -iname 'release_notes.md' -printf '%P\n' 2>/dev/null | head -n1 || true)"; fi
1698
+ if [ -z "${RN_FILE}" ] || [ ! -s "${RN_FILE}" ]; then
1699
+ echo "::notice::No curated release-notes file present; skipping wiki publish."
1700
+ exit 0
1701
+ fi
1702
+
1703
+ # --- Extract the notes content SAFELY (no symlink follow). Prefer the git
1704
+ # blob: it cannot be a symlink/outside-workspace target and is immune to
1705
+ # a working-tree swap. Fall back to the on-disk file only when untracked,
1706
+ # and only after rejecting symlinks + asserting workspace containment.
1707
+ NOTES_CONTENT="$(mktemp)"
1708
+ GIT_MODE="$(git ls-files -s -- "${RN_FILE}" 2>/dev/null | awk '{print $1; exit}' || true)"
1709
+ if [ -n "${GIT_MODE}" ]; then
1710
+ case "${GIT_MODE}" in
1711
+ 100644|100755)
1712
+ git show "HEAD:${RN_FILE}" > "${NOTES_CONTENT}" ;;
1713
+ 120000)
1714
+ echo "::error::Refusing to publish: ${RN_FILE} is a symlink (git mode 120000)."; exit 0 ;;
1715
+ *)
1716
+ echo "::error::Refusing to publish: ${RN_FILE} has non-regular git mode ${GIT_MODE}."; exit 0 ;;
1717
+ esac
1718
+ else
1719
+ # Untracked working-tree file: reject symlinks outright, then require the
1720
+ # resolved path to stay inside the workspace before reading it.
1721
+ if [ -L "${RN_FILE}" ]; then
1722
+ echo "::error::Refusing to publish: ${RN_FILE} is a symlink."; exit 0
1723
+ fi
1724
+ WS="$(realpath -- "${GITHUB_WORKSPACE:-$PWD}")"
1725
+ RN_REAL="$(realpath -- "${RN_FILE}" 2>/dev/null || true)"
1726
+ if [ -z "${RN_REAL}" ] || { [ "${RN_REAL}" != "${WS}" ] && [ "${RN_REAL#${WS}/}" = "${RN_REAL}" ]; }; then
1727
+ echo "::error::Refusing to publish: ${RN_FILE} resolves outside the workspace."; exit 0
1728
+ fi
1729
+ cat -- "${RN_FILE}" > "${NOTES_CONTENT}"
1730
+ fi
1731
+ if [ ! -s "${NOTES_CONTENT}" ]; then
1732
+ echo "::notice::Resolved release-notes content is empty; skipping wiki publish."
1733
+ exit 0
1734
+ fi
1735
+
1736
+ SERVER="${MOKOGIT_URL#https://}"
1737
+ WIKI_DIR="$(mktemp -d)"
1738
+ # Clone/push WITHOUT the token in the URL (anonymous read is fine for a public
1739
+ # wiki; the token is supplied only on the authenticated push below). The wiki
1740
+ # repo may not exist yet; clone main, else init a fresh main.
1741
+ WIKI_URL_ANON="https://${SERVER}/${{ github.repository }}.wiki.git"
1742
+ if ! git clone --depth 1 --branch main "${WIKI_URL_ANON}" "${WIKI_DIR}" 2>/dev/null; then
1743
+ echo "::notice::Wiki repo/main branch not clonable; initialising a fresh wiki."
1744
+ git init -q -b main "${WIKI_DIR}"
1745
+ git -C "${WIKI_DIR}" remote add origin "${WIKI_URL_ANON}"
1746
+ fi
1747
+ # Write the safely-extracted blob (NOT a copy of the possibly-symlinked file).
1748
+ install -m 0644 "${NOTES_CONTENT}" "${WIKI_DIR}/Release-Notes.md"
1749
+ cd "${WIKI_DIR}"
1750
+ git config --local user.email "mokogit-actions[bot]@mokoenterprises.us"
1751
+ git config --local user.name "mokogit-actions[bot]"
1752
+ git config --local commit.gpgsign false
1753
+ git add -- Release-Notes.md
1754
+ if git diff --cached --quiet; then
1755
+ echo "Wiki Release-Notes.md already up to date -- nothing to publish."
1756
+ exit 0
1757
+ fi
1758
+ git commit --no-gpg-sign -m "docs(wiki): update Release-Notes.md for ${{ steps.version.outputs.version }}"
1759
+
1760
+ # --- Token-scoped push: the secret exists only on this line's subshell, after
1761
+ # the untrusted notes file has already been read and committed.
1762
+ TOK="$(printf '%s' "${{ secrets.MOKOGIT_TOKEN }}" | tr -d '[:space:]')"
1763
+ if [ -z "${TOK}" ]; then
1764
+ echo "::notice::No MOKOGIT_TOKEN available for wiki push; skipping wiki publish."
1765
+ exit 0
1766
+ fi
1767
+ echo "::add-mask::${TOK}"
1768
+ PUSH_URL="https://x-access-token:${TOK}@${SERVER}/${{ github.repository }}.wiki.git"
1769
+ if git push "${PUSH_URL}" HEAD:main 2>/dev/null; then
1770
+ echo "Published release notes to ${{ github.repository }} wiki (Release-Notes.md)."
1771
+ else
1772
+ echo "::warning::Wiki push rejected -- skipping (release unaffected)."
1773
+ fi
1774
+
1775
+ # -- npm stable publish: ABSORBED from Template-NPM's retired npm-publish.yml
1776
+ # Replaces the former "Dispatch npm publish" fan-out to the standalone
1777
+ # npm-publish.yml. Dual-registry, idempotent, gated on the npm platform so
1778
+ # every non-npm platform is 100% unaffected. MokoGIT npm registry uses the
1779
+ # per-run Actions token (packages:write, least-privilege, no shared secret);
1780
+ # npmjs.org uses NPM_TOKEN (best-effort, skipped if absent). A version already
1781
+ # present on a registry is a no-op. Version is the Makefile PROJECT_VERSION
1782
+ # (single source of truth), stamped into package.json in-tree only.
1783
+ - name: "Setup Node (npm publish)"
1784
+ if: steps.gate.outputs.has_makefile == 'true' && steps.npmpub.outputs.publishable == 'true' && (startsWith(steps.platform.outputs.platform, 'npm') || startsWith(steps.platform.outputs.platform, 'nodejs'))
1785
+ uses: actions/setup-node@v4
1786
+ with:
1787
+ node-version: '20'
1788
+ - name: "npm: resolve version, stamp package.json, build"
1789
+ if: steps.gate.outputs.has_makefile == 'true' && steps.npmpub.outputs.publishable == 'true' && (startsWith(steps.platform.outputs.platform, 'npm') || startsWith(steps.platform.outputs.platform, 'nodejs'))
1790
+ run: |
1791
+ set -euo pipefail
1792
+ VERSION=$(grep -E '^[[:space:]]*PROJECT_VERSION[[:space:]]*:?=' Makefile | head -1 | sed 's/.*=[[:space:]]*//' | tr -d '[:space:]')
1793
+ [ -z "$VERSION" ] && { echo "::error::No PROJECT_VERSION in Makefile - cannot determine npm publish version."; exit 1; }
1794
+ # Makefile PROJECT_VERSION is zero-padded NN.NN.NN (e.g. 01.00.01); npm needs
1795
+ # strict semver (no leading zeros), so normalize before stamping.
1796
+ SEMVER=$(printf '%s' "$VERSION" | awk -F. '{printf "%d.%d.%d",$1,$2,$3}')
1797
+ npm version "$SEMVER" --no-git-tag-version --allow-same-version
1798
+ npm ci --no-audit --no-fund || npm install --no-audit --no-fund
1799
+ npm run build --if-present
1800
+ echo "Prepared npm publish for ${SEMVER} (source: Makefile PROJECT_VERSION ${VERSION})"
1801
+ - name: "npm: publish to the MokoGIT registry (per-run Actions token)"
1802
+ if: steps.gate.outputs.has_makefile == 'true' && steps.npmpub.outputs.publishable == 'true' && (startsWith(steps.platform.outputs.platform, 'npm') || startsWith(steps.platform.outputs.platform, 'nodejs'))
1803
+ env:
1804
+ NODE_AUTH_TOKEN: ${{ github.token }}
1805
+ run: |
1806
+ set -euo pipefail
1807
+ HOST="${MOKOGIT_URL#https://}"; OWNER="${GIT_ORG}"
1808
+ REG="https://${HOST}/api/packages/${OWNER}/npm/"
1809
+ echo "//${HOST}/api/packages/${OWNER}/npm/:_authToken=${NODE_AUTH_TOKEN}" > .npmrc
1810
+ set +e; out="$(npm publish --ignore-scripts --registry "${REG}" 2>&1)"; rc=$?; set -e
1811
+ rm -f .npmrc; echo "$out"
1812
+ if [ "$rc" -eq 0 ]; then echo "Published to the MokoGIT registry."
1813
+ 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)."
1814
+ else echo "::error::MokoGIT-registry publish FAILED (npm exit ${rc}) - NOT 'already published'. See the npm error above."; exit "$rc"; fi
1815
+ - name: "npm: publish to npmjs.org (NPM_TOKEN, best-effort)"
1816
+ if: steps.gate.outputs.has_makefile == 'true' && steps.npmpub.outputs.publishable == 'true' && (startsWith(steps.platform.outputs.platform, 'npm') || startsWith(steps.platform.outputs.platform, 'nodejs'))
1817
+ env:
1818
+ NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
1819
+ run: |
1820
+ set -euo pipefail
1821
+ if [ -z "${NPM_TOKEN:-}" ]; then echo "::notice::NPM_TOKEN not set - skipping the public npmjs.org publish (MokoGIT registry is primary)."; exit 0; fi
1822
+ echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc
1823
+ set +e; out="$(npm publish --ignore-scripts --registry https://registry.npmjs.org/ --access public 2>&1)"; rc=$?; set -e
1824
+ rm -f .npmrc; echo "$out"
1825
+ if [ "$rc" -eq 0 ]; then echo "Published to npmjs.org."
1826
+ 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."
1827
+ 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 @mokoenterprises 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"
1828
+ 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
1829
+
1830
+ # -- VS Code extension: build .vsix -> attach to the release (Template-Generic#515)
1831
+ # ADDITIVE + GATED. Mirrors the absorbed npm platform. Gated on a root
1832
+ # package.json declaring engines.vscode, so every non-VSCode repo is 100%
1833
+ # unaffected. Best-effort attach (continue-on-error): the release is already
1834
+ # published, so a packaging hiccup must not red the pipeline.
1835
+ - name: "Detect VS Code extension"
1836
+ id: vscode
1837
+ if: steps.gate.outputs.has_makefile == 'true'
1838
+ run: |
1839
+ is_vscode=false
1840
+ if [ -f package.json ] && python3 -c "import json,sys; d=json.load(open('package.json')); sys.exit(0 if (d.get('engines') or {}).get('vscode') else 1)" 2>/dev/null; then
1841
+ is_vscode=true
1842
+ fi
1843
+ echo "is_vscode=${is_vscode}" >> "$GITHUB_OUTPUT"
1844
+ echo "::notice::VS Code extension detected: ${is_vscode}"
1845
+ - name: "Setup Node (.vsix)"
1846
+ if: steps.gate.outputs.has_makefile == 'true' && steps.vscode.outputs.is_vscode == 'true'
1847
+ uses: actions/setup-node@v4
1848
+ with:
1849
+ node-version: '20'
1850
+ - name: "Package & attach VS Code extension (.vsix)"
1851
+ if: steps.gate.outputs.has_makefile == 'true' && steps.vscode.outputs.is_vscode == 'true' && steps.version.outputs.skip != 'true'
1852
+ continue-on-error: true
1853
+ run: |
1854
+ set -euo pipefail
1855
+ npm ci --no-audit --no-fund || npm install --no-audit --no-fund
1856
+ TAG="${{ steps.version.outputs.tag }}"
1857
+ VSIX="${GIT_REPO}-${TAG}.vsix"
1858
+ npx --yes @vscode/vsce package --no-dependencies --allow-missing-repository -o "$VSIX"
1859
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1860
+ TOKEN="${{ github.token }}"
1861
+ 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)
1862
+ if [ -z "$RID" ]; then echo "::warning::release ${TAG} not found -- skipping .vsix upload"; else
1863
+ curl -sf -X POST -H "Authorization: token ${TOKEN}" "${API_BASE}/releases/${RID}/assets?name=${VSIX}" -F "attachment=@${VSIX}" >/dev/null \
1864
+ && echo "attached ${VSIX} to ${TAG}" || echo "::warning::failed to upload ${VSIX}"
1865
+ fi
1866
+
1867
+ # GitHub mirroring (release assets + main branch) moved to an out-of-band
1868
+ # OAuth push-mirror (Gitea repo setting) -- former Steps 9 & 10 removed.
1869
+
1870
+ - name: "Step 11: Delete rc branch (dev reset moved to cascade.yml)"
1871
+ if: steps.gate.outputs.has_makefile == 'true' && steps.version.outputs.skip != 'true'
1872
+ continue-on-error: true
1873
+ run: |
1874
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1875
+ TOKEN="${{ github.token }}"
1876
+
1877
+ # Delete rc branch (ephemeral -- created by promote-rc)
1878
+ curl -sf -X DELETE -H "Authorization: token ${TOKEN}" \
1879
+ "${API_BASE}/branches/rc" 2>/dev/null \
1880
+ && echo "Deleted rc branch" || echo "rc branch not found"
1881
+
1882
+ # dev is reset from main by the fleet cascade workflow (cascade.yml, which
1883
+ # rolled up the retired cascade-dev.yml): a push to main cascades down the
1884
+ # ladder, recreating/resetting dev from main after this release completes.
1885
+ echo "rc cleaned; dev reset handled by cascade.yml" >> $GITHUB_STEP_SUMMARY
1886
+
1887
+ # -- Version identity: #1621 Phase 6 retired the per-version branches ------
1888
+ # The old "Step 12: Create version branch from main" and "Step 13: Prune
1889
+ # superseded-major version branches" are REMOVED. A release's permanent
1890
+ # version identity is now a git TAG (v<version>) attached by the publish as a
1891
+ # movable channel tag on the dual-tagged release (MokoCLI release_publish ->
1892
+ # release_create channel_tags[]/update_stream; MokoGIT #1621), not a
1893
+ # version/* branch. No version/* consumer exists, so nothing creates them any
1894
+ # more. Pruning EXISTING version/* branches is a SEPARATE one-time manual
1895
+ # reconcile (do not prune here) — e.g. stop protecting version/* in
1896
+ # cleanup.yml and let the stale-branch sweep retire them, or a one-shot fleet
1897
+ # delete. See issue #1621 Phase 6.
1898
+
1899
+ # -- Post-release: Reset dev version (safety-net) ----------------
1900
+ - name: "Post-release: Reset dev version"
1901
+ if: steps.gate.outputs.has_makefile == 'true' && steps.version.outputs.skip != 'true'
1902
+ continue-on-error: true
1903
+ run: |
1904
+ API_BASE="${MOKOGIT_URL}/api/v1/repos/${GIT_ORG}/${GIT_REPO}"
1905
+ # Post-release SAFETY-NET: advance dev ABOVE the just-released version so the
1906
+ # dev update channel always leads stable (a dev build X.Y.Z-dev sorts BELOW
1907
+ # stable X.Y.Z, so dev MUST be bumped past the release or dev-channel updates
1908
+ # vanish). #309/#900: this was previously a SILENT swallow — a failed advance
1909
+ # under continue-on-error left the run green while dev drifted below stable.
1910
+ # Now the failure is LOUD (::error:: + job summary), and on success we also
1911
+ # explicitly (re)publish the leading dev build, because the advance commit is
1912
+ # `[skip ci]` and matches pre-release.yml's paths-ignore, so it never auto-fires.
1913
+ if php ${MOKO_CLI}/version_reset_dev.php \
1914
+ --token "${{ github.token }}" --api-base "${API_BASE}" \
1915
+ --branch dev --path . 2>&1; then
1916
+ # Advance landed → publish the leading dev build (best-effort; the release
1917
+ # already succeeded, so a publish hiccup must not red the pipeline).
1918
+ curl -sf -X POST \
1919
+ -H "Authorization: token ${{ github.token }}" \
1920
+ -H "Content-Type: application/json" \
1921
+ "${API_BASE}/actions/workflows/pre-release.yml/dispatches" \
1922
+ -d '{"ref":"dev","inputs":{"stability":"development","ref":"dev"}}' \
1923
+ && echo "Triggered pre-release (development) to publish the leading dev build." \
1924
+ || echo "::warning::Could not dispatch pre-release; the leading dev build will publish on the next real dev push."
1925
+ else
1926
+ # Do NOT silently swallow: a failed advance means the dev channel falls
1927
+ # BEHIND stable and dev-channel updates vanish fleet-wide. Surface loudly
1928
+ # (the stable release already published, so this stays green via
1929
+ # continue-on-error — but it is now impossible to miss).
1930
+ 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."
1931
+ {
1932
+ echo "### ⚠️ Dev-advance FAILED (dev-channel will trail stable)"
1933
+ echo ""
1934
+ 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."
1935
+ echo ""
1936
+ echo "Likely cause: the CI Actions token lacks **MERGE** rights on protected \`dev\` (org merge-whitelist). See **MokoCLI #900** / **Template-Joomla #309**."
1937
+ } >> "$GITHUB_STEP_SUMMARY"
1938
+ fi
1939
+
1940
+ # -- Summary --------------------------------------------------------------
1941
+ - name: Pipeline Summary
1942
+ if: always()
1943
+ run: |
1944
+ VERSION="${{ steps.bump.outputs.version || steps.version.outputs.version }}"
1945
+ PLATFORM="${{ steps.platform.outputs.platform }}"
1946
+ if [ "${{ steps.gate.outputs.has_makefile }}" != "true" ]; then
1947
+ echo "## Release Skipped -- no Makefile" >> $GITHUB_STEP_SUMMARY
1948
+ echo "No Makefile at the repository root -- this repo does not build a release." >> $GITHUB_STEP_SUMMARY
1949
+ elif [ "${{ steps.version.outputs.skip }}" = "true" ]; then
1950
+ echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
1951
+ echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
1952
+ elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
1953
+ echo "## Already Released -- ${VERSION}" >> $GITHUB_STEP_SUMMARY
1954
+ else
1955
+ # #469: label the run bump vs no-bump (update-in-place) for clarity.
1956
+ MODE="Build & Release Complete"
1957
+ [ "${{ steps.srcgate.outputs.should_bump }}" = "false" ] && MODE="Release Updated In Place (no bump, #469)"
1958
+ echo "" >> $GITHUB_STEP_SUMMARY
1959
+ echo "## ${MODE} (${PLATFORM})" >> $GITHUB_STEP_SUMMARY
1960
+ if [ "${{ steps.srcgate.outputs.should_bump }}" = "false" ]; then
1961
+ echo "" >> $GITHUB_STEP_SUMMARY
1962
+ 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
1963
+ fi
1964
+ echo "" >> $GITHUB_STEP_SUMMARY
1965
+ echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
1966
+ echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
1967
+ echo "| Platform | \`${PLATFORM}\` |" >> $GITHUB_STEP_SUMMARY
1968
+ echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
1969
+ echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
1970
+ echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
1971
+ echo "| Release | [View](${MOKOGIT_URL}/${GIT_ORG}/${GIT_REPO}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
1972
+ fi