feat(workflows): move to .mokogitea/, rename all, add .moko-platform

- Move all workflows to .mokogitea/workflows/
- Delete: auto-release.yml (redundant), deploy-demo.yml, deploy-dev.yml, pr-branch-check.yml
- Rename with MCP:/Universal: prefixes
- Create: .moko-platform (mcp), gitleaks.yml, notify.yml, pr-check.yml, security-audit.yml
- pr-check.yml includes merged branch policy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-05-11 23:03:48 -05:00
parent 4e56a14c0e
commit 8eb3f2faa2
23 changed files with 457 additions and 1914 deletions
-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
MokoStandards Repository Manifest
Auto-generated by cleanup script.
See: https://git.mokoconsulting.tech/MokoConsulting/moko-platform/wiki/Home
-->
<moko-platform xmlns="https://standards.mokoconsulting.tech/moko-platform/1.0" schema-version="1.0">
<identity>
<name>Template-MCP</name>
<org>MokoConsulting</org>
<description>Template repository for creating MokoStandards-compliant MCP API servers</description>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
<platform>nodejs</platform>
<standards-version>04.07.00</standards-version>
<standards-source>https://git.mokoconsulting.tech/MokoConsulting/moko-platform</standards-source>
<last-synced>2026-05-10T19:51:11+00:00</last-synced>
</governance>
<build>
<language>TypeScript</language>
<package-type>mcp-server</package-type>
<entry-point>src/</entry-point>
</build>
</moko-platform>
-352
View File
@@ -1,352 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: GitHub.Workflow
# INGROUP: MokoStandards.Release
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
# PATH: /templates/workflows/shared/auto-release.yml.template
# VERSION: 04.06.00
# BRIEF: Generic build & release pipeline — version branch, platform version, badges, tag, release
#
# +========================================================================+
# | BUILD & RELEASE PIPELINE |
# +========================================================================+
# | |
# | Triggers on push to main (skips bot commits + [skip ci]): |
# | |
# | Every push: |
# | 1. Read version from README.md |
# | 3. Set platform version |
# | 4. Update [VERSION: XX.YY.ZZ] badges in markdown files |
# | 6. Create git tag vXX.YY.ZZ |
# | 7a. Patch: update existing GitHub Release for this minor |
# | |
# | Every version change: archives main -> version/XX.YY branch |
# | Patch 00 = development (no release). First release = patch 01. |
# | First release only (patch == 01): |
# | 7b. Create new GitHub Release |
# | |
# +========================================================================+
name: Build & Release
on:
push:
branches:
- main
- master
paths:
- 'src/**'
- 'htdocs/**'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
permissions:
contents: write
jobs:
release:
name: Build & Release Pipeline
runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
github.actor != 'github-actions[bot]'
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
token: ${{ secrets.GH_TOKEN || github.token }}
fetch-depth: 0
- name: Setup MokoStandards tools
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch version/04 --quiet \
"https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \
/tmp/mokostandards
cd /tmp/mokostandards
composer install --no-dev --no-interaction --quiet
# -- STEP 1: Read version -----------------------------------------------
- name: "Step 1: Read version from README.md"
id: version
run: |
VERSION=$(php /tmp/mokostandards/api/cli/version_read.php --path . 2>/dev/null)
if [ -z "$VERSION" ]; then
echo "No VERSION in README.md — skipping release"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Derive major.minor for branch naming (patches update existing branch)
MINOR=$(echo "$VERSION" | awk -F. '{printf "%s.%s", $1, $2}')
PATCH=$(echo "$VERSION" | awk -F. '{print $3}')
MAJOR=$(echo "$VERSION" | awk -F. '{print $1}')
MINOR_NUM=$(echo "$VERSION" | awk -F. '{print $2}')
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "branch=version/${MAJOR}" >> "$GITHUB_OUTPUT"
echo "minor=$MINOR" >> "$GITHUB_OUTPUT"
echo "major=$MAJOR" >> "$GITHUB_OUTPUT"
echo "release_tag=v${MAJOR}" >> "$GITHUB_OUTPUT"
if [ "$PATCH" = "00" ]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "is_minor=false" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION (patch 00 = development — skipping release)"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
if [ "$PATCH" = "01" ]; then
echo "is_minor=true" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION (first release — full pipeline)"
else
echo "is_minor=false" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION (patch — platform version + badges only)"
fi
fi
- name: Check if already released
if: steps.version.outputs.skip != 'true'
id: check
run: |
TAG="${{ steps.version.outputs.release_tag }}"
BRANCH="${{ steps.version.outputs.branch }}"
TAG_EXISTS=false
BRANCH_EXISTS=false
git rev-parse "$TAG" >/dev/null 2>&1 && TAG_EXISTS=true
git ls-remote --heads origin "$BRANCH" 2>/dev/null | grep -q "$BRANCH" && BRANCH_EXISTS=true
echo "tag_exists=$TAG_EXISTS" >> "$GITHUB_OUTPUT"
echo "branch_exists=$BRANCH_EXISTS" >> "$GITHUB_OUTPUT"
if [ "$TAG_EXISTS" = "true" ] && [ "$BRANCH_EXISTS" = "true" ]; then
echo "already_released=true" >> "$GITHUB_OUTPUT"
else
echo "already_released=false" >> "$GITHUB_OUTPUT"
fi
# -- SANITY CHECKS -------------------------------------------------------
- name: "Sanity: Pre-release validation"
if: >-
steps.version.outputs.skip != 'true' &&
steps.check.outputs.already_released != 'true'
run: |
VERSION="${{ steps.version.outputs.version }}"
ERRORS=0
echo "## Pre-Release Sanity Checks" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# -- Version drift check (must pass before release) --------
README_VER=$(grep -oP 'VERSION:\s*\K[\d.]+' README.md 2>/dev/null | head -1)
if [ "$README_VER" != "$VERSION" ]; then
echo "- Version drift: README says \`${README_VER}\` but releasing \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS+1))
else
echo "- Version consistent: \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
fi
# Check CHANGELOG version matches
CL_VER=$(grep -oP 'VERSION:\s*\K[\d.]+' CHANGELOG.md 2>/dev/null | head -1)
if [ -n "$CL_VER" ] && [ "$CL_VER" != "$VERSION" ]; then
echo "- CHANGELOG drift: \`${CL_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS+1))
fi
# Check composer.json version if present
if [ -f "composer.json" ]; then
COMP_VER=$(grep -oP '"version"\s*:\s*"\K[^"]+' composer.json 2>/dev/null | head -1)
if [ -n "$COMP_VER" ] && [ "$COMP_VER" != "$VERSION" ]; then
echo "- composer.json drift: \`${COMP_VER}\` != \`${VERSION}\`" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS+1))
fi
fi
# Common checks
if [ ! -f "LICENSE" ]; then
echo "- Missing LICENSE file" >> $GITHUB_STEP_SUMMARY
ERRORS=$((ERRORS+1))
else
echo "- LICENSE present" >> $GITHUB_STEP_SUMMARY
fi
if [ ! -d "src" ] && [ ! -d "htdocs" ]; then
echo "- Warning: No src/ or htdocs/ directory" >> $GITHUB_STEP_SUMMARY
else
echo "- Source directory present" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
if [ "$ERRORS" -gt 0 ]; then
echo "**${ERRORS} error(s) — release may be incomplete**" >> $GITHUB_STEP_SUMMARY
else
echo "**All sanity checks passed**" >> $GITHUB_STEP_SUMMARY
fi
# -- STEP 2: Create or update version/XX.YY archive branch ---------------
# Always runs — every version change on main archives to version/XX.YY
- name: "Step 2: Version archive branch"
if: steps.check.outputs.already_released != 'true'
run: |
BRANCH="${{ steps.version.outputs.branch }}"
IS_MINOR="${{ steps.version.outputs.is_minor }}"
PATCH="${{ steps.version.outputs.version }}"
PATCH_NUM=$(echo "$PATCH" | awk -F. '{print $3}')
# Check if branch exists
if git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
git push origin HEAD:"$BRANCH" --force
echo "Updated archive branch: ${BRANCH} (patch ${PATCH_NUM})" >> $GITHUB_STEP_SUMMARY
else
git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH"
git push origin "$BRANCH" --force
echo "Created archive branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
fi
# -- STEP 3: Set platform version ----------------------------------------
- name: "Step 3: Set platform version"
if: >-
steps.version.outputs.skip != 'true' &&
steps.check.outputs.already_released != 'true'
run: |
VERSION="${{ steps.version.outputs.version }}"
php /tmp/mokostandards/api/cli/version_set_platform.php \
--path . --version "$VERSION" --branch main
# -- STEP 4: Update version badges ----------------------------------------
- name: "Step 4: Update version badges"
if: >-
steps.version.outputs.skip != 'true' &&
steps.check.outputs.already_released != 'true'
run: |
VERSION="${{ steps.version.outputs.version }}"
find . -name "*.md" ! -path "./.git/*" ! -path "./vendor/*" | while read -r f; do
if grep -q '\[VERSION:' "$f" 2>/dev/null; then
sed -i "s/\[VERSION:[[:space:]]*[0-9]\{2\}\.[0-9]\{2\}\.[0-9]\{2\}\]/[VERSION: ${VERSION}]/" "$f"
fi
done
# -- Commit all changes ---------------------------------------------------
- name: Commit release changes
if: >-
steps.version.outputs.skip != 'true' &&
steps.check.outputs.already_released != 'true'
run: |
if git diff --quiet && git diff --cached --quiet; then
echo "No changes to commit"
exit 0
fi
VERSION="${{ steps.version.outputs.version }}"
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add -A
git commit -m "chore(release): build ${VERSION} [skip ci]" \
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
git push
# -- STEP 6: Create tag ---------------------------------------------------
- name: "Step 6: Create git tag"
if: >-
steps.version.outputs.skip != 'true' &&
steps.check.outputs.tag_exists != 'true' &&
steps.version.outputs.is_minor == 'true'
run: |
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
# Only create the major release tag if it doesn't exist yet
if ! git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then
git tag "$RELEASE_TAG"
git push origin "$RELEASE_TAG"
echo "Tag created: ${RELEASE_TAG}" >> $GITHUB_STEP_SUMMARY
else
echo "Tag ${RELEASE_TAG} already exists" >> $GITHUB_STEP_SUMMARY
fi
echo "Tag: ${TAG}" >> $GITHUB_STEP_SUMMARY
# -- STEP 7: Create or update GitHub Release ------------------------------
- name: "Step 7: GitHub Release"
if: >-
steps.version.outputs.skip != 'true' &&
steps.check.outputs.tag_exists != 'true'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
run: |
VERSION="${{ steps.version.outputs.version }}"
RELEASE_TAG="${{ steps.version.outputs.release_tag }}"
BRANCH="${{ steps.version.outputs.branch }}"
MAJOR="${{ steps.version.outputs.major }}"
NOTES=$(php /tmp/mokostandards/api/cli/release_notes.php --path . --version "$VERSION" 2>/dev/null)
[ -z "$NOTES" ] && NOTES="Release ${VERSION}"
echo "$NOTES" > /tmp/release_notes.md
# Check if the major release already exists
EXISTING=$(gh release view "$RELEASE_TAG" --json tagName -q .tagName 2>/dev/null || true)
if [ -z "$EXISTING" ]; then
# First release for this major: create GitHub Release
gh release create "$RELEASE_TAG" \
--title "v${MAJOR} (latest: ${VERSION})" \
--notes-file /tmp/release_notes.md \
--target "$BRANCH"
echo "Release created: ${RELEASE_TAG} (${VERSION})" >> $GITHUB_STEP_SUMMARY
else
# Update existing major release with new version info
CURRENT_NOTES=$(gh release view "$RELEASE_TAG" --json body -q .body 2>/dev/null || true)
{
echo "$CURRENT_NOTES"
echo ""
echo "---"
echo "### ${VERSION}"
echo ""
cat /tmp/release_notes.md
} > /tmp/updated_notes.md
gh release edit "$RELEASE_TAG" \
--title "v${MAJOR} (latest: ${VERSION})" \
--notes-file /tmp/updated_notes.md
echo "Release updated: ${RELEASE_TAG} -> ${VERSION}" >> $GITHUB_STEP_SUMMARY
fi
- name: "Step 7.5: Minify assets"
if: >-
steps.version.outputs.skip != 'true'
run: |
npm install --no-save terser clean-css 2>/dev/null || true
MINIFY=""
for p in "../moko-platform/build/minify.js" "scripts/minify.js"; do
[ -f "$p" ] && MINIFY="$p" && break
done
if [ -n "$MINIFY" ]; then
node "$MINIFY" src
else
echo "No minify script found — skipping"
fi
# -- Summary --------------------------------------------------------------
- name: Pipeline Summary
if: always()
run: |
VERSION="${{ steps.version.outputs.version }}"
if [ "${{ steps.version.outputs.skip }}" = "true" ]; then
echo "## Release Skipped" >> $GITHUB_STEP_SUMMARY
echo "No VERSION in README.md" >> $GITHUB_STEP_SUMMARY
elif [ "${{ steps.check.outputs.already_released }}" = "true" ]; then
echo "## Already Released — ${VERSION}" >> $GITHUB_STEP_SUMMARY
else
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Build & Release Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Step | Result |" >> $GITHUB_STEP_SUMMARY
echo "|------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Version | \`${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Branch | \`${{ steps.version.outputs.branch }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Tag | \`${{ steps.version.outputs.tag }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Release | [View](https://github.com/${{ github.repository }}/releases/tag/${{ steps.version.outputs.tag }}) |" >> $GITHUB_STEP_SUMMARY
fi
-734
View File
@@ -1,734 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# FILE INFORMATION
# DEFGROUP: GitHub.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
# PATH: /templates/workflows/shared/deploy-demo.yml.template
# VERSION: 04.06.00
# BRIEF: SFTP deployment workflow for demo server — synced to all governed repos
# NOTE: Synced via bulk-repo-sync to .github/workflows/deploy-demo.yml in all governed repos.
# Port is resolved in order: DEMO_FTP_PORT variable → :port suffix in DEMO_FTP_HOST → 22.
name: Deploy to Demo Server (SFTP)
# Deploys the contents of the src/ directory to the demo server via SFTP.
# Triggers on push/merge to main — deploys the production-ready build to the demo server.
#
# Required org-level variables: DEMO_FTP_HOST, DEMO_FTP_PATH, DEMO_FTP_USERNAME
# Optional org-level variable: DEMO_FTP_PORT (auto-detected from host or defaults to 22)
# Optional org/repo variable: DEMO_FTP_SUFFIX — when set, appended to DEMO_FTP_PATH to form the
# full remote destination: DEMO_FTP_PATH/DEMO_FTP_SUFFIX
# Ignore rules: Place a .ftpignore file in the src/ directory. Each non-empty,
# non-comment line is a glob pattern tested against the relative path
# of each file (e.g. "subdir/file.txt"). The .gitignore is NOT used.
# Required org-level secret: DEMO_FTP_KEY (preferred) or DEMO_FTP_PASSWORD
#
# Access control: only users with admin or maintain role on the repository may deploy.
on:
push:
branches:
- main
- master
paths:
- 'src/**'
- 'htdocs/**'
pull_request:
types: [opened, synchronize, reopened, closed]
branches:
- main
- master
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
inputs:
clear_remote:
description: 'Delete all files inside the remote destination folder before uploading'
required: false
default: false
type: boolean
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
check-permission:
name: Verify Deployment Permission
runs-on: ubuntu-latest
steps:
- name: Check actor permission
env:
# Prefer the org-scoped GH_TOKEN secret (needed for the org membership
# fallback). Falls back to the built-in github.token so the collaborator
# endpoint still works even if GH_TOKEN is not configured.
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
run: |
ACTOR="${{ github.actor }}"
REPO="${{ github.repository }}"
ORG="${{ github.repository_owner }}"
METHOD=""
AUTHORIZED="false"
# Hardcoded authorized users — always allowed to deploy
AUTHORIZED_USERS="jmiller github-actions[bot]"
for user in $AUTHORIZED_USERS; do
if [ "$ACTOR" = "$user" ]; then
AUTHORIZED="true"
METHOD="hardcoded allowlist"
PERMISSION="admin"
break
fi
done
# For other actors, check repo/org permissions via API
if [ "$AUTHORIZED" != "true" ]; then
PERMISSION=$(gh api "repos/${REPO}/collaborators/${ACTOR}/permission" \
--jq '.permission' 2>/dev/null)
METHOD="repo collaborator API"
if [ -z "$PERMISSION" ]; then
ORG_ROLE=$(gh api "orgs/${ORG}/memberships/${ACTOR}" \
--jq '.role' 2>/dev/null)
METHOD="org membership API"
if [ "$ORG_ROLE" = "owner" ]; then
PERMISSION="admin"
else
PERMISSION="none"
fi
fi
case "$PERMISSION" in
admin|maintain) AUTHORIZED="true" ;;
esac
fi
# Write detailed summary
{
echo "## 🔐 Deploy Authorization"
echo ""
echo "| Field | Value |"
echo "|-------|-------|"
echo "| **Actor** | \`${ACTOR}\` |"
echo "| **Repository** | \`${REPO}\` |"
echo "| **Permission** | \`${PERMISSION}\` |"
echo "| **Method** | ${METHOD} |"
echo "| **Authorized** | ${AUTHORIZED} |"
echo "| **Trigger** | \`${{ github.event_name }}\` |"
echo "| **Branch** | \`${{ github.ref_name }}\` |"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
if [ "$AUTHORIZED" = "true" ]; then
echo "✅ ${ACTOR} authorized to deploy (${METHOD})" >> "$GITHUB_STEP_SUMMARY"
else
echo "❌ ${ACTOR} is NOT authorized to deploy." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Deployment requires one of:" >> "$GITHUB_STEP_SUMMARY"
echo "- Being in the hardcoded allowlist" >> "$GITHUB_STEP_SUMMARY"
echo "- Having \`admin\` or \`maintain\` role on the repository" >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
deploy:
name: SFTP Deploy → Demo
runs-on: ubuntu-latest
needs: [check-permission]
if: >-
!startsWith(github.head_ref || github.ref_name, 'chore/') &&
(github.event_name == 'workflow_dispatch' ||
github.event_name == 'push' ||
(github.event_name == 'pull_request' &&
(github.event.action == 'opened' ||
github.event.action == 'synchronize' ||
github.event.action == 'reopened' ||
github.event.pull_request.merged == true)))
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Resolve source directory
id: source
run: |
# Resolve source directory: src/ preferred, htdocs/ as fallback
if [ -d "src" ]; then
SRC="src"
elif [ -d "htdocs" ]; then
SRC="htdocs"
else
echo "⚠️ No src/ or htdocs/ directory found — skipping deployment"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
COUNT=$(find "$SRC" -type f | wc -l)
echo "✅ Source: ${SRC}/ (${COUNT} file(s))"
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "dir=${SRC}" >> "$GITHUB_OUTPUT"
- name: Preview files to deploy
if: steps.source.outputs.skip == 'false'
env:
SOURCE_DIR: ${{ steps.source.outputs.dir }}
run: |
# ── Convert a ftpignore-style glob line to an ERE pattern ──────────────
ftpignore_to_regex() {
local line="$1"
local anchored=false
# Strip inline comments and whitespace
line=$(printf '%s' "$line" | sed 's/[[:space:]]*#.*$//' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[ -z "$line" ] && return
# Skip negation patterns (not supported)
[[ "$line" == !* ]] && return
# Trailing slash = directory marker; strip it
line="${line%/}"
# Leading slash = anchored to root; strip it
if [[ "$line" == /* ]]; then
anchored=true
line="${line#/}"
fi
# Escape ERE special chars, then restore glob semantics
local regex
regex=$(printf '%s' "$line" \
| sed 's/[.+^${}()|[\\]/\\&/g' \
| sed 's/\\\*\\\*/\x01/g' \
| sed 's/\\\*/[^\/]*/g' \
| sed 's/\x01/.*/g' \
| sed 's/\\\?/[^\/]/g')
if $anchored; then
printf '^%s(/|$)' "$regex"
else
printf '(^|/)%s(/|$)' "$regex"
fi
}
# ── Read .ftpignore (ftpignore-style globs) ─────────────────────────
IGNORE_PATTERNS=()
IGNORE_SOURCES=()
if [ -f "${SOURCE_DIR}/.ftpignore" ]; then
while IFS= read -r line; do
[[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue
regex=$(ftpignore_to_regex "$line")
[ -n "$regex" ] && IGNORE_PATTERNS+=("$regex") && IGNORE_SOURCES+=("$line")
done < "${SOURCE_DIR}/.ftpignore"
fi
# ── Walk src/ and classify every file ────────────────────────────────
WILL_UPLOAD=()
IGNORED_FILES=()
while IFS= read -r -d '' file; do
rel="${file#${SOURCE_DIR}/}"
SKIP=false
for i in "${!IGNORE_PATTERNS[@]}"; do
if echo "$rel" | grep -qE "${IGNORE_PATTERNS[$i]}" 2>/dev/null; then
IGNORED_FILES+=("$rel | .ftpignore \`${IGNORE_SOURCES[$i]}\`")
SKIP=true; break
fi
done
$SKIP && continue
WILL_UPLOAD+=("$rel")
done < <(find "$SOURCE_DIR" -type f -print0 | sort -z)
UPLOAD_COUNT="${#WILL_UPLOAD[@]}"
IGNORE_COUNT="${#IGNORED_FILES[@]}"
echo "️ ${UPLOAD_COUNT} file(s) will be uploaded, ${IGNORE_COUNT} ignored"
# ── Write deployment preview to step summary ──────────────────────────
{
echo "## 📋 Deployment Preview"
echo ""
echo "| Field | Value |"
echo "|---|---|"
echo "| Source | \`${SOURCE_DIR}/\` |"
echo "| Files to upload | **${UPLOAD_COUNT}** |"
echo "| Files ignored | **${IGNORE_COUNT}** |"
echo ""
if [ "${UPLOAD_COUNT}" -gt 0 ]; then
echo "### 📂 Files that will be uploaded"
echo '```'
printf '%s\n' "${WILL_UPLOAD[@]}"
echo '```'
echo ""
fi
if [ "${IGNORE_COUNT}" -gt 0 ]; then
echo "### ⏭️ Files excluded"
echo "| File | Reason |"
echo "|---|---|"
for entry in "${IGNORED_FILES[@]}"; do
f="${entry% | *}"; r="${entry##* | }"
echo "| \`${f}\` | ${r} |"
done
echo ""
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Resolve SFTP host and port
if: steps.source.outputs.skip == 'false'
id: conn
env:
HOST_RAW: ${{ vars.DEMO_FTP_HOST }}
PORT_VAR: ${{ vars.DEMO_FTP_PORT }}
run: |
HOST="$HOST_RAW"
PORT="$PORT_VAR"
if [ -z "$HOST" ]; then
echo "⏭️ DEMO_FTP_HOST not configured — skipping demo deployment."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Priority 1 — explicit DEMO_FTP_PORT variable
if [ -n "$PORT" ]; then
echo "️ Using explicit DEMO_FTP_PORT=${PORT}"
# Priority 2 — port embedded in DEMO_FTP_HOST (host:port)
elif [[ "$HOST" == *:* ]]; then
PORT="${HOST##*:}"
HOST="${HOST%:*}"
echo "️ Extracted port ${PORT} from DEMO_FTP_HOST"
# Priority 3 — SFTP default
else
PORT="22"
echo "️ No port specified — defaulting to SFTP port 22"
fi
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
echo "port=${PORT}" >> "$GITHUB_OUTPUT"
echo "SFTP target: ${HOST}:${PORT}"
- name: Build remote path
if: steps.source.outputs.skip == 'false' && steps.conn.outputs.skip != 'true'
id: remote
env:
DEMO_FTP_PATH: ${{ vars.DEMO_FTP_PATH }}
DEMO_FTP_SUFFIX: ${{ vars.DEMO_FTP_SUFFIX }}
run: |
BASE="$DEMO_FTP_PATH"
if [ -z "$BASE" ]; then
echo "⏭️ DEMO_FTP_PATH not configured — skipping demo deployment."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# DEMO_FTP_SUFFIX is required — it identifies the remote subdirectory for this repo.
# Without it we cannot safely determine the deployment target.
if [ -z "$DEMO_FTP_SUFFIX" ]; then
echo "⏭️ DEMO_FTP_SUFFIX variable is not set — skipping deployment."
echo " Set DEMO_FTP_SUFFIX as a repo or org variable to enable deploy-demo."
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "path=" >> "$GITHUB_OUTPUT"
exit 0
fi
REMOTE="${BASE%/}/${DEMO_FTP_SUFFIX#/}"
# ── Platform-specific path safety guards ──────────────────────────────
PLATFORM=""
MOKO_FILE=".github/.mokostandards"; [ ! -f "$MOKO_FILE" ] && MOKO_FILE=".mokostandards"; if [ -f "$MOKO_FILE" ]; then
PLATFORM=$(grep -E '^platform:' "$MOKO_FILE" | sed 's/.*:[[:space:]]*//' | tr -d '"')
fi
if [ "$PLATFORM" = "crm-module" ]; then
# Dolibarr modules must deploy under htdocs/custom/ — guard against
# accidentally overwriting server root or unrelated directories.
if [[ "$REMOTE" != *custom* ]]; then
echo "❌ Safety check failed: Dolibarr (crm-module) remote path must contain 'custom'."
echo " Current path: ${REMOTE}"
echo " Set DEMO_FTP_SUFFIX to the module's htdocs/custom/ subdirectory."
exit 1
fi
fi
if [ "$PLATFORM" = "waas-component" ]; then
# Joomla extensions may only deploy to the server's tmp/ directory.
if [[ "$REMOTE" != *tmp* ]]; then
echo "❌ Safety check failed: Joomla (waas-component) remote path must contain 'tmp'."
echo " Current path: ${REMOTE}"
echo " Set DEMO_FTP_SUFFIX to a path under the server tmp/ directory."
exit 1
fi
fi
echo "️ Remote path: ${REMOTE}"
echo "path=${REMOTE}" >> "$GITHUB_OUTPUT"
- name: Detect SFTP authentication method
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
id: auth
env:
HAS_KEY: ${{ secrets.DEMO_FTP_KEY }}
HAS_PASSWORD: ${{ secrets.DEMO_FTP_PASSWORD }}
run: |
if [ -n "$HAS_KEY" ] && [ -n "$HAS_PASSWORD" ]; then
# Both set: key auth with password as passphrase; falls back to password-only if key fails
echo "method=key" >> "$GITHUB_OUTPUT"
echo "use_passphrase=true" >> "$GITHUB_OUTPUT"
echo "has_password=true" >> "$GITHUB_OUTPUT"
echo "️ Primary: SSH key + passphrase (DEMO_FTP_KEY / DEMO_FTP_PASSWORD)"
echo "️ Fallback: password-only auth if key authentication fails"
elif [ -n "$HAS_KEY" ]; then
# Key only: no passphrase, no password fallback
echo "method=key" >> "$GITHUB_OUTPUT"
echo "use_passphrase=false" >> "$GITHUB_OUTPUT"
echo "has_password=false" >> "$GITHUB_OUTPUT"
echo "️ Using SSH key authentication (DEMO_FTP_KEY, no passphrase, no fallback)"
elif [ -n "$HAS_PASSWORD" ]; then
# Password only: direct SFTP password auth
echo "method=password" >> "$GITHUB_OUTPUT"
echo "use_passphrase=false" >> "$GITHUB_OUTPUT"
echo "has_password=true" >> "$GITHUB_OUTPUT"
echo "️ Using password authentication (DEMO_FTP_PASSWORD)"
else
echo "❌ No SFTP credentials configured."
echo " Set DEMO_FTP_KEY (preferred) or DEMO_FTP_PASSWORD as an org-level secret."
exit 1
fi
- name: Setup PHP
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
uses: shivammathur/setup-php@fcafdd6392932010c2bd5094439b8e33be2a8a09 # v2.37.0
with:
php-version: '8.1'
tools: composer
- name: Setup MokoStandards deploy tools
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch version/04 --quiet \
"https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \
/tmp/mokostandards
cd /tmp/mokostandards
composer install --no-dev --no-interaction --quiet
- name: Clear remote destination folder (manual only)
if: >-
steps.source.outputs.skip == 'false' &&
steps.remote.outputs.skip != 'true' &&
inputs.clear_remote == true
env:
SFTP_HOST: ${{ steps.conn.outputs.host }}
SFTP_PORT: ${{ steps.conn.outputs.port }}
SFTP_USER: ${{ vars.DEMO_FTP_USERNAME }}
SFTP_KEY: ${{ secrets.DEMO_FTP_KEY }}
SFTP_PASSWORD: ${{ secrets.DEMO_FTP_PASSWORD }}
AUTH_METHOD: ${{ steps.auth.outputs.method }}
USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }}
HAS_PASSWORD: ${{ steps.auth.outputs.has_password }}
REMOTE_PATH: ${{ steps.remote.outputs.path }}
run: |
cat > /tmp/moko_clear.php << 'PHPEOF'
<?php
declare(strict_types=1);
require '/tmp/mokostandards/vendor/autoload.php';
use phpseclib3\Net\SFTP;
use phpseclib3\Crypt\PublicKeyLoader;
$host = (string) getenv('SFTP_HOST');
$port = (int) getenv('SFTP_PORT');
$username = (string) getenv('SFTP_USER');
$authMethod = (string) getenv('AUTH_METHOD');
$usePassphrase = getenv('USE_PASSPHRASE') === 'true';
$hasPassword = getenv('HAS_PASSWORD') === 'true';
$remotePath = rtrim((string) getenv('REMOTE_PATH'), '/');
echo "⚠️ Clearing remote folder: {$remotePath}\n";
$sftp = new SFTP($host, $port);
// ── Authentication ──────────────────────────────────────────────
if ($authMethod === 'key') {
$keyData = (string) getenv('SFTP_KEY');
$passphrase = $usePassphrase ? (string) getenv('SFTP_PASSWORD') : false;
$password = $hasPassword ? (string) getenv('SFTP_PASSWORD') : '';
$key = PublicKeyLoader::load($keyData, $passphrase);
if (!$sftp->login($username, $key)) {
if ($password !== '') {
echo "⚠️ Key auth failed — falling back to password\n";
if (!$sftp->login($username, $password)) {
fwrite(STDERR, "❌ Both key and password authentication failed\n");
exit(1);
}
echo "✅ Connected via password authentication (key fallback)\n";
} else {
fwrite(STDERR, "❌ Key authentication failed and no password fallback is available\n");
exit(1);
}
} else {
echo "✅ Connected via SSH key authentication\n";
}
} else {
if (!$sftp->login($username, (string) getenv('SFTP_PASSWORD'))) {
fwrite(STDERR, "❌ Password authentication failed\n");
exit(1);
}
echo "✅ Connected via password authentication\n";
}
// ── Recursive delete ────────────────────────────────────────────
function rmrf(SFTP $sftp, string $path): void
{
$entries = $sftp->nlist($path);
if ($entries === false) {
return; // path does not exist — nothing to clear
}
foreach ($entries as $name) {
if ($name === '.' || $name === '..') {
continue;
}
$entry = "{$path}/{$name}";
if ($sftp->is_dir($entry)) {
rmrf($sftp, $entry);
$sftp->rmdir($entry);
echo " 🗑️ Removed dir: {$entry}\n";
} else {
$sftp->delete($entry);
echo " 🗑️ Removed file: {$entry}\n";
}
}
}
// ── Create remote directory tree ────────────────────────────────
function sftpMakedirs(SFTP $sftp, string $path): void
{
$parts = array_values(array_filter(explode('/', $path), fn(string $p) => $p !== ''));
$current = str_starts_with($path, '/') ? '' : '';
foreach ($parts as $part) {
$current .= '/' . $part;
$sftp->mkdir($current); // silently returns false if already exists
}
}
rmrf($sftp, $remotePath);
sftpMakedirs($sftp, $remotePath);
echo "✅ Remote folder ready: {$remotePath}\n";
PHPEOF
php /tmp/moko_clear.php
- name: Deploy via SFTP
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
env:
SFTP_HOST: ${{ steps.conn.outputs.host }}
SFTP_PORT: ${{ steps.conn.outputs.port }}
SFTP_USER: ${{ vars.DEMO_FTP_USERNAME }}
SFTP_KEY: ${{ secrets.DEMO_FTP_KEY }}
SFTP_PASSWORD: ${{ secrets.DEMO_FTP_PASSWORD }}
AUTH_METHOD: ${{ steps.auth.outputs.method }}
USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }}
REMOTE_PATH: ${{ steps.remote.outputs.path }}
SOURCE_DIR: ${{ steps.source.outputs.dir }}
run: |
# ── Write SSH key to temp file (key auth only) ────────────────────────
if [ "$AUTH_METHOD" = "key" ]; then
printf '%s' "$SFTP_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
fi
# ── Generate sftp-config.json safely via jq ───────────────────────────
if [ "$AUTH_METHOD" = "key" ]; then
jq -n \
--arg host "$SFTP_HOST" \
--argjson port "${SFTP_PORT:-22}" \
--arg user "$SFTP_USER" \
--arg path "$REMOTE_PATH" \
--arg key "/tmp/deploy_key" \
'{host:$host, port:$port, user:$user, remote_path:$path, ssh_key_file:$key}' \
> /tmp/sftp-config.json
else
jq -n \
--arg host "$SFTP_HOST" \
--argjson port "${SFTP_PORT:-22}" \
--arg user "$SFTP_USER" \
--arg path "$REMOTE_PATH" \
--arg pass "$SFTP_PASSWORD" \
'{host:$host, port:$port, user:$user, remote_path:$path, password:$pass}' \
> /tmp/sftp-config.json
fi
# ── Write update files (demo = stable) ─────────────────────────────
PLATFORM=$(php /tmp/mokostandards/api/cli/platform_detect.php --path . 2>/dev/null || true)
VERSION=$(php /tmp/mokostandards/api/cli/version_read.php --path . 2>/dev/null || echo "unknown")
REPO="${{ github.repository }}"
if [ "$PLATFORM" = "crm-module" ]; then
printf '%s' "$VERSION" > update.txt
fi
if [ "$PLATFORM" = "waas-component" ]; then
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
if [ -n "$MANIFEST" ]; then
EXT_NAME=$(grep -oP '<name>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "${{ github.event.repository.name }}")
EXT_TYPE=$(grep -oP '<extension[^>]+type="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "component")
EXT_ELEMENT=$(grep -oP '<element>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || basename "$MANIFEST" .xml)
EXT_CLIENT=$(grep -oP '<extension[^>]+client="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
EXT_FOLDER=$(grep -oP '<extension[^>]+group="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
TARGET_PLATFORM=$(grep -oP '<targetplatform[^/]*/' "$MANIFEST" 2>/dev/null | head -1 || true)
[ -n "$TARGET_PLATFORM" ] && TARGET_PLATFORM="${TARGET_PLATFORM}>"
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="5.*" %s>' "/")
CLIENT_TAG=""
if [ -n "$EXT_CLIENT" ]; then CLIENT_TAG="<client>${EXT_CLIENT}</client>"; elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then CLIENT_TAG="<client>site</client>"; fi
FOLDER_TAG=""
if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"; fi
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/v${VERSION}/${EXT_ELEMENT}-${VERSION}.zip"
{
printf '%s\n' '<?xml version="1.0" encoding="utf-8"?>'
printf '%s\n' '<updates>'
printf '%s\n' ' <update>'
printf '%s\n' " <name>${EXT_NAME}</name>"
printf '%s\n' " <description>${EXT_NAME} update</description>"
printf '%s\n' " <element>${EXT_ELEMENT}</element>"
printf '%s\n' " <type>${EXT_TYPE}</type>"
printf '%s\n' " <version>${VERSION}</version>"
[ -n "$CLIENT_TAG" ] && printf '%s\n' " ${CLIENT_TAG}"
[ -n "$FOLDER_TAG" ] && printf '%s\n' " ${FOLDER_TAG}"
printf '%s\n' ' <tags>'
printf '%s\n' ' <tag>stable</tag>'
printf '%s\n' ' </tags>'
printf '%s\n' " <infourl title=\"${EXT_NAME}\">https://github.com/${REPO}</infourl>"
printf '%s\n' ' <downloads>'
printf '%s\n' " <downloadurl type=\"full\" format=\"zip\">${DOWNLOAD_URL}</downloadurl>"
printf '%s\n' ' </downloads>'
printf '%s\n' " ${TARGET_PLATFORM}"
printf '%s\n' ' <maintainer>Moko Consulting</maintainer>'
printf '%s\n' ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>'
printf '%s\n' ' </update>'
printf '%s\n' '</updates>'
} > updates.xml
fi
fi
# ── Run deploy-sftp.php from MokoStandards ────────────────────────────
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
if [ "$USE_PASSPHRASE" = "true" ]; then
DEPLOY_ARGS+=(--key-passphrase "$SFTP_PASSWORD")
fi
PLATFORM=$(php /tmp/mokostandards/api/cli/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards/api/deploy/deploy-joomla.php" ]; then
php /tmp/mokostandards/api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
else
php /tmp/mokostandards/api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
fi
# Remove temp files that should never be left behind
rm -f /tmp/deploy_key /tmp/sftp-config.json
- name: Create or update failure issue
if: failure() && steps.remote.outputs.skip != 'true' && steps.conn.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
run: |
REPO="${{ github.repository }}"
RUN_URL="${{ github.server_url }}/${REPO}/actions/runs/${{ github.run_id }}"
ACTOR="${{ github.actor }}"
BRANCH="${{ github.ref_name }}"
EVENT="${{ github.event_name }}"
NOW=$(date -u '+%Y-%m-%d %H:%M:%S UTC')
LABEL="deploy-failure"
TITLE="fix: Demo deployment failed — ${REPO}"
BODY="## Demo Deployment Failed
A deployment to the demo server failed and requires attention.
| Field | Value |
|-------|-------|
| **Repository** | \`${REPO}\` |
| **Branch** | \`${BRANCH}\` |
| **Trigger** | ${EVENT} |
| **Actor** | @${ACTOR} |
| **Failed at** | ${NOW} |
| **Run** | [View workflow run](${RUN_URL}) |
### Next steps
1. Review the [workflow run log](${RUN_URL}) for the specific error.
2. Fix the underlying issue (credentials, SFTP connectivity, permissions).
3. Re-trigger the deployment via **Actions → Deploy to Demo Server → Run workflow**.
---
*Auto-created by deploy-demo.yml — close this issue once the deployment is resolved.*"
# Ensure the label exists (idempotent — no-op if already present)
gh label create "$LABEL" \
--repo "$REPO" \
--color "CC0000" \
--description "Automated deploy failure tracking" \
--force 2>/dev/null || true
# Look for an existing open deploy-failure issue
EXISTING=$(gh api "repos/${REPO}/issues?labels=${LABEL}&state=all&per_page=1&sort=created&direction=desc" \
--jq '.[0].number' 2>/dev/null)
if [ -n "$EXISTING" ] && [ "$EXISTING" != "null" ]; then
gh api "repos/${REPO}/issues/${EXISTING}" \
-X PATCH \
-f title="$TITLE" \
-f body="$BODY" \
-f state="open" \
--silent
echo "📋 Failure issue #${EXISTING} updated/reopened: ${REPO}" >> "$GITHUB_STEP_SUMMARY"
else
gh issue create \
--repo "$REPO" \
--title "$TITLE" \
--body "$BODY" \
--label "$LABEL" \
--assignee "jmiller" \
| tee -a "$GITHUB_STEP_SUMMARY"
fi
- name: Deployment summary
if: always()
run: |
if [ "${{ steps.source.outputs.skip }}" == "true" ]; then
echo "### ⏭️ Deployment Skipped" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "No \`src/\` directory found in this repository." >> "$GITHUB_STEP_SUMMARY"
elif [ "${{ job.status }}" == "success" ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### ✅ Demo Deployment Successful" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Field | Value |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Host | \`${{ steps.conn.outputs.host }}:${{ steps.conn.outputs.port }}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Remote path | \`${{ steps.remote.outputs.path }}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Source | \`src/\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Trigger | ${{ github.event_name }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Auth | ${{ steps.auth.outputs.method }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Clear remote | ${{ inputs.clear_remote || 'false' }} |" >> "$GITHUB_STEP_SUMMARY"
else
echo "### ❌ Demo Deployment Failed" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Check the job log above for error details." >> "$GITHUB_STEP_SUMMARY"
fi
-700
View File
@@ -1,700 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# This file is part of a Moko Consulting project.
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# FILE INFORMATION
# DEFGROUP: GitHub.Workflow
# INGROUP: MokoStandards.Deploy
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
# PATH: /templates/workflows/shared/deploy-dev.yml.template
# VERSION: 04.06.00
# BRIEF: SFTP deployment workflow for development server — synced to all governed repos
# NOTE: Synced via bulk-repo-sync to .github/workflows/deploy-dev.yml in all governed repos.
# Port is resolved in order: DEV_FTP_PORT variable → :port suffix in DEV_FTP_HOST → 22.
name: Deploy to Dev Server (SFTP)
# Deploys the contents of the src/ directory to the development server via SFTP.
# Triggers on every pull_request to development branches (so the dev server always
# reflects the latest PR state) and on push/merge to main branches.
#
# Required org-level variables: DEV_FTP_HOST, DEV_FTP_PATH, DEV_FTP_USERNAME
# Optional org-level variable: DEV_FTP_PORT (auto-detected from host or defaults to 22)
# Optional org/repo variable: DEV_FTP_SUFFIX — when set, appended to DEV_FTP_PATH to form the
# full remote destination: DEV_FTP_PATH/DEV_FTP_SUFFIX
# Ignore rules: Place a .ftpignore file in the src/ directory. Each non-empty,
# non-comment line is a glob pattern tested against the relative path
# of each file (e.g. "subdir/file.txt"). The .gitignore is NOT used.
# Required org-level secret: DEV_FTP_KEY (preferred) or DEV_FTP_PASSWORD
#
# Access control: only users with admin or maintain role on the repository may deploy.
on:
push:
branches:
- 'dev/**'
- 'rc/**'
- develop
- development
paths:
- 'src/**'
- 'htdocs/**'
pull_request:
types: [opened, synchronize, reopened, closed]
branches:
- 'dev/**'
- 'rc/**'
- develop
- development
paths:
- 'src/**'
- 'htdocs/**'
workflow_dispatch:
inputs:
clear_remote:
description: 'Delete all files inside the remote destination folder before uploading'
required: false
default: false
type: boolean
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
check-permission:
name: Verify Deployment Permission
runs-on: ubuntu-latest
steps:
- name: Check actor permission
env:
# Prefer the org-scoped GH_TOKEN secret (needed for the org membership
# fallback). Falls back to the built-in github.token so the collaborator
# endpoint still works even if GH_TOKEN is not configured.
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
run: |
ACTOR="${{ github.actor }}"
REPO="${{ github.repository }}"
ORG="${{ github.repository_owner }}"
METHOD=""
AUTHORIZED="false"
# Hardcoded authorized users — always allowed to deploy
AUTHORIZED_USERS="jmiller github-actions[bot]"
for user in $AUTHORIZED_USERS; do
if [ "$ACTOR" = "$user" ]; then
AUTHORIZED="true"
METHOD="hardcoded allowlist"
PERMISSION="admin"
break
fi
done
# For other actors, check repo/org permissions via API
if [ "$AUTHORIZED" != "true" ]; then
PERMISSION=$(gh api "repos/${REPO}/collaborators/${ACTOR}/permission" \
--jq '.permission' 2>/dev/null)
METHOD="repo collaborator API"
if [ -z "$PERMISSION" ]; then
ORG_ROLE=$(gh api "orgs/${ORG}/memberships/${ACTOR}" \
--jq '.role' 2>/dev/null)
METHOD="org membership API"
if [ "$ORG_ROLE" = "owner" ]; then
PERMISSION="admin"
else
PERMISSION="none"
fi
fi
case "$PERMISSION" in
admin|maintain) AUTHORIZED="true" ;;
esac
fi
# Write detailed summary
{
echo "## 🔐 Deploy Authorization"
echo ""
echo "| Field | Value |"
echo "|-------|-------|"
echo "| **Actor** | \`${ACTOR}\` |"
echo "| **Repository** | \`${REPO}\` |"
echo "| **Permission** | \`${PERMISSION}\` |"
echo "| **Method** | ${METHOD} |"
echo "| **Authorized** | ${AUTHORIZED} |"
echo "| **Trigger** | \`${{ github.event_name }}\` |"
echo "| **Branch** | \`${{ github.ref_name }}\` |"
echo ""
} >> "$GITHUB_STEP_SUMMARY"
if [ "$AUTHORIZED" = "true" ]; then
echo "✅ ${ACTOR} authorized to deploy (${METHOD})" >> "$GITHUB_STEP_SUMMARY"
else
echo "❌ ${ACTOR} is NOT authorized to deploy." >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Deployment requires one of:" >> "$GITHUB_STEP_SUMMARY"
echo "- Being in the hardcoded allowlist" >> "$GITHUB_STEP_SUMMARY"
echo "- Having \`admin\` or \`maintain\` role on the repository" >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
deploy:
name: SFTP Deploy → Dev
runs-on: ubuntu-latest
needs: [check-permission]
if: >-
!startsWith(github.head_ref || github.ref_name, 'chore/') &&
(github.event_name == 'workflow_dispatch' ||
github.event_name == 'push' ||
(github.event_name == 'pull_request' &&
(github.event.action == 'opened' ||
github.event.action == 'synchronize' ||
github.event.action == 'reopened' ||
github.event.pull_request.merged == true)))
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Resolve source directory
id: source
run: |
# Resolve source directory: src/ preferred, htdocs/ as fallback
if [ -d "src" ]; then
SRC="src"
elif [ -d "htdocs" ]; then
SRC="htdocs"
else
echo "⚠️ No src/ or htdocs/ directory found — skipping deployment"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
COUNT=$(find "$SRC" -type f | wc -l)
echo "✅ Source: ${SRC}/ (${COUNT} file(s))"
echo "skip=false" >> "$GITHUB_OUTPUT"
echo "dir=${SRC}" >> "$GITHUB_OUTPUT"
- name: Preview files to deploy
if: steps.source.outputs.skip == 'false'
env:
SOURCE_DIR: ${{ steps.source.outputs.dir }}
run: |
# ── Convert a ftpignore-style glob line to an ERE pattern ──────────────
ftpignore_to_regex() {
local line="$1"
local anchored=false
# Strip inline comments and whitespace
line=$(printf '%s' "$line" | sed 's/[[:space:]]*#.*$//' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
[ -z "$line" ] && return
# Skip negation patterns (not supported)
[[ "$line" == !* ]] && return
# Trailing slash = directory marker; strip it
line="${line%/}"
# Leading slash = anchored to root; strip it
if [[ "$line" == /* ]]; then
anchored=true
line="${line#/}"
fi
# Escape ERE special chars, then restore glob semantics
local regex
regex=$(printf '%s' "$line" \
| sed 's/[.+^${}()|[\\]/\\&/g' \
| sed 's/\\\*\\\*/\x01/g' \
| sed 's/\\\*/[^\/]*/g' \
| sed 's/\x01/.*/g' \
| sed 's/\\\?/[^\/]/g')
if $anchored; then
printf '^%s(/|$)' "$regex"
else
printf '(^|/)%s(/|$)' "$regex"
fi
}
# ── Read .ftpignore (ftpignore-style globs) ─────────────────────────
IGNORE_PATTERNS=()
IGNORE_SOURCES=()
if [ -f "${SOURCE_DIR}/.ftpignore" ]; then
while IFS= read -r line; do
[[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue
regex=$(ftpignore_to_regex "$line")
[ -n "$regex" ] && IGNORE_PATTERNS+=("$regex") && IGNORE_SOURCES+=("$line")
done < "${SOURCE_DIR}/.ftpignore"
fi
# ── Walk src/ and classify every file ────────────────────────────────
WILL_UPLOAD=()
IGNORED_FILES=()
while IFS= read -r -d '' file; do
rel="${file#${SOURCE_DIR}/}"
SKIP=false
for i in "${!IGNORE_PATTERNS[@]}"; do
if echo "$rel" | grep -qE "${IGNORE_PATTERNS[$i]}" 2>/dev/null; then
IGNORED_FILES+=("$rel | .ftpignore \`${IGNORE_SOURCES[$i]}\`")
SKIP=true; break
fi
done
$SKIP && continue
WILL_UPLOAD+=("$rel")
done < <(find "$SOURCE_DIR" -type f -print0 | sort -z)
UPLOAD_COUNT="${#WILL_UPLOAD[@]}"
IGNORE_COUNT="${#IGNORED_FILES[@]}"
echo "️ ${UPLOAD_COUNT} file(s) will be uploaded, ${IGNORE_COUNT} ignored"
# ── Write deployment preview to step summary ──────────────────────────
{
echo "## 📋 Deployment Preview"
echo ""
echo "| Field | Value |"
echo "|---|---|"
echo "| Source | \`${SOURCE_DIR}/\` |"
echo "| Files to upload | **${UPLOAD_COUNT}** |"
echo "| Files ignored | **${IGNORE_COUNT}** |"
echo ""
if [ "${UPLOAD_COUNT}" -gt 0 ]; then
echo "### 📂 Files that will be uploaded"
echo '```'
printf '%s\n' "${WILL_UPLOAD[@]}"
echo '```'
echo ""
fi
if [ "${IGNORE_COUNT}" -gt 0 ]; then
echo "### ⏭️ Files excluded"
echo "| File | Reason |"
echo "|---|---|"
for entry in "${IGNORED_FILES[@]}"; do
f="${entry% | *}"; r="${entry##* | }"
echo "| \`${f}\` | ${r} |"
done
echo ""
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Resolve SFTP host and port
if: steps.source.outputs.skip == 'false'
id: conn
env:
HOST_RAW: ${{ vars.DEV_FTP_HOST }}
PORT_VAR: ${{ vars.DEV_FTP_PORT }}
run: |
HOST="$HOST_RAW"
PORT="$PORT_VAR"
# Priority 1 — explicit DEV_FTP_PORT variable
if [ -n "$PORT" ]; then
echo "️ Using explicit DEV_FTP_PORT=${PORT}"
# Priority 2 — port embedded in DEV_FTP_HOST (host:port)
elif [[ "$HOST" == *:* ]]; then
PORT="${HOST##*:}"
HOST="${HOST%:*}"
echo "️ Extracted port ${PORT} from DEV_FTP_HOST"
# Priority 3 — SFTP default
else
PORT="22"
echo "️ No port specified — defaulting to SFTP port 22"
fi
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
echo "port=${PORT}" >> "$GITHUB_OUTPUT"
echo "SFTP target: ${HOST}:${PORT}"
- name: Build remote path
if: steps.source.outputs.skip == 'false'
id: remote
env:
DEV_FTP_PATH: ${{ vars.DEV_FTP_PATH }}
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
run: |
BASE="$DEV_FTP_PATH"
if [ -z "$BASE" ]; then
echo "❌ DEV_FTP_PATH is not set."
echo " Configure it as an org-level variable (Settings → Variables) and"
echo " ensure this repository has been granted access to it."
exit 1
fi
# DEV_FTP_SUFFIX is required — it identifies the remote subdirectory for this repo.
# Without it we cannot safely determine the deployment target.
if [ -z "$DEV_FTP_SUFFIX" ]; then
echo "⏭️ DEV_FTP_SUFFIX variable is not set — skipping deployment."
echo " Set DEV_FTP_SUFFIX as a repo or org variable to enable deploy-dev."
echo "skip=true" >> "$GITHUB_OUTPUT"
echo "path=" >> "$GITHUB_OUTPUT"
exit 0
fi
REMOTE="${BASE%/}/${DEV_FTP_SUFFIX#/}"
# ── Platform-specific path safety guards ──────────────────────────────
PLATFORM=""
MOKO_FILE=".github/.mokostandards"; [ ! -f "$MOKO_FILE" ] && MOKO_FILE=".mokostandards"; if [ -f "$MOKO_FILE" ]; then
PLATFORM=$(grep -oP '^platform:.*' "$MOKO_FILE" 2>/dev/null || true)
fi
if [ "$PLATFORM" = "crm-module" ]; then
# Dolibarr modules must deploy under htdocs/custom/ — guard against
# accidentally overwriting server root or unrelated directories.
if [[ "$REMOTE" != *custom* ]]; then
echo "❌ Safety check failed: Dolibarr (crm-module) remote path must contain 'custom'."
echo " Current path: ${REMOTE}"
echo " Set DEV_FTP_SUFFIX to the module's htdocs/custom/ subdirectory."
exit 1
fi
fi
if [ "$PLATFORM" = "waas-component" ]; then
# Joomla extensions may only deploy to the server's tmp/ directory.
if [[ "$REMOTE" != *tmp* ]]; then
echo "❌ Safety check failed: Joomla (waas-component) remote path must contain 'tmp'."
echo " Current path: ${REMOTE}"
echo " Set DEV_FTP_SUFFIX to a path under the server tmp/ directory."
exit 1
fi
fi
echo "️ Remote path: ${REMOTE}"
echo "path=${REMOTE}" >> "$GITHUB_OUTPUT"
- name: Detect SFTP authentication method
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
id: auth
env:
HAS_KEY: ${{ secrets.DEV_FTP_KEY }}
HAS_PASSWORD: ${{ secrets.DEV_FTP_PASSWORD }}
run: |
if [ -n "$HAS_KEY" ] && [ -n "$HAS_PASSWORD" ]; then
# Both set: key auth with password as passphrase; falls back to password-only if key fails
echo "method=key" >> "$GITHUB_OUTPUT"
echo "use_passphrase=true" >> "$GITHUB_OUTPUT"
echo "has_password=true" >> "$GITHUB_OUTPUT"
echo "️ Primary: SSH key + passphrase (DEV_FTP_KEY / DEV_FTP_PASSWORD)"
echo "️ Fallback: password-only auth if key authentication fails"
elif [ -n "$HAS_KEY" ]; then
# Key only: no passphrase, no password fallback
echo "method=key" >> "$GITHUB_OUTPUT"
echo "use_passphrase=false" >> "$GITHUB_OUTPUT"
echo "has_password=false" >> "$GITHUB_OUTPUT"
echo "️ Using SSH key authentication (DEV_FTP_KEY, no passphrase, no fallback)"
elif [ -n "$HAS_PASSWORD" ]; then
# Password only: direct SFTP password auth
echo "method=password" >> "$GITHUB_OUTPUT"
echo "use_passphrase=false" >> "$GITHUB_OUTPUT"
echo "has_password=true" >> "$GITHUB_OUTPUT"
echo "️ Using password authentication (DEV_FTP_PASSWORD)"
else
echo "❌ No SFTP credentials configured."
echo " Set DEV_FTP_KEY (preferred) or DEV_FTP_PASSWORD as an org-level secret."
exit 1
fi
- name: Setup PHP
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
uses: shivammathur/setup-php@fcafdd6392932010c2bd5094439b8e33be2a8a09 # v2.37.0
with:
php-version: '8.1'
tools: composer
- name: Setup MokoStandards deploy tools
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
env:
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
run: |
git clone --depth 1 --branch version/04 --quiet \
"https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \
/tmp/mokostandards
cd /tmp/mokostandards
composer install --no-dev --no-interaction --quiet
- name: Clear remote destination folder (manual only)
if: >-
steps.source.outputs.skip == 'false' &&
steps.remote.outputs.skip != 'true' &&
inputs.clear_remote == true
env:
SFTP_HOST: ${{ steps.conn.outputs.host }}
SFTP_PORT: ${{ steps.conn.outputs.port }}
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
SFTP_PASSWORD: ${{ secrets.DEV_FTP_PASSWORD }}
AUTH_METHOD: ${{ steps.auth.outputs.method }}
USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }}
HAS_PASSWORD: ${{ steps.auth.outputs.has_password }}
REMOTE_PATH: ${{ steps.remote.outputs.path }}
run: |
cat > /tmp/moko_clear.php << 'PHPEOF'
<?php
declare(strict_types=1);
require '/tmp/mokostandards/vendor/autoload.php';
use phpseclib3\Net\SFTP;
use phpseclib3\Crypt\PublicKeyLoader;
$host = (string) getenv('SFTP_HOST');
$port = (int) getenv('SFTP_PORT');
$username = (string) getenv('SFTP_USER');
$authMethod = (string) getenv('AUTH_METHOD');
$usePassphrase = getenv('USE_PASSPHRASE') === 'true';
$hasPassword = getenv('HAS_PASSWORD') === 'true';
$remotePath = rtrim((string) getenv('REMOTE_PATH'), '/');
echo "⚠️ Clearing remote folder: {$remotePath}\n";
$sftp = new SFTP($host, $port);
// ── Authentication ──────────────────────────────────────────────
if ($authMethod === 'key') {
$keyData = (string) getenv('SFTP_KEY');
$passphrase = $usePassphrase ? (string) getenv('SFTP_PASSWORD') : false;
$password = $hasPassword ? (string) getenv('SFTP_PASSWORD') : '';
$key = PublicKeyLoader::load($keyData, $passphrase);
if (!$sftp->login($username, $key)) {
if ($password !== '') {
echo "⚠️ Key auth failed — falling back to password\n";
if (!$sftp->login($username, $password)) {
fwrite(STDERR, "❌ Both key and password authentication failed\n");
exit(1);
}
echo "✅ Connected via password authentication (key fallback)\n";
} else {
fwrite(STDERR, "❌ Key authentication failed and no password fallback is available\n");
exit(1);
}
} else {
echo "✅ Connected via SSH key authentication\n";
}
} else {
if (!$sftp->login($username, (string) getenv('SFTP_PASSWORD'))) {
fwrite(STDERR, "❌ Password authentication failed\n");
exit(1);
}
echo "✅ Connected via password authentication\n";
}
// ── Recursive delete ────────────────────────────────────────────
function rmrf(SFTP $sftp, string $path): void
{
$entries = $sftp->nlist($path);
if ($entries === false) {
return; // path does not exist — nothing to clear
}
foreach ($entries as $name) {
if ($name === '.' || $name === '..') {
continue;
}
$entry = "{$path}/{$name}";
if ($sftp->is_dir($entry)) {
rmrf($sftp, $entry);
$sftp->rmdir($entry);
echo " 🗑️ Removed dir: {$entry}\n";
} else {
$sftp->delete($entry);
echo " 🗑️ Removed file: {$entry}\n";
}
}
}
// ── Create remote directory tree ────────────────────────────────
function sftpMakedirs(SFTP $sftp, string $path): void
{
$parts = array_values(array_filter(explode('/', $path), fn(string $p) => $p !== ''));
$current = str_starts_with($path, '/') ? '' : '';
foreach ($parts as $part) {
$current .= '/' . $part;
$sftp->mkdir($current); // silently returns false if already exists
}
}
rmrf($sftp, $remotePath);
sftpMakedirs($sftp, $remotePath);
echo "✅ Remote folder ready: {$remotePath}\n";
PHPEOF
php /tmp/moko_clear.php
- name: Deploy via SFTP
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
env:
SFTP_HOST: ${{ steps.conn.outputs.host }}
SFTP_PORT: ${{ steps.conn.outputs.port }}
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
SFTP_PASSWORD: ${{ secrets.DEV_FTP_PASSWORD }}
AUTH_METHOD: ${{ steps.auth.outputs.method }}
USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }}
REMOTE_PATH: ${{ steps.remote.outputs.path }}
SOURCE_DIR: ${{ steps.source.outputs.dir }}
run: |
# ── Write SSH key to temp file (key auth only) ────────────────────────
if [ "$AUTH_METHOD" = "key" ]; then
printf '%s' "$SFTP_KEY" > /tmp/deploy_key
chmod 600 /tmp/deploy_key
fi
# ── Generate sftp-config.json safely via jq ───────────────────────────
if [ "$AUTH_METHOD" = "key" ]; then
jq -n \
--arg host "$SFTP_HOST" \
--argjson port "${SFTP_PORT:-22}" \
--arg user "$SFTP_USER" \
--arg path "$REMOTE_PATH" \
--arg key "/tmp/deploy_key" \
'{host:$host, port:$port, user:$user, remote_path:$path, ssh_key_file:$key}' \
> /tmp/sftp-config.json
else
jq -n \
--arg host "$SFTP_HOST" \
--argjson port "${SFTP_PORT:-22}" \
--arg user "$SFTP_USER" \
--arg path "$REMOTE_PATH" \
--arg pass "$SFTP_PASSWORD" \
'{host:$host, port:$port, user:$user, remote_path:$path, password:$pass}' \
> /tmp/sftp-config.json
fi
# Dev deploys skip minified files — use unminified sources for debugging
echo "*.min.js" >> "${SOURCE_DIR}/.ftpignore"
echo "*.min.css" >> "${SOURCE_DIR}/.ftpignore"
# ── Run deploy-sftp.php from MokoStandards ────────────────────────────
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
if [ "$USE_PASSPHRASE" = "true" ]; then
DEPLOY_ARGS+=(--key-passphrase "$SFTP_PASSWORD")
fi
# Set platform version to "development" before deploy (Dolibarr + Joomla)
php /tmp/mokostandards/api/cli/version_set_platform.php --path . --version development
# Write update files — dev/** = development, rc/** = rc
PLATFORM=$(php /tmp/mokostandards/api/cli/platform_detect.php --path . 2>/dev/null || true)
REPO="${{ github.repository }}"
BRANCH="${{ github.ref_name }}"
# Determine stability tag from branch prefix
STABILITY="development"
VERSION_LABEL="development"
if [[ "$BRANCH" == rc/* ]]; then
STABILITY="rc"
VERSION_LABEL=$(php /tmp/mokostandards/api/cli/version_read.php --path . 2>/dev/null || echo "${BRANCH#rc/}")-rc
fi
if [ "$PLATFORM" = "crm-module" ]; then
printf '%s' "$VERSION_LABEL" > update.txt
fi
if [ "$PLATFORM" = "waas-component" ]; then
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
if [ -n "$MANIFEST" ]; then
EXT_NAME=$(grep -oP '<name>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "${{ github.event.repository.name }}")
EXT_TYPE=$(grep -oP '<extension[^>]+type="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "component")
EXT_ELEMENT=$(grep -oP '<element>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || basename "$MANIFEST" .xml)
EXT_CLIENT=$(grep -oP '<extension[^>]+client="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
EXT_FOLDER=$(grep -oP '<extension[^>]+group="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
TARGET_PLATFORM=$(grep -oP '<targetplatform[^/]*/' "$MANIFEST" 2>/dev/null | head -1 || true)
[ -n "$TARGET_PLATFORM" ] && TARGET_PLATFORM="${TARGET_PLATFORM}>"
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="5.*" %s>' "/")
CLIENT_TAG=""
if [ -n "$EXT_CLIENT" ]; then
CLIENT_TAG="<client>${EXT_CLIENT}</client>"
elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then
CLIENT_TAG="<client>site</client>"
fi
FOLDER_TAG=""
if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then
FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
fi
DOWNLOAD_URL="https://github.com/${REPO}/archive/refs/heads/${BRANCH}.zip"
{
printf '%s\n' '<?xml version="1.0" encoding="utf-8"?>'
printf '%s\n' '<updates>'
printf '%s\n' ' <update>'
printf '%s\n' " <name>${EXT_NAME}</name>"
printf '%s\n' " <description>${EXT_NAME} ${STABILITY} build</description>"
printf '%s\n' " <element>${EXT_ELEMENT}</element>"
printf '%s\n' " <type>${EXT_TYPE}</type>"
printf '%s\n' " <version>${VERSION_LABEL}</version>"
[ -n "$CLIENT_TAG" ] && printf '%s\n' " ${CLIENT_TAG}"
[ -n "$FOLDER_TAG" ] && printf '%s\n' " ${FOLDER_TAG}"
printf '%s\n' ' <tags>'
printf '%s\n' " <tag>${STABILITY}</tag>"
printf '%s\n' ' </tags>'
printf '%s\n' " <infourl title=\"${EXT_NAME}\">https://github.com/${REPO}/tree/${BRANCH}</infourl>"
printf '%s\n' ' <downloads>'
printf '%s\n' " <downloadurl type=\"full\" format=\"zip\">${DOWNLOAD_URL}</downloadurl>"
printf '%s\n' ' </downloads>'
printf '%s\n' " ${TARGET_PLATFORM}"
printf '%s\n' ' <maintainer>Moko Consulting</maintainer>'
printf '%s\n' ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>'
printf '%s\n' ' </update>'
printf '%s\n' '</updates>'
} > updates.xml
sed -i '/^[[:space:]]*$/d' updates.xml
fi
fi
# Use Joomla-aware deploy for waas-component (routes files to correct Joomla dirs)
# Use standard SFTP deploy for everything else
PLATFORM=$(php /tmp/mokostandards/api/cli/platform_detect.php --path . 2>/dev/null || true)
if [ "$PLATFORM" = "waas-component" ] && [ -f "/tmp/mokostandards/api/deploy/deploy-joomla.php" ]; then
php /tmp/mokostandards/api/deploy/deploy-joomla.php "${DEPLOY_ARGS[@]}"
else
php /tmp/mokostandards/api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
fi
# (both scripts handle dotfile skipping and .ftpignore natively)
# Remove temp files that should never be left behind
rm -f /tmp/deploy_key /tmp/sftp-config.json
# Dev deploys fail silently — no issue creation.
# Demo and RS deploys create failure issues (production-facing).
- name: Deployment summary
if: always()
run: |
if [ "${{ steps.source.outputs.skip }}" == "true" ]; then
echo "### ⏭️ Deployment Skipped" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "No \`src/\` directory found in this repository." >> "$GITHUB_STEP_SUMMARY"
elif [ "${{ job.status }}" == "success" ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### ✅ Dev Deployment Successful" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Field | Value |" >> "$GITHUB_STEP_SUMMARY"
echo "|-------|-------|" >> "$GITHUB_STEP_SUMMARY"
echo "| Host | \`${{ steps.conn.outputs.host }}:${{ steps.conn.outputs.port }}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Remote path | \`${{ steps.remote.outputs.path }}\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Source | \`src/\` |" >> "$GITHUB_STEP_SUMMARY"
echo "| Trigger | ${{ github.event_name }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Auth | ${{ steps.auth.outputs.method }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Clear remote | ${{ inputs.clear_remote || 'false' }} |" >> "$GITHUB_STEP_SUMMARY"
else
echo "### ❌ Dev Deployment Failed" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "Check the job log above for error details." >> "$GITHUB_STEP_SUMMARY"
fi
-90
View File
@@ -1,90 +0,0 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
#
# Enforces branch merge policy:
# feature/* → dev only
# fix/* → dev only
# hotfix/* → dev or main (emergency)
# dev → main only
# alpha/* → dev only
# beta/* → dev only
# rc/* → main only
name: Branch Policy Check
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
check-target:
name: Verify merge target
runs-on: ubuntu-latest
steps:
- name: Check branch policy
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
alpha/*|beta/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Pre-release branches must target 'dev', not '${BASE}'"
fi
;;
rc/*)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Release candidate branches must target 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo ""
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
+1
View File
@@ -0,0 +1 @@
mcp
@@ -9,7 +9,7 @@
# VERSION: 04.06.00
# BRIEF: Auto-assign jmiller to unassigned issues and PRs every 15 minutes
name: Auto-Assign Issues & PRs
name: "Universal: Auto-Assign"
on:
issues:
@@ -13,7 +13,7 @@
# BRIEF: Auto-create tracking issue with sub-issues for dev/rc branch workflow
# NOTE: Synced via bulk-repo-sync to .github/workflows/auto-dev-issue.yml in all governed repos.
name: Dev/RC Branch Issue
name: "Universal: Dev/RC Branch Issue"
on:
# Auto-create on RC branch creation
@@ -13,7 +13,7 @@
# BRIEF: Validates CHANGELOG.md format and version consistency
# NOTE: Deployed to .github/workflows/changelog-validation.yml in governed repos.
name: Changelog Validation
name: "Universal: Changelog Validation"
on:
push:
@@ -15,7 +15,7 @@
# CodeQL does not support PHP directly; JavaScript scans JSON/YAML/shell.
# For PHP-specific security scanning see standards-compliance.yml.
name: CodeQL Security Scanning
name: "Universal: CodeQL Analysis"
on:
push:
@@ -4,7 +4,7 @@
# GitHub Actions workflow for Copilot coding agent
# This workflow demonstrates how to use the firewall configuration
name: Copilot Coding Agent
name: "MCP: Copilot Agent"
on:
pull_request:
@@ -26,7 +26,7 @@
# BRIEF: Enterprise firewall configuration — generates outbound allow-rules including SFTP deployment server
# NOTE: Reads DEV_FTP_HOST / DEV_FTP_PORT variables to include SFTP egress rules alongside HTTPS rules.
name: Enterprise Firewall Configuration
name: "MCP: Enterprise Firewall"
# This workflow provides firewall configuration guidance for enterprise-ready sites
# It generates firewall rules for allowing outbound access to trusted domains
+96
View File
@@ -0,0 +1,96 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/gitleaks.yml.template
# VERSION: 01.00.00
# BRIEF: Secret scanning — detect leaked credentials, API keys, and tokens
#
# +========================================================================+
# | SECRET SCANNING |
# +========================================================================+
# | |
# | Scans commits for leaked secrets using Gitleaks. |
# | |
# | - PR scan: only new commits in the PR |
# | - Scheduled: full repo scan weekly |
# | - Alerts via ntfy on findings |
# | |
# +========================================================================+
name: "Universal: Secret Scanning"
on:
pull_request:
branches:
- main
- 'dev/**'
schedule:
- cron: '0 5 * * 1' # Weekly Monday 05:00 UTC
workflow_dispatch:
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
jobs:
gitleaks:
name: Gitleaks Secret Scan
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install Gitleaks
run: |
GITLEAKS_VERSION="8.21.2"
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
| tar -xz -C /usr/local/bin gitleaks
gitleaks version
- name: Scan for secrets
id: scan
run: |
echo "### Secret Scanning" >> $GITHUB_STEP_SUMMARY
ARGS="--source . --verbose --report-format json --report-path /tmp/gitleaks-report.json"
if [ "${{ github.event_name }}" = "pull_request" ]; then
# Scan only PR commits
ARGS="$ARGS --log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
echo "Scanning PR commits only" >> $GITHUB_STEP_SUMMARY
else
echo "Full repository scan" >> $GITHUB_STEP_SUMMARY
fi
if gitleaks detect $ARGS 2>&1; then
echo "result=clean" >> "$GITHUB_OUTPUT"
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
else
echo "result=found" >> "$GITHUB_OUTPUT"
FINDINGS=$(jq length /tmp/gitleaks-report.json 2>/dev/null || echo "unknown")
echo "**${FINDINGS} potential secret(s) detected.**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Review the findings and rotate any exposed credentials immediately." >> $GITHUB_STEP_SUMMARY
exit 1
fi
- name: Notify on findings
if: failure() && steps.scan.outputs.result == 'found'
run: |
REPO="${{ github.event.repository.name }}"
curl -sS \
-H "Title: ${REPO} — secrets detected in code" \
-H "Tags: rotating_light,key" \
-H "Priority: urgent" \
-d "Gitleaks found potential secrets. Review and rotate credentials immediately." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
@@ -8,7 +8,7 @@
#
# This replaces the generic auto-release.yml for MCP server repos.
name: MCP Release
name: "MCP: Build & Release"
on:
push:
@@ -5,7 +5,7 @@
# Builds the MCP server, validates TypeScript compilation, and checks
# that tools are properly registered with valid Zod schemas.
name: MCP Build & Validate
name: "MCP: Build & Validate"
on:
push:
@@ -5,7 +5,7 @@
# Weekly check for MCP SDK updates. Creates an issue when a new version
# of @modelcontextprotocol/sdk is available.
name: MCP SDK Version Check
name: "MCP: SDK Version Check"
on:
schedule:
@@ -5,7 +5,7 @@
# Generates a tool inventory report on each push to main.
# Extracts tool names, descriptions, and parameter counts from src/index.ts.
name: MCP Tool Inventory
name: "MCP: Tool Inventory"
on:
push:
+71
View File
@@ -0,0 +1,71 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Notifications
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/notify.yml
# VERSION: 01.00.00
# BRIEF: Push notifications via ntfy on release success or workflow failure
name: "Universal: Notifications"
on:
workflow_run:
workflows:
- "Joomla Build & Release"
- "Joomla Extension CI"
- "Deploy"
- "Cascade Main → Dev"
types:
- completed
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-releases' }}
jobs:
notify:
name: Send Notification
runs-on: ubuntu-latest
if: >-
github.event.workflow_run.conclusion == 'success' ||
github.event.workflow_run.conclusion == 'failure'
steps:
- name: Notify on success (releases only)
if: >-
github.event.workflow_run.conclusion == 'success' &&
contains(github.event.workflow_run.name, 'Release')
run: |
REPO="${{ github.event.repository.name }}"
WORKFLOW="${{ github.event.workflow_run.name }}"
URL="${{ github.event.workflow_run.html_url }}"
curl -sS \
-H "Title: ${REPO} released" \
-H "Tags: white_check_mark,package" \
-H "Priority: default" \
-H "Click: ${URL}" \
-d "${WORKFLOW} completed successfully." \
"${NTFY_URL}/${NTFY_TOPIC}"
- name: Notify on failure
if: github.event.workflow_run.conclusion == 'failure'
run: |
REPO="${{ github.event.repository.name }}"
WORKFLOW="${{ github.event.workflow_run.name }}"
URL="${{ github.event.workflow_run.html_url }}"
curl -sS \
-H "Title: ${REPO} workflow failed" \
-H "Tags: x,warning" \
-H "Priority: high" \
-H "Click: ${URL}" \
-d "${WORKFLOW} failed. Check the run for details." \
"${NTFY_URL}/${NTFY_TOPIC}"
+194
View File
@@ -0,0 +1,194 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.CI
# REPO: https://git.mokoconsulting.tech/mokoconsulting-tech/MokoStandards-API
# PATH: /templates/workflows/universal/pr-check.yml.template
# VERSION: 05.00.00
# BRIEF: PR gate — branch policy + code validation before merge
name: "Universal: PR Check"
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ── Branch Policy ──────────────────────────────────────────────────────
branch-policy:
name: Branch Policy
runs-on: ubuntu-latest
steps:
- name: Check branch merge target
run: |
HEAD="${{ github.head_ref }}"
BASE="${{ github.base_ref }}"
echo "PR: ${HEAD} → ${BASE}"
ALLOWED=true
REASON=""
case "$HEAD" in
feature/*|feat/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Feature branches must target 'dev', not '${BASE}'"
fi
;;
fix/*|bugfix/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Fix branches must target 'dev', not '${BASE}'"
fi
;;
hotfix/*)
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
fi
;;
alpha/*|beta/*)
if [ "$BASE" != "dev" ]; then
ALLOWED=false
REASON="Pre-release branches must target 'dev', not '${BASE}'"
fi
;;
rc/*)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Release candidate branches must target 'main', not '${BASE}'"
fi
;;
dev)
if [ "$BASE" != "main" ]; then
ALLOWED=false
REASON="Dev branch can only merge into 'main', not '${BASE}'"
fi
;;
esac
if [ "$ALLOWED" = false ]; then
echo "::error::${REASON}"
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo "Branch policy: OK (${HEAD} → ${BASE})"
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
# ── Code Validation ────────────────────────────────────────────────────
validate:
name: Validate PR
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Detect platform
id: platform
run: |
PLATFORM=$(cat .moko-platform 2>/dev/null | tr -d '[:space:]')
[ -z "$PLATFORM" ] && PLATFORM="generic"
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
- name: Setup PHP
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
fi
- name: PHP syntax check
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
run: |
ERRORS=0
while IFS= read -r -d '' file; do
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
ERRORS=$((ERRORS + 1))
fi
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
echo "PHP lint: ${ERRORS} error(s)"
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
- name: Validate platform manifest
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
if [ -z "$MANIFEST" ]; then
echo "::warning::No Joomla manifest found (WaaS site)"
exit 0
fi
echo "Manifest: ${MANIFEST}"
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
fi
for ELEMENT in name version description; do
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
done
echo "Joomla manifest valid"
;;
dolibarr)
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
if [ -z "$MOD_FILE" ]; then
echo "::error::No mod*.class.php found"
exit 1
fi
echo "Dolibarr module: ${MOD_FILE}"
;;
*)
echo "Generic platform — no manifest validation"
;;
esac
- name: Check update stream format
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
case "$PLATFORM" in
joomla)
if [ -f "updates.xml" ]; then
if command -v php &> /dev/null; then
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
fi
echo "updates.xml valid"
fi
;;
dolibarr)
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
;;
esac
- name: Verify package source
run: |
SOURCE_DIR="src"
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
if [ ! -d "$SOURCE_DIR" ]; then
echo "::warning::No src/ or htdocs/ directory"
exit 0
fi
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
@@ -14,7 +14,7 @@
# NOTE: Synced via bulk-repo-sync to .github/workflows/repository-cleanup.yml in all governed repos.
# Runs on the 1st and 15th of each month at 6:00 AM UTC, and on manual dispatch.
name: Repository Cleanup
name: "Universal: Repository Cleanup"
on:
schedule:
+82
View File
@@ -0,0 +1,82 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Security
# REPO: https://git.mokoconsulting.tech/MokoConsulting/MokoStandards
# PATH: /.gitea/workflows/security-audit.yml
# VERSION: 01.00.00
# BRIEF: Dependency vulnerability scanning for composer and npm packages
name: "Universal: Security Audit"
on:
schedule:
- cron: '0 6 * * 1' # Weekly on Monday at 06:00 UTC
pull_request:
branches:
- main
paths:
- 'composer.json'
- 'composer.lock'
- 'package.json'
- 'package-lock.json'
workflow_dispatch:
permissions:
contents: read
env:
NTFY_URL: ${{ vars.NTFY_URL || 'https://ntfy.mokoconsulting.tech' }}
NTFY_TOPIC: ${{ vars.NTFY_TOPIC || 'gitea-security' }}
jobs:
audit:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Composer audit
if: hashFiles('composer.lock') != ''
run: |
echo "=== Composer Security Audit ==="
if ! command -v composer &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli composer >/dev/null 2>&1
fi
composer audit --format=plain 2>&1 | tee /tmp/composer-audit.txt
RESULT=$?
if [ $RESULT -ne 0 ]; then
echo "::warning::Composer vulnerabilities found"
echo "composer_vulnerable=true" >> "$GITHUB_ENV"
else
echo "No known vulnerabilities in composer dependencies"
fi
- name: NPM audit
if: hashFiles('package-lock.json') != ''
run: |
echo "=== NPM Security Audit ==="
npm audit --production 2>&1 | tee /tmp/npm-audit.txt || true
if npm audit --production 2>&1 | grep -q "found 0 vulnerabilities"; then
echo "No known vulnerabilities in npm dependencies"
else
echo "::warning::NPM vulnerabilities found"
echo "npm_vulnerable=true" >> "$GITHUB_ENV"
fi
- name: Notify on vulnerabilities
if: env.composer_vulnerable == 'true' || env.npm_vulnerable == 'true'
run: |
REPO="${{ github.event.repository.name }}"
curl -sS \
-H "Title: ${REPO} has vulnerable dependencies" \
-H "Tags: lock,warning" \
-H "Priority: high" \
-d "Security audit found vulnerabilities. Review dependency updates." \
"${NTFY_URL}/${NTFY_TOPIC}" || true
@@ -9,7 +9,7 @@
# BRIEF: MokoStandards compliance validation workflow
# NOTE: Validates repository structure, documentation, and coding standards
name: Standards Compliance
name: "MCP: Standards Compliance"
# ╔════════════════════════════════════════════════════════════════════════╗
# ║ MOKOSTANDARDS COMPLIANCE WORKFLOW ║
@@ -14,7 +14,7 @@
# NOTE: Synced via bulk-repo-sync to .github/workflows/sync-version-on-merge.yml in all governed repos.
# README.md is the single source of truth for the repository version.
name: Sync Version from README
name: "Universal: Sync Version on Merge"
on:
push: