Template
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:
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+1
-1
@@ -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:
|
||||
+1
-1
@@ -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
|
||||
@@ -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:
|
||||
+1
-1
@@ -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:
|
||||
@@ -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}"
|
||||
@@ -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; }
|
||||
+1
-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:
|
||||
@@ -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
|
||||
+1
-1
@@ -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 ║
|
||||
+1
-1
@@ -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:
|
||||
Reference in New Issue
Block a user