chore: Sync MokoStandards 04.04.00 #15
@@ -0,0 +1,20 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: MokoStandards.Templates.Config
|
||||
# INGROUP: MokoStandards.Templates
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/configs/moko-standards.yml
|
||||
# VERSION: 04.04.01
|
||||
# BRIEF: Governance attachment template — synced to .mokostandards in every governed repository
|
||||
# NOTE: Tokens replaced at sync time: mokoconsulting-tech, MokoJoomHero, waas-component, 04.04.00
|
||||
#
|
||||
# This file is managed automatically by MokoStandards bulk sync.
|
||||
# Do not edit manually — changes will be overwritten on the next sync.
|
||||
# To update governance settings, open a PR in MokoStandards instead:
|
||||
# https://github.com/mokoconsulting-tech/MokoStandards
|
||||
|
||||
standards_source: "https://github.com/mokoconsulting-tech/MokoStandards"
|
||||
standards_version: "04.04.00"
|
||||
platform: "waas-component"
|
||||
governed_repo: "mokoconsulting-tech/MokoJoomHero"
|
||||
@@ -3,7 +3,7 @@ name: Firewall Request
|
||||
about: Request firewall rule changes or access to external resources
|
||||
title: '[FIREWALL] [Resource Name] - [Brief Description]'
|
||||
labels: ['firewall-request', 'infrastructure', 'security']
|
||||
assignees: []
|
||||
assignees: ['jmiller-moko']
|
||||
---
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ name: Question
|
||||
about: Ask a question about usage, features, or best practices
|
||||
title: '[QUESTION] '
|
||||
labels: ['question']
|
||||
assignees: []
|
||||
assignees: ['jmiller-moko']
|
||||
---
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ name: License Request
|
||||
about: Request an organization license for Sublime Text
|
||||
title: '[LICENSE REQUEST] Sublime Text - [Your Name]'
|
||||
labels: ['license-request', 'admin']
|
||||
assignees: []
|
||||
assignees: ['jmiller-moko']
|
||||
---
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# 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
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: GitHub.Workflow
|
||||
# INGROUP: MokoStandards.Automation
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/workflows/shared/auto-dev-issue.yml
|
||||
# VERSION: 04.04.01
|
||||
# BRIEF: Auto-create tracking issue when a dev/** or rc/** branch is pushed
|
||||
# NOTE: Synced via bulk-repo-sync to .github/workflows/auto-dev-issue.yml in all governed repos.
|
||||
|
||||
name: Auto Dev Branch Issue
|
||||
|
||||
on:
|
||||
create:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
create-issue:
|
||||
name: Create version tracking issue
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event.ref_type == 'branch' &&
|
||||
(startsWith(github.event.ref, 'dev/') || startsWith(github.event.ref, 'rc/'))
|
||||
|
||||
steps:
|
||||
- name: Create tracking issue
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
run: |
|
||||
BRANCH="${{ github.event.ref }}"
|
||||
REPO="${{ github.repository }}"
|
||||
ACTOR="${{ github.actor }}"
|
||||
NOW=$(date -u '+%Y-%m-%d %H:%M UTC')
|
||||
|
||||
# Determine branch type and version
|
||||
if [[ "$BRANCH" == rc/* ]]; then
|
||||
VERSION="${BRANCH#rc/}"
|
||||
BRANCH_TYPE="Release Candidate"
|
||||
LABEL_TYPE="type: release"
|
||||
TITLE_PREFIX="rc"
|
||||
else
|
||||
VERSION="${BRANCH#dev/}"
|
||||
BRANCH_TYPE="Development"
|
||||
LABEL_TYPE="type: feature"
|
||||
TITLE_PREFIX="feat"
|
||||
fi
|
||||
|
||||
TITLE="${TITLE_PREFIX}(${VERSION}): ${BRANCH_TYPE} tracking for ${BRANCH}"
|
||||
|
||||
BODY="## ${BRANCH_TYPE} Branch Created
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Branch** | \`${BRANCH}\` |
|
||||
| **Version** | \`${VERSION}\` |
|
||||
| **Type** | ${BRANCH_TYPE} |
|
||||
| **Created by** | @${ACTOR} |
|
||||
| **Created at** | ${NOW} |
|
||||
| **Repository** | \`${REPO}\` |
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Feature development complete
|
||||
- [ ] Tests passing
|
||||
- [ ] README.md version bumped to \`${VERSION}\`
|
||||
- [ ] CHANGELOG.md updated
|
||||
- [ ] PR created targeting \`main\`
|
||||
- [ ] Code reviewed and approved
|
||||
- [ ] Merged to \`main\`
|
||||
|
||||
---
|
||||
*Auto-created by [auto-dev-issue.yml](.github/workflows/auto-dev-issue.yml) on branch creation.*"
|
||||
|
||||
# Dedent heredoc
|
||||
BODY=$(echo "$BODY" | sed 's/^ //')
|
||||
|
||||
# Check for existing issue with same title prefix
|
||||
EXISTING=$(gh api "repos/${REPO}/issues?state=open&per_page=5" \
|
||||
--jq ".[] | select(.title | startswith(\"${TITLE_PREFIX}(${VERSION})\")) | .number" 2>/dev/null | head -1)
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "ℹ️ Issue #${EXISTING} already exists for ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
ISSUE_URL=$(gh issue create \
|
||||
--repo "$REPO" \
|
||||
--title "$TITLE" \
|
||||
--body "$BODY" \
|
||||
--label "${LABEL_TYPE},version" \
|
||||
--assignee "jmiller-moko" 2>&1)
|
||||
echo "✅ Created tracking issue: ${ISSUE_URL}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -0,0 +1,439 @@
|
||||
# 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
|
||||
# VERSION: 04.04.01
|
||||
# BRIEF: Unified 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 (Dolibarr $this->version, Joomla <version>)║
|
||||
# ║ 4. Update [VERSION: XX.YY.ZZ] badges in markdown files ║
|
||||
# ║ 5. Write update.txt / update.xml ║
|
||||
# ║ 6. Create git tag vXX.YY.ZZ ║
|
||||
# ║ 7a. Patch: update existing GitHub Release for this minor ║
|
||||
# ║ ║
|
||||
# ║ Minor releases only (patch == 00): ║
|
||||
# ║ 2. Create/update version/XX.YY branch (patches update in-place) ║
|
||||
# ║ 7b. Create new GitHub Release ║
|
||||
# ║ ║
|
||||
# ╚════════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
name: Build & Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
|
||||
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.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}')
|
||||
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=version/${MINOR}" >> "$GITHUB_OUTPUT"
|
||||
echo "minor=$MINOR" >> "$GITHUB_OUTPUT"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
if [ "$PATCH" = "00" ]; then
|
||||
echo "is_minor=true" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Version: $VERSION (minor release — full pipeline)"
|
||||
else
|
||||
echo "is_minor=false" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ Version: $VERSION (patch — platform version + badges only)"
|
||||
fi
|
||||
|
||||
- name: Check if already released
|
||||
if: steps.version.outputs.skip != 'true'
|
||||
id: check
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.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: Platform-specific validation"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
PLATFORM=$(php /tmp/mokostandards/api/cli/platform_detect.php --path . 2>/dev/null)
|
||||
ERRORS=0
|
||||
|
||||
echo "## 🔍 Pre-Release Sanity Checks" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Platform: \`${PLATFORM}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Common checks
|
||||
if [ ! -f "LICENSE" ]; then
|
||||
echo "❌ Missing LICENSE file" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "✅ LICENSE" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [ ! -d "src" ]; then
|
||||
echo "⚠️ No src/ directory" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "✅ src/ directory" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Dolibarr-specific checks
|
||||
if [ "$PLATFORM" = "crm-module" ]; then
|
||||
MOD_FILE=$(find src htdocs -path "*/core/modules/mod*.class.php" -print -quit 2>/dev/null)
|
||||
if [ -z "$MOD_FILE" ]; then
|
||||
echo "❌ No module descriptor (src/core/modules/mod*.class.php)" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "✅ Module descriptor: \`${MOD_FILE}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Check module number
|
||||
NUMERO=$(grep -oP '\$this->numero\s*=\s*\K\d+' "$MOD_FILE" 2>/dev/null || echo "0")
|
||||
if [ "$NUMERO" = "0" ] || [ -z "$NUMERO" ]; then
|
||||
echo "❌ Module number (\$this->numero) is 0 or not set" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "✅ Module number: ${NUMERO}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Check url_last_version exists
|
||||
if grep -q 'url_last_version' "$MOD_FILE" 2>/dev/null; then
|
||||
echo "✅ url_last_version is set" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "⚠️ url_last_version not set — update checks won't work" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Joomla-specific checks
|
||||
if [ "$PLATFORM" = "waas-component" ]; then
|
||||
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "❌ No Joomla XML manifest found" >> $GITHUB_STEP_SUMMARY
|
||||
ERRORS=$((ERRORS+1))
|
||||
else
|
||||
echo "✅ Manifest: \`${MANIFEST}\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Check extension type
|
||||
TYPE=$(grep -oP '<extension[^>]+type="\K[^"]+' "$MANIFEST" 2>/dev/null)
|
||||
echo "✅ Extension type: ${TYPE:-unknown}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
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 branch ──────────────────
|
||||
- name: "Step 2: Version branch"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
IS_MINOR="${{ steps.version.outputs.is_minor }}"
|
||||
if [ "$IS_MINOR" = "true" ]; then
|
||||
git checkout -b "$BRANCH" 2>/dev/null || git checkout "$BRANCH"
|
||||
git push origin "$BRANCH" --force
|
||||
echo "🌿 Created branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
git push origin HEAD:"$BRANCH" --force
|
||||
echo "📝 Updated branch: ${BRANCH} (patch)" >> $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
|
||||
|
||||
# ── STEP 5: Write update files (Dolibarr: update.txt / Joomla: update.xml)
|
||||
- name: "Step 5: Write update files"
|
||||
if: >-
|
||||
steps.version.outputs.skip != 'true' &&
|
||||
steps.check.outputs.already_released != 'true'
|
||||
run: |
|
||||
PLATFORM=$(php /tmp/mokostandards/api/cli/platform_detect.php --path . 2>/dev/null)
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
REPO="${{ github.repository }}"
|
||||
|
||||
if [ "$PLATFORM" = "crm-module" ]; then
|
||||
printf '%s' "$VERSION" > update.txt
|
||||
echo "📦 update.txt: ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
if [ "$PLATFORM" = "waas-component" ]; then
|
||||
# ── Parse extension metadata from XML manifest ──────────────
|
||||
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
||||
if [ -z "$MANIFEST" ]; then
|
||||
echo "⚠️ No Joomla XML manifest found — skipping update.xml" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
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 || echo "")
|
||||
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 || echo "")
|
||||
PHP_MINIMUM=$(grep -oP '<php_minimum>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "")
|
||||
|
||||
# Derive element from manifest filename if not in XML
|
||||
if [ -z "$EXT_ELEMENT" ]; then
|
||||
EXT_ELEMENT=$(basename "$MANIFEST" .xml)
|
||||
fi
|
||||
|
||||
# Build client tag: plugins and frontend modules need <client>site</client>
|
||||
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
|
||||
|
||||
# Build folder tag for plugins (required for Joomla to match the update)
|
||||
FOLDER_TAG=""
|
||||
if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then
|
||||
FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
|
||||
fi
|
||||
|
||||
# Build targetplatform (fallback to Joomla 4+5 if not in manifest)
|
||||
if [ -z "$TARGET_PLATFORM" ]; then
|
||||
TARGET_PLATFORM='<targetplatform name="joomla" version="4.*" />'
|
||||
fi
|
||||
|
||||
# Build php_minimum tag
|
||||
PHP_TAG=""
|
||||
if [ -n "$PHP_MINIMUM" ]; then
|
||||
PHP_TAG="<php_minimum>${PHP_MINIMUM}</php_minimum>"
|
||||
fi
|
||||
|
||||
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/v${VERSION}/${EXT_ELEMENT}-${VERSION}.zip"
|
||||
INFO_URL="https://github.com/${REPO}/releases/tag/v${VERSION}"
|
||||
|
||||
# ── Write update.xml (stable release) ───────────────────────
|
||||
{
|
||||
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}\">${INFO_URL}</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}"
|
||||
[ -n "$PHP_TAG" ] && printf '%s\n' " ${PHP_TAG}"
|
||||
printf '%s\n' ' <maintainer>Moko Consulting</maintainer>'
|
||||
printf '%s\n' ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>'
|
||||
printf '%s\n' ' </update>'
|
||||
printf '%s\n' '</updates>'
|
||||
} > update.xml
|
||||
|
||||
echo "📦 update.xml: ${VERSION} (stable) — ${EXT_TYPE}/${EXT_ELEMENT}" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 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'
|
||||
run: |
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
git tag "$TAG"
|
||||
git push origin "$TAG"
|
||||
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 }}"
|
||||
TAG="${{ steps.version.outputs.tag }}"
|
||||
BRANCH="${{ steps.version.outputs.branch }}"
|
||||
IS_MINOR="${{ steps.version.outputs.is_minor }}"
|
||||
|
||||
# Derive the minor version base (XX.YY.00)
|
||||
MINOR_BASE=$(echo "$VERSION" | sed 's/\.[0-9]*$/.00/')
|
||||
MINOR_TAG="v${MINOR_BASE}"
|
||||
|
||||
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
|
||||
|
||||
if [ "$IS_MINOR" = "true" ]; then
|
||||
# Minor release: create new GitHub Release
|
||||
gh release create "$TAG" \
|
||||
--title "${VERSION}" \
|
||||
--notes-file /tmp/release_notes.md \
|
||||
--target "$BRANCH"
|
||||
echo "🚀 Release created: ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
# Patch release: update the existing minor release with new tag
|
||||
# Find the latest release for this minor version
|
||||
EXISTING=$(gh release view "$MINOR_TAG" --json tagName -q .tagName 2>/dev/null || true)
|
||||
if [ -n "$EXISTING" ]; then
|
||||
# Update existing release body with patch info
|
||||
CURRENT_NOTES=$(gh release view "$MINOR_TAG" --json body -q .body 2>/dev/null || true)
|
||||
{
|
||||
echo "$CURRENT_NOTES"
|
||||
echo ""
|
||||
echo "---"
|
||||
echo "### Patch ${VERSION}"
|
||||
echo ""
|
||||
cat /tmp/release_notes.md
|
||||
} > /tmp/updated_notes.md
|
||||
|
||||
gh release edit "$MINOR_TAG" \
|
||||
--title "${MINOR_BASE} (latest: ${VERSION})" \
|
||||
--notes-file /tmp/updated_notes.md
|
||||
echo "📝 Release updated: ${MINOR_BASE} → patch ${VERSION}" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
# No existing minor release found — create one for this patch
|
||||
gh release create "$TAG" \
|
||||
--title "${VERSION}" \
|
||||
--notes-file /tmp/release_notes.md
|
||||
echo "🚀 Release created: ${VERSION} (no minor release found)" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
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
|
||||
@@ -9,7 +9,7 @@
|
||||
# INGROUP: MokoStandards.Security
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/workflows/generic/codeql-analysis.yml
|
||||
# VERSION: 04.00.15
|
||||
# VERSION: 04.04.01
|
||||
# BRIEF: CodeQL security scanning workflow (generic — all repo types)
|
||||
# NOTE: Deployed to .github/workflows/codeql-analysis.yml in governed repos.
|
||||
# CodeQL does not support PHP directly; JavaScript scans JSON/YAML/shell.
|
||||
|
||||
@@ -0,0 +1,731 @@
|
||||
# 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
|
||||
# VERSION: 04.04.01
|
||||
# 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 .ftp_ignore file in the repository root. Each non-empty,
|
||||
# non-comment line is a regex pattern tested against the relative path
|
||||
# of each file (e.g. "subdir/file.txt"). The .gitignore is also
|
||||
# respected automatically.
|
||||
# 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-moko 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 gitignore-style glob line to an ERE pattern ──────────────
|
||||
ftp_ignore_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 .ftp_ignore (gitignore-style globs) ─────────────────────────
|
||||
IGNORE_PATTERNS=()
|
||||
IGNORE_SOURCES=()
|
||||
if [ -f ".ftp_ignore" ]; then
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
regex=$(ftp_ignore_to_regex "$line")
|
||||
[ -n "$regex" ] && IGNORE_PATTERNS+=("$regex") && IGNORE_SOURCES+=("$line")
|
||||
done < ".ftp_ignore"
|
||||
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 | .ftp_ignore \`${IGNORE_SOURCES[$i]}\`")
|
||||
SKIP=true; break
|
||||
fi
|
||||
done
|
||||
$SKIP && continue
|
||||
if [ -f ".gitignore" ]; then
|
||||
git check-ignore -q "$rel" 2>/dev/null && {
|
||||
IGNORED_FILES+=("$rel | .gitignore")
|
||||
continue
|
||||
} || true
|
||||
fi
|
||||
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"
|
||||
|
||||
# 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'
|
||||
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 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
|
||||
|
||||
# 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.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)
|
||||
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM='<targetplatform name="joomla" version="4.*" />'
|
||||
|
||||
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>'
|
||||
} > update.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
|
||||
|
||||
php /tmp/mokostandards/api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
||||
# (deploy-sftp.php handles dotfile skipping and .ftp_ignore natively)
|
||||
# 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'
|
||||
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-moko" \
|
||||
| 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
|
||||
@@ -22,7 +22,7 @@
|
||||
# INGROUP: MokoStandards.Deploy
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/workflows/shared/deploy-dev.yml
|
||||
# VERSION: 04.00.27
|
||||
# VERSION: 04.04.01
|
||||
# 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.
|
||||
@@ -35,8 +35,8 @@ name: Deploy to Dev Server (SFTP)
|
||||
#
|
||||
# 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: CUSTOM_NAME — when set, appended to DEV_FTP_PATH to form the
|
||||
# full remote destination: DEV_FTP_PATH/CUSTOM_NAME
|
||||
# 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 .ftp_ignore file in the repository root. Each non-empty,
|
||||
# non-comment line is a regex pattern tested against the relative path
|
||||
# of each file (e.g. "subdir/file.txt"). The .gitignore is also
|
||||
@@ -48,19 +48,23 @@ name: Deploy to Dev Server (SFTP)
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- 'dev/**'
|
||||
- 'rc/**'
|
||||
- develop
|
||||
- dev
|
||||
- development
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, closed]
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
- 'dev/**'
|
||||
- 'rc/**'
|
||||
- develop
|
||||
- dev
|
||||
- development
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'htdocs/**'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
clear_remote:
|
||||
@@ -73,6 +77,9 @@ permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
check-permission:
|
||||
name: Verify Deployment Permission
|
||||
@@ -89,52 +96,82 @@ jobs:
|
||||
REPO="${{ github.repository }}"
|
||||
ORG="${{ github.repository_owner }}"
|
||||
|
||||
# Try the per-repo collaborator endpoint first.
|
||||
# This returns 404 for org owners who are not listed as explicit
|
||||
# collaborators, so we fall back to the org membership role check.
|
||||
PERMISSION=$(gh api "repos/${REPO}/collaborators/${ACTOR}/permission" \
|
||||
--jq '.permission' 2>/dev/null)
|
||||
METHOD=""
|
||||
AUTHORIZED="false"
|
||||
|
||||
if [ -z "$PERMISSION" ]; then
|
||||
# Collaborator endpoint returned nothing — try org membership.
|
||||
# Requires a token with read:org scope (secrets.GH_TOKEN).
|
||||
# github.token alone is insufficient for this endpoint.
|
||||
ORG_ROLE=$(gh api "orgs/${ORG}/memberships/${ACTOR}" \
|
||||
--jq '.role' 2>/dev/null)
|
||||
if [ "$ORG_ROLE" = "owner" ]; then
|
||||
# Hardcoded authorized users — always allowed to deploy
|
||||
AUTHORIZED_USERS="jmiller-moko github-actions[bot]"
|
||||
for user in $AUTHORIZED_USERS; do
|
||||
if [ "$ACTOR" = "$user" ]; then
|
||||
AUTHORIZED="true"
|
||||
METHOD="hardcoded allowlist"
|
||||
PERMISSION="admin"
|
||||
echo "ℹ️ ${ACTOR} is an org owner — granting admin access"
|
||||
else
|
||||
# Both checks failed — token may lack read:org scope.
|
||||
echo "⚠️ Could not determine permission for ${ACTOR}."
|
||||
echo " Add GH_TOKEN (PAT with read:org scope) as an org secret to fix this."
|
||||
PERMISSION="none"
|
||||
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
|
||||
|
||||
case "$PERMISSION" in
|
||||
admin|maintain)
|
||||
echo "✅ ${ACTOR} has '${PERMISSION}' permission — authorized to deploy"
|
||||
;;
|
||||
*)
|
||||
echo "❌ Deployment requires admin or maintain role."
|
||||
echo " ${ACTOR} has '${PERMISSION}' — contact your org administrator."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
# 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: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
!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))
|
||||
github.event.pull_request.merged == true)))
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -143,16 +180,20 @@ jobs:
|
||||
- name: Resolve source directory
|
||||
id: source
|
||||
run: |
|
||||
SRC="src"
|
||||
if [ ! -d "$SRC" ]; then
|
||||
echo "⚠️ No src/ directory found — skipping deployment"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
# Resolve source directory: src/ preferred, htdocs/ as fallback
|
||||
if [ -d "src" ]; then
|
||||
SRC="src"
|
||||
elif [ -d "htdocs" ]; then
|
||||
SRC="htdocs"
|
||||
else
|
||||
COUNT=$(find "$SRC" -maxdepth 0 -type d > /dev/null && find "$SRC" -type f | wc -l)
|
||||
echo "✅ Source: src/ (${COUNT} file(s))"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "dir=${SRC}" >> "$GITHUB_OUTPUT"
|
||||
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'
|
||||
@@ -214,9 +255,11 @@ jobs:
|
||||
fi
|
||||
done
|
||||
$SKIP && continue
|
||||
if [ -f ".gitignore" ] && git check-ignore -q "$rel" 2>/dev/null; then
|
||||
IGNORED_FILES+=("$rel | .gitignore")
|
||||
continue
|
||||
if [ -f ".gitignore" ]; then
|
||||
git check-ignore -q "$rel" 2>/dev/null && {
|
||||
IGNORED_FILES+=("$rel | .gitignore")
|
||||
continue
|
||||
} || true
|
||||
fi
|
||||
WILL_UPLOAD+=("$rel")
|
||||
done < <(find "$SOURCE_DIR" -type f -print0 | sort -z)
|
||||
@@ -290,7 +333,7 @@ jobs:
|
||||
id: remote
|
||||
env:
|
||||
DEV_FTP_PATH: ${{ vars.DEV_FTP_PATH }}
|
||||
CUSTOM_NAME: ${{ vars.CUSTOM_NAME }}
|
||||
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
run: |
|
||||
BASE="$DEV_FTP_PATH"
|
||||
|
||||
@@ -301,17 +344,22 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Path format: DEV_FTP_PATH/CUSTOM_NAME (CUSTOM_NAME is optional)
|
||||
if [ -n "$CUSTOM_NAME" ]; then
|
||||
REMOTE="${BASE%/}/${CUSTOM_NAME#/}"
|
||||
else
|
||||
REMOTE="$BASE"
|
||||
# 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=""
|
||||
if [ -f ".moko-standards" ]; then
|
||||
PLATFORM=$(grep -E '^platform:' .moko-standards | sed 's/.*:[[:space:]]*//' | tr -d '"')
|
||||
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
|
||||
@@ -320,7 +368,7 @@ jobs:
|
||||
if [[ "$REMOTE" != *custom* ]]; then
|
||||
echo "❌ Safety check failed: Dolibarr (crm-module) remote path must contain 'custom'."
|
||||
echo " Current path: ${REMOTE}"
|
||||
echo " Set CUSTOM_NAME to the module's htdocs/custom/ subdirectory."
|
||||
echo " Set DEV_FTP_SUFFIX to the module's htdocs/custom/ subdirectory."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
@@ -330,7 +378,7 @@ jobs:
|
||||
if [[ "$REMOTE" != *tmp* ]]; then
|
||||
echo "❌ Safety check failed: Joomla (waas-component) remote path must contain 'tmp'."
|
||||
echo " Current path: ${REMOTE}"
|
||||
echo " Set CUSTOM_NAME to a path under the server tmp/ directory."
|
||||
echo " Set DEV_FTP_SUFFIX to a path under the server tmp/ directory."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
@@ -339,7 +387,7 @@ jobs:
|
||||
echo "path=${REMOTE}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Detect SFTP authentication method
|
||||
if: steps.source.outputs.skip == 'false'
|
||||
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
|
||||
id: auth
|
||||
env:
|
||||
HAS_KEY: ${{ secrets.DEV_FTP_KEY }}
|
||||
@@ -371,26 +419,29 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Setup PHP
|
||||
if: steps.source.outputs.skip == 'false'
|
||||
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.31.0
|
||||
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'
|
||||
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 --quiet \
|
||||
git clone --depth 1 --branch version/04.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
|
||||
if: steps.source.outputs.skip == 'false'
|
||||
- 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 }}
|
||||
@@ -492,7 +543,7 @@ jobs:
|
||||
php /tmp/moko_clear.php
|
||||
|
||||
- name: Deploy via SFTP
|
||||
if: steps.source.outputs.skip == 'false'
|
||||
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
|
||||
env:
|
||||
SFTP_HOST: ${{ steps.conn.outputs.host }}
|
||||
SFTP_PORT: ${{ steps.conn.outputs.port }}
|
||||
@@ -537,6 +588,79 @@ jobs:
|
||||
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)
|
||||
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM='<targetplatform name="joomla" version="4.*" />'
|
||||
|
||||
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>'
|
||||
} > update.xml
|
||||
sed -i '/^[[:space:]]*$/d' update.xml
|
||||
fi
|
||||
fi
|
||||
|
||||
php /tmp/mokostandards/api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
||||
# (deploy-sftp.php handles dotfile skipping and .ftp_ignore natively)
|
||||
# Remove temp files that should never be left behind
|
||||
@@ -585,7 +709,7 @@ jobs:
|
||||
--force 2>/dev/null || true
|
||||
|
||||
# Look for an existing open deploy-failure issue
|
||||
EXISTING=$(gh api "repos/${REPO}/issues?labels=${LABEL}&state=open&per_page=1" \
|
||||
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
|
||||
@@ -593,14 +717,16 @@ jobs:
|
||||
-X PATCH \
|
||||
-f title="$TITLE" \
|
||||
-f body="$BODY" \
|
||||
-f state="open" \
|
||||
--silent
|
||||
echo "📋 Failure issue #${EXISTING} updated: ${REPO}" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "📋 Failure issue #${EXISTING} updated/reopened: ${REPO}" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
gh issue create \
|
||||
--repo "$REPO" \
|
||||
--title "$TITLE" \
|
||||
--body "$BODY" \
|
||||
--label "$LABEL" \
|
||||
--assignee "jmiller-moko" \
|
||||
| tee -a "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,659 @@
|
||||
# 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-rs.yml
|
||||
# VERSION: 04.04.01
|
||||
# BRIEF: SFTP deployment workflow for release staging server — synced to all governed repos
|
||||
# NOTE: Synced via bulk-repo-sync to .github/workflows/deploy-rs.yml in all governed repos.
|
||||
# Port is resolved in order: RS_FTP_PORT variable → :port suffix in RS_FTP_HOST → 22.
|
||||
|
||||
name: Deploy to RS Server (SFTP)
|
||||
|
||||
# Deploys the contents of the src/ directory to the release staging server via SFTP.
|
||||
# Triggers on push/merge to main — deploys the production-ready build to the release staging server.
|
||||
#
|
||||
# Required org-level variables: RS_FTP_HOST, RS_FTP_PATH, RS_FTP_USERNAME
|
||||
# Optional org-level variable: RS_FTP_PORT (auto-detected from host or defaults to 22)
|
||||
# Optional org/repo variable: RS_FTP_SUFFIX — when set, appended to RS_FTP_PATH to form the
|
||||
# full remote destination: RS_FTP_PATH/RS_FTP_SUFFIX
|
||||
# Ignore rules: Place a .ftp_ignore file in the repository root. Each non-empty,
|
||||
# non-comment line is a regex pattern tested against the relative path
|
||||
# of each file (e.g. "subdir/file.txt"). The .gitignore is also
|
||||
# respected automatically.
|
||||
# Required org-level secret: RS_FTP_KEY (preferred) or RS_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-moko 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 → RS
|
||||
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 gitignore-style glob line to an ERE pattern ──────────────
|
||||
ftp_ignore_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 .ftp_ignore (gitignore-style globs) ─────────────────────────
|
||||
IGNORE_PATTERNS=()
|
||||
IGNORE_SOURCES=()
|
||||
if [ -f ".ftp_ignore" ]; then
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
regex=$(ftp_ignore_to_regex "$line")
|
||||
[ -n "$regex" ] && IGNORE_PATTERNS+=("$regex") && IGNORE_SOURCES+=("$line")
|
||||
done < ".ftp_ignore"
|
||||
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 | .ftp_ignore \`${IGNORE_SOURCES[$i]}\`")
|
||||
SKIP=true; break
|
||||
fi
|
||||
done
|
||||
$SKIP && continue
|
||||
if [ -f ".gitignore" ]; then
|
||||
git check-ignore -q "$rel" 2>/dev/null && {
|
||||
IGNORED_FILES+=("$rel | .gitignore")
|
||||
continue
|
||||
} || true
|
||||
fi
|
||||
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.RS_FTP_HOST }}
|
||||
PORT_VAR: ${{ vars.RS_FTP_PORT }}
|
||||
run: |
|
||||
HOST="$HOST_RAW"
|
||||
PORT="$PORT_VAR"
|
||||
|
||||
# Priority 1 — explicit RS_FTP_PORT variable
|
||||
if [ -n "$PORT" ]; then
|
||||
echo "ℹ️ Using explicit RS_FTP_PORT=${PORT}"
|
||||
|
||||
# Priority 2 — port embedded in RS_FTP_HOST (host:port)
|
||||
elif [[ "$HOST" == *:* ]]; then
|
||||
PORT="${HOST##*:}"
|
||||
HOST="${HOST%:*}"
|
||||
echo "ℹ️ Extracted port ${PORT} from RS_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:
|
||||
RS_FTP_PATH: ${{ vars.RS_FTP_PATH }}
|
||||
RS_FTP_SUFFIX: ${{ vars.RS_FTP_SUFFIX }}
|
||||
run: |
|
||||
BASE="$RS_FTP_PATH"
|
||||
|
||||
if [ -z "$BASE" ]; then
|
||||
echo "❌ RS_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
|
||||
|
||||
# RS_FTP_SUFFIX is required — it identifies the remote subdirectory for this repo.
|
||||
# Without it we cannot safely determine the deployment target.
|
||||
if [ -z "$RS_FTP_SUFFIX" ]; then
|
||||
echo "⏭️ RS_FTP_SUFFIX variable is not set — skipping deployment."
|
||||
echo " Set RS_FTP_SUFFIX as a repo or org variable to enable deploy-rs."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "path=" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REMOTE="${BASE%/}/${RS_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
|
||||
|
||||
# RS deployment: no path restrictions for any platform
|
||||
|
||||
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.RS_FTP_KEY }}
|
||||
HAS_PASSWORD: ${{ secrets.RS_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 (RS_FTP_KEY / RS_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 (RS_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 (RS_FTP_PASSWORD)"
|
||||
else
|
||||
echo "❌ No SFTP credentials configured."
|
||||
echo " Set RS_FTP_KEY (preferred) or RS_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.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.RS_FTP_USERNAME }}
|
||||
SFTP_KEY: ${{ secrets.RS_FTP_KEY }}
|
||||
SFTP_PASSWORD: ${{ secrets.RS_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.RS_FTP_USERNAME }}
|
||||
SFTP_KEY: ${{ secrets.RS_FTP_KEY }}
|
||||
SFTP_PASSWORD: ${{ secrets.RS_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
|
||||
|
||||
# ── 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
|
||||
|
||||
php /tmp/mokostandards/api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
||||
# (deploy-sftp.php handles dotfile skipping and .ftp_ignore natively)
|
||||
# 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'
|
||||
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: RS deployment failed — ${REPO}"
|
||||
BODY="## RS Deployment Failed
|
||||
|
||||
A deployment to the RS 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 RS Server → Run workflow**.
|
||||
|
||||
---
|
||||
*Auto-created by deploy-rs.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 deploy-failure issue (any state — reopen if closed)
|
||||
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-moko" \
|
||||
| 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 "### ✅ RS 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 "### ❌ RS Deployment Failed" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Check the job log above for error details." >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
@@ -22,7 +22,7 @@
|
||||
# INGROUP: MokoStandards.Firewall
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/workflows/shared/enterprise-firewall-setup.yml
|
||||
# VERSION: 01.00.00
|
||||
# VERSION: 04.04.01
|
||||
# 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.
|
||||
|
||||
|
||||
@@ -0,0 +1,795 @@
|
||||
# ============================================================================
|
||||
# Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# This file is part of a Moko Consulting project.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: GitHub.Workflow
|
||||
# INGROUP: MokoStandards.Validation
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /.github/workflows/repo_health.yml
|
||||
# VERSION: 04.04.01
|
||||
# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts.
|
||||
# NOTE: Field is user-managed.
|
||||
# ============================================================================
|
||||
|
||||
name: Repo Health
|
||||
|
||||
concurrency:
|
||||
group: repo-health-${{ github.repository }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
description: 'Validation profile: all, release, scripts, or repo'
|
||||
required: true
|
||||
default: all
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- release
|
||||
- scripts
|
||||
- repo
|
||||
pull_request:
|
||||
push:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# Release policy - Repository Variables Only
|
||||
RELEASE_REQUIRED_REPO_VARS: RS_FTP_PATH_SUFFIX
|
||||
RELEASE_OPTIONAL_REPO_VARS: DEV_FTP_SUFFIX
|
||||
|
||||
# Scripts governance policy
|
||||
# Note: directories listed without a trailing slash.
|
||||
SCRIPTS_REQUIRED_DIRS:
|
||||
SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate
|
||||
|
||||
# Repo health policy
|
||||
# Files are listed as-is; directories must end with a trailing slash.
|
||||
REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.github/workflows/
|
||||
REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/
|
||||
REPO_DISALLOWED_DIRS:
|
||||
REPO_DISALLOWED_FILES: TODO.md,todo.md
|
||||
|
||||
# Extended checks toggles
|
||||
EXTENDED_CHECKS: "true"
|
||||
|
||||
# File / directory variables (moved to top-level env)
|
||||
DOCS_INDEX: docs/docs-index.md
|
||||
SCRIPT_DIR: scripts
|
||||
WORKFLOWS_DIR: .github/workflows
|
||||
SHELLCHECK_PATTERN: '*.sh'
|
||||
SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml'
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
access_check:
|
||||
name: Access control
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
outputs:
|
||||
allowed: ${{ steps.perm.outputs.allowed }}
|
||||
permission: ${{ steps.perm.outputs.permission }}
|
||||
|
||||
steps:
|
||||
- name: Check actor permission (admin only)
|
||||
id: perm
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ secrets.GH_TOKEN }}
|
||||
script: |
|
||||
const actor = context.actor;
|
||||
let permission = "unknown";
|
||||
let allowed = false;
|
||||
let method = "";
|
||||
|
||||
// Hardcoded authorized users — always allowed
|
||||
const authorizedUsers = ["jmiller-moko", "github-actions[bot]"];
|
||||
if (authorizedUsers.includes(actor)) {
|
||||
allowed = true;
|
||||
permission = "admin";
|
||||
method = "hardcoded allowlist";
|
||||
} else {
|
||||
// Check via API for other actors
|
||||
try {
|
||||
const res = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username: actor,
|
||||
});
|
||||
permission = (res?.data?.permission || "unknown").toLowerCase();
|
||||
allowed = permission === "admin" || permission === "maintain";
|
||||
method = "repo collaborator API";
|
||||
} catch (error) {
|
||||
core.warning(`Could not fetch permissions for '${actor}': ${error.message}`);
|
||||
permission = "unknown";
|
||||
allowed = false;
|
||||
method = "API error";
|
||||
}
|
||||
}
|
||||
|
||||
core.setOutput("permission", permission);
|
||||
core.setOutput("allowed", allowed ? "true" : "false");
|
||||
|
||||
const lines = [
|
||||
"## 🔐 Access Authorization",
|
||||
"",
|
||||
"| Field | Value |",
|
||||
"|-------|-------|",
|
||||
`| **Actor** | \`${actor}\` |`,
|
||||
`| **Repository** | \`${context.repo.owner}/${context.repo.repo}\` |`,
|
||||
`| **Permission** | \`${permission}\` |`,
|
||||
`| **Method** | ${method} |`,
|
||||
`| **Authorized** | ${allowed} |`,
|
||||
`| **Trigger** | \`${context.eventName}\` |`,
|
||||
`| **Branch** | \`${context.ref.replace('refs/heads/', '')}\` |`,
|
||||
"",
|
||||
allowed
|
||||
? `✅ ${actor} authorized (${method})`
|
||||
: `❌ ${actor} is NOT authorized. Requires admin or maintain role, or be in the hardcoded allowlist.`,
|
||||
];
|
||||
|
||||
await core.summary.addRaw(lines.join("\n")).write();
|
||||
|
||||
- name: Deny execution when not permitted
|
||||
if: ${{ steps.perm.outputs.allowed != 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
|
||||
release_config:
|
||||
name: Release configuration
|
||||
needs: access_check
|
||||
if: ${{ needs.access_check.outputs.allowed == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Guardrails release vars
|
||||
env:
|
||||
PROFILE_RAW: ${{ github.event.inputs.profile }}
|
||||
RS_FTP_PATH_SUFFIX: ${{ vars.RS_FTP_PATH_SUFFIX }}
|
||||
DEV_FTP_SUFFIX: ${{ vars.DEV_FTP_SUFFIX }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Release configuration (Repository Variables)'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' 'Status: SKIPPED'
|
||||
printf '%s\n' 'Reason: profile excludes release validation'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_REPO_VARS}"
|
||||
IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_REPO_VARS}"
|
||||
|
||||
missing=()
|
||||
missing_optional=()
|
||||
|
||||
for k in "${required[@]}"; do
|
||||
v="${!k:-}"
|
||||
[ -z "${v}" ] && missing+=("${k}")
|
||||
done
|
||||
|
||||
for k in "${optional[@]}"; do
|
||||
v="${!k:-}"
|
||||
[ -z "${v}" ] && missing_optional+=("${k}")
|
||||
done
|
||||
|
||||
{
|
||||
printf '%s\n' '### Release configuration (Repository Variables)'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' '| Variable | Status |'
|
||||
printf '%s\n' '|---|---|'
|
||||
printf '%s\n' "| RS_FTP_PATH_SUFFIX | ${RS_FTP_PATH_SUFFIX:-NOT SET} |"
|
||||
printf '%s\n' "| DEV_FTP_SUFFIX | ${DEV_FTP_SUFFIX:-NOT SET} |"
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
if [ "${#missing_optional[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing optional repository variables'
|
||||
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
if [ "${#missing[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing required repository variables'
|
||||
for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository variables.'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
{
|
||||
printf '%s\n' '### Repository variables validation result'
|
||||
printf '%s\n' 'Status: OK'
|
||||
printf '%s\n' 'All required repository variables present.'
|
||||
printf '%s\n' ''
|
||||
printf '%s\n' '**Note**: Organization secrets (RS_FTP_HOST, RS_FTP_USER, etc.) are validated at deployment time, not in repository health checks.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
scripts_governance:
|
||||
name: Scripts governance
|
||||
needs: access_check
|
||||
if: ${{ needs.access_check.outputs.allowed == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Scripts folder checks
|
||||
env:
|
||||
PROFILE_RAW: ${{ github.event.inputs.profile }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' 'Status: SKIPPED'
|
||||
printf '%s\n' 'Reason: profile excludes scripts governance'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -d "${SCRIPT_DIR}" ]; then
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' 'Status: OK (advisory)'
|
||||
printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}"
|
||||
IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}"
|
||||
|
||||
missing_dirs=()
|
||||
unapproved_dirs=()
|
||||
|
||||
for d in "${required_dirs[@]}"; do
|
||||
req="${d%/}"
|
||||
[ ! -d "${req}" ] && missing_dirs+=("${req}/")
|
||||
done
|
||||
|
||||
while IFS= read -r d; do
|
||||
allowed=false
|
||||
for a in "${allowed_dirs[@]}"; do
|
||||
a_norm="${a%/}"
|
||||
[ "${d%/}" = "${a_norm}" ] && allowed=true
|
||||
done
|
||||
[ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/")
|
||||
done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##')
|
||||
|
||||
{
|
||||
printf '%s\n' '### Scripts governance'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' '| Area | Status | Notes |'
|
||||
printf '%s\n' '|---|---|---|'
|
||||
|
||||
if [ "${#missing_dirs[@]}" -gt 0 ]; then
|
||||
printf '%s\n' '| Required directories | Warning | Missing required subfolders |'
|
||||
else
|
||||
printf '%s\n' '| Required directories | OK | All required subfolders present |'
|
||||
fi
|
||||
|
||||
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
|
||||
printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |'
|
||||
else
|
||||
printf '%s\n' '| Directory policy | OK | No unapproved directories |'
|
||||
fi
|
||||
|
||||
printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |'
|
||||
printf '\n'
|
||||
|
||||
if [ "${#missing_dirs[@]}" -gt 0 ]; then
|
||||
printf '%s\n' 'Missing required script directories:'
|
||||
for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
else
|
||||
printf '%s\n' 'Missing required script directories: none.'
|
||||
printf '\n'
|
||||
fi
|
||||
|
||||
if [ "${#unapproved_dirs[@]}" -gt 0 ]; then
|
||||
printf '%s\n' 'Unapproved script directories detected:'
|
||||
for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
else
|
||||
printf '%s\n' 'Unapproved script directories detected: none.'
|
||||
printf '\n'
|
||||
fi
|
||||
|
||||
printf '%s\n' 'Scripts governance completed in advisory mode.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
repo_health:
|
||||
name: Repository health
|
||||
needs: access_check
|
||||
if: ${{ needs.access_check.outputs.allowed == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Repository health checks
|
||||
env:
|
||||
PROFILE_RAW: ${{ github.event.inputs.profile }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
profile="${PROFILE_RAW:-all}"
|
||||
case "${profile}" in
|
||||
all|release|scripts|repo) ;;
|
||||
*)
|
||||
printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then
|
||||
{
|
||||
printf '%s\n' '### Repository health'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' 'Status: SKIPPED'
|
||||
printf '%s\n' 'Reason: profile excludes repository health'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Source directory: src/ or htdocs/ (either is valid)
|
||||
if [ -d "src" ]; then
|
||||
SOURCE_DIR="src"
|
||||
elif [ -d "htdocs" ]; then
|
||||
SOURCE_DIR="htdocs"
|
||||
else
|
||||
missing_required+=("src/ or htdocs/ (source directory required)")
|
||||
fi
|
||||
|
||||
IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}"
|
||||
IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}"
|
||||
IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}"
|
||||
IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES}"
|
||||
|
||||
missing_required=()
|
||||
missing_optional=()
|
||||
|
||||
for item in "${required_artifacts[@]}"; do
|
||||
if printf '%s' "${item}" | grep -q '/$'; then
|
||||
d="${item%/}"
|
||||
[ ! -d "${d}" ] && missing_required+=("${item}")
|
||||
else
|
||||
[ ! -f "${item}" ] && missing_required+=("${item}")
|
||||
fi
|
||||
done
|
||||
|
||||
# Optional entries: handle files and directories (trailing slash indicates dir)
|
||||
for f in "${optional_files[@]}"; do
|
||||
if printf '%s' "${f}" | grep -q '/$'; then
|
||||
d="${f%/}"
|
||||
[ ! -d "${d}" ] && missing_optional+=("${f}")
|
||||
else
|
||||
[ ! -f "${f}" ] && missing_optional+=("${f}")
|
||||
fi
|
||||
done
|
||||
|
||||
for d in "${disallowed_dirs[@]}"; do
|
||||
d_norm="${d%/}"
|
||||
[ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)")
|
||||
done
|
||||
|
||||
for f in "${disallowed_files[@]}"; do
|
||||
[ -f "${f}" ] && missing_required+=("${f} (disallowed)")
|
||||
done
|
||||
|
||||
git fetch origin --prune
|
||||
|
||||
dev_paths=()
|
||||
dev_branches=()
|
||||
|
||||
# Look for remote branches matching origin/dev*.
|
||||
# A plain origin/dev is considered invalid; we require dev/<something> branches.
|
||||
while IFS= read -r b; do
|
||||
name="${b#origin/}"
|
||||
if [ "${name}" = 'dev' ]; then
|
||||
dev_branches+=("${name}")
|
||||
else
|
||||
dev_paths+=("${name}")
|
||||
fi
|
||||
done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//')
|
||||
|
||||
# If there are no dev/* branches, fail the guardrail.
|
||||
if [ "${#dev_paths[@]}" -eq 0 ]; then
|
||||
missing_required+=("dev/* branch (e.g. dev/01.00.00)")
|
||||
fi
|
||||
|
||||
# If a plain dev branch exists (origin/dev), flag it as invalid.
|
||||
if [ "${#dev_branches[@]}" -gt 0 ]; then
|
||||
missing_required+=("invalid branch dev (must be dev/<version>)")
|
||||
fi
|
||||
|
||||
content_warnings=()
|
||||
|
||||
if [ -f 'CHANGELOG.md' ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then
|
||||
content_warnings+=("CHANGELOG.md missing '# Changelog' header")
|
||||
fi
|
||||
|
||||
if [ -f 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then
|
||||
content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)")
|
||||
fi
|
||||
|
||||
if [ -f 'LICENSE' ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then
|
||||
content_warnings+=("LICENSE does not look like a GPL text")
|
||||
fi
|
||||
|
||||
if [ -f 'README.md' ] && ! grep -qiE 'moko|Moko' README.md; then
|
||||
content_warnings+=("README.md missing expected brand keyword")
|
||||
fi
|
||||
|
||||
export PROFILE_RAW="${profile}"
|
||||
export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")"
|
||||
export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")"
|
||||
export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")"
|
||||
|
||||
report_json="$(python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
|
||||
profile = os.environ.get('PROFILE_RAW') or 'all'
|
||||
|
||||
missing_required = os.environ.get('MISSING_REQUIRED', '').splitlines() if os.environ.get('MISSING_REQUIRED') else []
|
||||
missing_optional = os.environ.get('MISSING_OPTIONAL', '').splitlines() if os.environ.get('MISSING_OPTIONAL') else []
|
||||
content_warnings = os.environ.get('CONTENT_WARNINGS', '').splitlines() if os.environ.get('CONTENT_WARNINGS') else []
|
||||
|
||||
out = {
|
||||
'profile': profile,
|
||||
'missing_required': [x for x in missing_required if x],
|
||||
'missing_optional': [x for x in missing_optional if x],
|
||||
'content_warnings': [x for x in content_warnings if x],
|
||||
}
|
||||
|
||||
print(json.dumps(out, indent=2))
|
||||
PY
|
||||
)"
|
||||
|
||||
{
|
||||
printf '%s\n' '### Repository health'
|
||||
printf '%s\n' "Profile: ${profile}"
|
||||
printf '%s\n' '| Metric | Value |'
|
||||
printf '%s\n' '|---|---|'
|
||||
printf '%s\n' "| Missing required | ${#missing_required[@]} |"
|
||||
printf '%s\n' "| Missing optional | ${#missing_optional[@]} |"
|
||||
printf '%s\n' "| Content warnings | ${#content_warnings[@]} |"
|
||||
printf '\n'
|
||||
|
||||
printf '%s\n' '### Guardrails report (JSON)'
|
||||
printf '%s\n' '```json'
|
||||
printf '%s\n' "${report_json}"
|
||||
printf '%s\n' '```'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
if [ "${#missing_required[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing required repo artifacts'
|
||||
for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${#missing_optional[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Missing optional repo artifacts'
|
||||
for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
if [ "${#content_warnings[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Repo content warnings'
|
||||
for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
# ── Joomla-specific checks ───────────────────────────────────────
|
||||
joomla_findings=()
|
||||
|
||||
# XML manifest: find any XML file containing <extension
|
||||
MANIFEST="$(find . -maxdepth 2 -name '*.xml' -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)"
|
||||
if [ -z "${MANIFEST}" ]; then
|
||||
joomla_findings+=("Joomla XML manifest not found (no *.xml with <extension> tag)")
|
||||
else
|
||||
# Check <version> tag exists
|
||||
if ! grep -qP '<version>' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <version> tag missing")
|
||||
fi
|
||||
# Check extension type attribute
|
||||
if ! grep -qP 'type="(component|module|plugin|library|package|template|language)"' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: type attribute missing or invalid")
|
||||
fi
|
||||
# Check <name> tag
|
||||
if ! grep -qP '<name>' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <name> tag missing")
|
||||
fi
|
||||
# Check <author> tag
|
||||
if ! grep -qP '<author>' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <author> tag missing")
|
||||
fi
|
||||
# Check <namespace> for Joomla 4+
|
||||
if ! grep -qP '<namespace' "${MANIFEST}"; then
|
||||
joomla_findings+=("XML manifest: <namespace> missing (required for Joomla 4+)")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Language files: check for at least one .ini file
|
||||
INI_COUNT="$(find . -name '*.ini' -type f 2>/dev/null | wc -l)"
|
||||
if [ "${INI_COUNT}" -eq 0 ]; then
|
||||
joomla_findings+=("No .ini language files found")
|
||||
fi
|
||||
|
||||
# update.xml must exist in root (Joomla update server)
|
||||
if [ ! -f 'update.xml' ]; then
|
||||
joomla_findings+=("update.xml missing in root (required for Joomla update server)")
|
||||
fi
|
||||
|
||||
# index.html files for directory listing protection
|
||||
INDEX_DIRS=("${SOURCE_DIR}" "${SOURCE_DIR}/admin" "${SOURCE_DIR}/site")
|
||||
for dir in "${INDEX_DIRS[@]}"; do
|
||||
if [ -d "${dir}" ] && [ ! -f "${dir}/index.html" ]; then
|
||||
joomla_findings+=("${dir}/index.html missing (directory listing protection)")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "${#joomla_findings[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Joomla extension checks'
|
||||
printf '%s\n' '| Check | Status |'
|
||||
printf '%s\n' '|---|---|'
|
||||
for f in "${joomla_findings[@]}"; do
|
||||
printf '%s\n' "| ${f} | Warning |"
|
||||
done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
else
|
||||
{
|
||||
printf '%s\n' '### Joomla extension checks'
|
||||
printf '%s\n' 'All Joomla-specific checks passed.'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
extended_enabled="${EXTENDED_CHECKS:-true}"
|
||||
extended_findings=()
|
||||
|
||||
if [ "${extended_enabled}" = 'true' ]; then
|
||||
# CODEOWNERS presence
|
||||
if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then
|
||||
:
|
||||
else
|
||||
extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)")
|
||||
fi
|
||||
|
||||
# Workflow pinning advisory: flag uses @main/@master
|
||||
if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then
|
||||
bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)"
|
||||
if [ -n "${bad_refs}" ]; then
|
||||
extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt")
|
||||
{
|
||||
printf '%s\n' '### Workflow pinning advisory'
|
||||
printf '%s\n' 'Found uses: entries pinned to main/master:'
|
||||
printf '%s\n' '```'
|
||||
printf '%s\n' "${bad_refs}"
|
||||
printf '%s\n' '```'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Docs index link integrity (docs/docs-index.md)
|
||||
if [ -f "${DOCS_INDEX}" ]; then
|
||||
missing_links="$(python3 - <<'PY'
|
||||
import os
|
||||
import re
|
||||
|
||||
idx = os.environ.get('DOCS_INDEX', 'docs/docs-index.md')
|
||||
base = os.getcwd()
|
||||
|
||||
bad = []
|
||||
pat = re.compile(r'\[[^\]]+\]\(([^)]+)\)')
|
||||
|
||||
with open(idx, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
for m in pat.findall(line):
|
||||
link = m.strip()
|
||||
if link.startswith('http://') or link.startswith('https://') or link.startswith('#') or link.startswith('mailto:'):
|
||||
continue
|
||||
if link.startswith('/'):
|
||||
rel = link.lstrip('/')
|
||||
else:
|
||||
rel = os.path.normpath(os.path.join(os.path.dirname(idx), link))
|
||||
rel = rel.split('#', 1)[0]
|
||||
rel = rel.split('?', 1)[0]
|
||||
if not rel:
|
||||
continue
|
||||
p = os.path.join(base, rel)
|
||||
if not os.path.exists(p):
|
||||
bad.append(rel)
|
||||
|
||||
print('\n'.join(sorted(set(bad))))
|
||||
PY
|
||||
)"
|
||||
if [ -n "${missing_links}" ]; then
|
||||
extended_findings+=("docs/docs-index.md contains broken relative links")
|
||||
{
|
||||
printf '%s\n' '### Docs index link integrity'
|
||||
printf '%s\n' 'Broken relative links:'
|
||||
while IFS= read -r l; do [ -n "${l}" ] && printf '%s\n' "- ${l}"; done <<< "${missing_links}"
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ShellCheck advisory
|
||||
if [ -d "${SCRIPT_DIR}" ]; then
|
||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y shellcheck >/dev/null
|
||||
fi
|
||||
|
||||
sc_out=''
|
||||
while IFS= read -r shf; do
|
||||
[ -z "${shf}" ] && continue
|
||||
out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)"
|
||||
if [ -n "${out_one}" ]; then
|
||||
sc_out="${sc_out}${out_one}\n"
|
||||
fi
|
||||
done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort)
|
||||
|
||||
if [ -n "${sc_out}" ]; then
|
||||
extended_findings+=("ShellCheck warnings detected (advisory)")
|
||||
sc_head="$(printf '%s' "${sc_out}" | head -n 200)"
|
||||
{
|
||||
printf '%s\n' '### ShellCheck (advisory)'
|
||||
printf '%s\n' '```'
|
||||
printf '%s\n' "${sc_head}"
|
||||
printf '%s\n' '```'
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# SPDX header advisory for common source types
|
||||
spdx_missing=()
|
||||
IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}"
|
||||
spdx_args=()
|
||||
for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done
|
||||
|
||||
while IFS= read -r f; do
|
||||
[ -z "${f}" ] && continue
|
||||
if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then
|
||||
spdx_missing+=("${f}")
|
||||
fi
|
||||
done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true)
|
||||
|
||||
if [ "${#spdx_missing[@]}" -gt 0 ]; then
|
||||
extended_findings+=("SPDX header missing in some tracked files (advisory)")
|
||||
{
|
||||
printf '%s\n' '### SPDX header advisory'
|
||||
printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):'
|
||||
for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
# Git hygiene advisory: branches older than 180 days (remote)
|
||||
stale_cutoff_days=180
|
||||
stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 [...]
|
||||
if [ -n "${stale_branches}" ]; then
|
||||
extended_findings+=("Stale remote branches detected (advisory)")
|
||||
{
|
||||
printf '%s\n' '### Git hygiene advisory'
|
||||
printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):"
|
||||
while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}"
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
fi
|
||||
|
||||
{
|
||||
printf '%s\n' '### Guardrails coverage matrix'
|
||||
printf '%s\n' '| Domain | Status | Notes |'
|
||||
printf '%s\n' '|---|---|---|'
|
||||
printf '%s\n' '| Access control | OK | Admin-only execution gate |'
|
||||
printf '%s\n' '| Release variables | OK | Repository variables validation |'
|
||||
printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |'
|
||||
printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |'
|
||||
printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |'
|
||||
if [ "${extended_enabled}" = 'true' ]; then
|
||||
if [ "${#extended_findings[@]}" -gt 0 ]; then
|
||||
printf '%s\n' '| Extended checks | Warning | See extended findings below |'
|
||||
else
|
||||
printf '%s\n' '| Extended checks | OK | No findings |'
|
||||
fi
|
||||
else
|
||||
printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |'
|
||||
fi
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
|
||||
if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then
|
||||
{
|
||||
printf '%s\n' '### Extended findings (advisory)'
|
||||
for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done
|
||||
printf '\n'
|
||||
} >> "${GITHUB_STEP_SUMMARY}"
|
||||
fi
|
||||
|
||||
printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}"
|
||||
@@ -0,0 +1,519 @@
|
||||
# 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
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: GitHub.Workflow
|
||||
# INGROUP: MokoStandards.Maintenance
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/workflows/shared/repository-cleanup.yml
|
||||
# VERSION: 04.04.01
|
||||
# BRIEF: Recurring repository maintenance — labels, branches, workflows, logs, doc indexes
|
||||
# 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
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 6 1,15 * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reset_labels:
|
||||
description: 'Delete ALL existing labels and recreate the standard set'
|
||||
type: boolean
|
||||
default: false
|
||||
clean_branches:
|
||||
description: 'Delete old chore/sync-mokostandards-* branches'
|
||||
type: boolean
|
||||
default: true
|
||||
clean_workflows:
|
||||
description: 'Delete orphaned workflow runs (cancelled, stale)'
|
||||
type: boolean
|
||||
default: true
|
||||
clean_logs:
|
||||
description: 'Delete workflow run logs older than 30 days'
|
||||
type: boolean
|
||||
default: true
|
||||
fix_templates:
|
||||
description: 'Strip copyright comment blocks from issue templates'
|
||||
type: boolean
|
||||
default: true
|
||||
rebuild_indexes:
|
||||
description: 'Rebuild docs/ index files'
|
||||
type: boolean
|
||||
default: true
|
||||
delete_closed_issues:
|
||||
description: 'Delete issues that have been closed for more than 30 days'
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
cleanup:
|
||||
name: Repository Maintenance
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
token: ${{ secrets.GH_TOKEN || github.token }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check actor permission
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
run: |
|
||||
ACTOR="${{ github.actor }}"
|
||||
# Schedule triggers use github-actions[bot]
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
echo "✅ Scheduled run — authorized"
|
||||
exit 0
|
||||
fi
|
||||
AUTHORIZED_USERS="jmiller-moko github-actions[bot]"
|
||||
for user in $AUTHORIZED_USERS; do
|
||||
if [ "$ACTOR" = "$user" ]; then
|
||||
echo "✅ ${ACTOR} authorized"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
PERMISSION=$(gh api "repos/${{ github.repository }}/collaborators/${ACTOR}/permission" \
|
||||
--jq '.permission' 2>/dev/null)
|
||||
case "$PERMISSION" in
|
||||
admin|maintain) echo "✅ ${ACTOR} has ${PERMISSION}" ;;
|
||||
*) echo "❌ Admin or maintain required"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# ── Determine which tasks to run ─────────────────────────────────────
|
||||
# On schedule: run all tasks with safe defaults (labels NOT reset)
|
||||
# On dispatch: use input toggles
|
||||
- name: Set task flags
|
||||
id: tasks
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
echo "reset_labels=false" >> $GITHUB_OUTPUT
|
||||
echo "clean_branches=true" >> $GITHUB_OUTPUT
|
||||
echo "clean_workflows=true" >> $GITHUB_OUTPUT
|
||||
echo "clean_logs=true" >> $GITHUB_OUTPUT
|
||||
echo "fix_templates=true" >> $GITHUB_OUTPUT
|
||||
echo "rebuild_indexes=true" >> $GITHUB_OUTPUT
|
||||
echo "delete_closed_issues=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "reset_labels=${{ inputs.reset_labels }}" >> $GITHUB_OUTPUT
|
||||
echo "clean_branches=${{ inputs.clean_branches }}" >> $GITHUB_OUTPUT
|
||||
echo "clean_workflows=${{ inputs.clean_workflows }}" >> $GITHUB_OUTPUT
|
||||
echo "clean_logs=${{ inputs.clean_logs }}" >> $GITHUB_OUTPUT
|
||||
echo "fix_templates=${{ inputs.fix_templates }}" >> $GITHUB_OUTPUT
|
||||
echo "rebuild_indexes=${{ inputs.rebuild_indexes }}" >> $GITHUB_OUTPUT
|
||||
echo "delete_closed_issues=${{ inputs.delete_closed_issues }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# ── DELETE RETIRED WORKFLOWS (always runs) ────────────────────────────
|
||||
- name: Delete retired workflow files
|
||||
run: |
|
||||
echo "## 🗑️ Retired Workflow Cleanup" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
RETIRED=(
|
||||
".github/workflows/build.yml"
|
||||
".github/workflows/code-quality.yml"
|
||||
".github/workflows/release-cycle.yml"
|
||||
".github/workflows/release-pipeline.yml"
|
||||
".github/workflows/branch-cleanup.yml"
|
||||
".github/workflows/auto-update-changelog.yml"
|
||||
".github/workflows/enterprise-issue-manager.yml"
|
||||
".github/workflows/flush-actions-cache.yml"
|
||||
".github/workflows/mokostandards-script-runner.yml"
|
||||
".github/workflows/unified-ci.yml"
|
||||
".github/workflows/unified-platform-testing.yml"
|
||||
".github/workflows/reusable-build.yml"
|
||||
".github/workflows/reusable-ci-validation.yml"
|
||||
".github/workflows/reusable-deploy.yml"
|
||||
".github/workflows/reusable-php-quality.yml"
|
||||
".github/workflows/reusable-platform-testing.yml"
|
||||
".github/workflows/reusable-project-detector.yml"
|
||||
".github/workflows/reusable-release.yml"
|
||||
".github/workflows/reusable-script-executor.yml"
|
||||
".github/workflows/rebuild-docs-indexes.yml"
|
||||
".github/workflows/setup-project-v2.yml"
|
||||
".github/workflows/sync-docs-to-project.yml"
|
||||
".github/workflows/release.yml"
|
||||
".github/workflows/sync-changelogs.yml"
|
||||
".github/workflows/version_branch.yml"
|
||||
"update.json"
|
||||
".github/workflows/auto-version-branch.yml"
|
||||
)
|
||||
|
||||
DELETED=0
|
||||
for wf in "${RETIRED[@]}"; do
|
||||
if [ -f "$wf" ]; then
|
||||
git rm "$wf" 2>/dev/null || rm -f "$wf"
|
||||
echo " Deleted: \`$(basename $wf)\`" >> $GITHUB_STEP_SUMMARY
|
||||
DELETED=$((DELETED+1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$DELETED" -gt 0 ]; then
|
||||
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: delete ${DELETED} retired workflow file(s) [skip ci]" \
|
||||
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
|
||||
git push
|
||||
echo "✅ ${DELETED} retired workflow(s) deleted" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "✅ No retired workflows found" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# ── LABEL RESET ──────────────────────────────────────────────────────
|
||||
- name: Reset labels to standard set
|
||||
if: steps.tasks.outputs.reset_labels == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
run: |
|
||||
REPO="${{ github.repository }}"
|
||||
echo "## 🏷️ Label Reset" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
gh api "repos/${REPO}/labels?per_page=100" --paginate --jq '.[].name' | while read -r label; do
|
||||
ENCODED=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$label', safe=''))")
|
||||
gh api -X DELETE "repos/${REPO}/labels/${ENCODED}" --silent 2>/dev/null || true
|
||||
done
|
||||
|
||||
while IFS='|' read -r name color description; do
|
||||
[ -z "$name" ] && continue
|
||||
gh api "repos/${REPO}/labels" \
|
||||
-f name="$name" -f color="$color" -f description="$description" \
|
||||
--silent 2>/dev/null || true
|
||||
done << 'LABELS'
|
||||
joomla|7F52FF|Joomla extension or component
|
||||
dolibarr|FF6B6B|Dolibarr module or extension
|
||||
generic|808080|Generic project or library
|
||||
php|4F5D95|PHP code changes
|
||||
javascript|F7DF1E|JavaScript code changes
|
||||
typescript|3178C6|TypeScript code changes
|
||||
python|3776AB|Python code changes
|
||||
css|1572B6|CSS/styling changes
|
||||
html|E34F26|HTML template changes
|
||||
documentation|0075CA|Documentation changes
|
||||
ci-cd|000000|CI/CD pipeline changes
|
||||
docker|2496ED|Docker configuration changes
|
||||
tests|00FF00|Test suite changes
|
||||
security|FF0000|Security-related changes
|
||||
dependencies|0366D6|Dependency updates
|
||||
config|F9D0C4|Configuration file changes
|
||||
build|FFA500|Build system changes
|
||||
automation|8B4513|Automated processes or scripts
|
||||
mokostandards|B60205|MokoStandards compliance
|
||||
needs-review|FBCA04|Awaiting code review
|
||||
work-in-progress|D93F0B|Work in progress, not ready for merge
|
||||
breaking-change|D73A4A|Breaking API or functionality change
|
||||
priority: critical|B60205|Critical priority, must be addressed immediately
|
||||
priority: high|D93F0B|High priority
|
||||
priority: medium|FBCA04|Medium priority
|
||||
priority: low|0E8A16|Low priority
|
||||
type: bug|D73A4A|Something isn't working
|
||||
type: feature|A2EEEF|New feature or request
|
||||
type: enhancement|84B6EB|Enhancement to existing feature
|
||||
type: refactor|F9D0C4|Code refactoring
|
||||
type: chore|FEF2C0|Maintenance tasks
|
||||
type: version|0E8A16|Version-related change
|
||||
status: pending|FBCA04|Pending action or decision
|
||||
status: in-progress|0E8A16|Currently being worked on
|
||||
status: blocked|B60205|Blocked by another issue or dependency
|
||||
status: on-hold|D4C5F9|Temporarily on hold
|
||||
status: wontfix|FFFFFF|This will not be worked on
|
||||
size/xs|C5DEF5|Extra small change (1-10 lines)
|
||||
size/s|6FD1E2|Small change (11-30 lines)
|
||||
size/m|F9DD72|Medium change (31-100 lines)
|
||||
size/l|FFA07A|Large change (101-300 lines)
|
||||
size/xl|FF6B6B|Extra large change (301-1000 lines)
|
||||
size/xxl|B60205|Extremely large change (1000+ lines)
|
||||
health: excellent|0E8A16|Health score 90-100
|
||||
health: good|FBCA04|Health score 70-89
|
||||
health: fair|FFA500|Health score 50-69
|
||||
health: poor|FF6B6B|Health score below 50
|
||||
standards-update|B60205|MokoStandards sync update
|
||||
standards-drift|FBCA04|Repository drifted from MokoStandards
|
||||
sync-report|0075CA|Bulk sync run report
|
||||
sync-failure|D73A4A|Bulk sync failure requiring attention
|
||||
push-failure|D73A4A|File push failure requiring attention
|
||||
health-check|0E8A16|Repository health check results
|
||||
version-drift|FFA500|Version mismatch detected
|
||||
deploy-failure|CC0000|Automated deploy failure tracking
|
||||
template-validation-failure|D73A4A|Template workflow validation failure
|
||||
version|0E8A16|Version bump or release
|
||||
LABELS
|
||||
|
||||
echo "✅ Standard labels created" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── BRANCH CLEANUP ───────────────────────────────────────────────────
|
||||
- name: Delete old sync branches
|
||||
if: steps.tasks.outputs.clean_branches == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
run: |
|
||||
REPO="${{ github.repository }}"
|
||||
CURRENT="chore/sync-mokostandards-v04.04"
|
||||
echo "## 🌿 Branch Cleanup" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
FOUND=false
|
||||
gh api "repos/${REPO}/branches?per_page=100" --jq '.[].name' | \
|
||||
grep "^chore/sync-mokostandards" | \
|
||||
grep -v "^${CURRENT}$" | while read -r branch; do
|
||||
gh pr list --repo "$REPO" --head "$branch" --state open --json number --jq '.[].number' 2>/dev/null | while read -r pr; do
|
||||
gh pr close "$pr" --repo "$REPO" --comment "Superseded by \`${CURRENT}\`" 2>/dev/null || true
|
||||
echo " Closed PR #${pr}" >> $GITHUB_STEP_SUMMARY
|
||||
done
|
||||
gh api -X DELETE "repos/${REPO}/git/refs/heads/${branch}" --silent 2>/dev/null || true
|
||||
echo " Deleted: \`${branch}\`" >> $GITHUB_STEP_SUMMARY
|
||||
FOUND=true
|
||||
done
|
||||
|
||||
if [ "$FOUND" != "true" ]; then
|
||||
echo "✅ No old sync branches found" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# ── WORKFLOW RUN CLEANUP ─────────────────────────────────────────────
|
||||
- name: Clean up workflow runs
|
||||
if: steps.tasks.outputs.clean_workflows == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
run: |
|
||||
REPO="${{ github.repository }}"
|
||||
echo "## 🔄 Workflow Run Cleanup" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
DELETED=0
|
||||
# Delete cancelled and stale workflow runs
|
||||
for status in cancelled stale; do
|
||||
gh api "repos/${REPO}/actions/runs?status=${status}&per_page=100" \
|
||||
--jq '.workflow_runs[].id' 2>/dev/null | while read -r run_id; do
|
||||
gh api -X DELETE "repos/${REPO}/actions/runs/${run_id}" --silent 2>/dev/null || true
|
||||
DELETED=$((DELETED+1))
|
||||
done
|
||||
done
|
||||
|
||||
echo "✅ Cleaned cancelled/stale workflow runs" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── LOG CLEANUP ──────────────────────────────────────────────────────
|
||||
- name: Delete old workflow run logs
|
||||
if: steps.tasks.outputs.clean_logs == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
run: |
|
||||
REPO="${{ github.repository }}"
|
||||
CUTOFF=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
||||
echo "## 📋 Log Cleanup" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Deleting logs older than: ${CUTOFF}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
DELETED=0
|
||||
gh api "repos/${REPO}/actions/runs?created=<${CUTOFF}&per_page=100" \
|
||||
--jq '.workflow_runs[].id' 2>/dev/null | while read -r run_id; do
|
||||
gh api -X DELETE "repos/${REPO}/actions/runs/${run_id}/logs" --silent 2>/dev/null || true
|
||||
DELETED=$((DELETED+1))
|
||||
done
|
||||
|
||||
echo "✅ Cleaned old workflow run logs" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# ── ISSUE TEMPLATE FIX ──────────────────────────────────────────────
|
||||
- name: Strip copyright headers from issue templates
|
||||
if: steps.tasks.outputs.fix_templates == 'true'
|
||||
run: |
|
||||
echo "## 📋 Issue Template Cleanup" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
FIXED=0
|
||||
for f in .github/ISSUE_TEMPLATE/*.md; do
|
||||
[ -f "$f" ] || continue
|
||||
if grep -q '^<!--$' "$f"; then
|
||||
sed -i '/^<!--$/,/^-->$/d' "$f"
|
||||
echo " Cleaned: \`$(basename $f)\`" >> $GITHUB_STEP_SUMMARY
|
||||
FIXED=$((FIXED+1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FIXED" -gt 0 ]; then
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add .github/ISSUE_TEMPLATE/
|
||||
git commit -m "fix: strip copyright comment blocks from issue templates [skip ci]" \
|
||||
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
|
||||
git push
|
||||
echo "✅ ${FIXED} template(s) cleaned and committed" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "✅ No templates need cleaning" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# ── REBUILD DOC INDEXES ─────────────────────────────────────────────
|
||||
- name: Rebuild docs/ index files
|
||||
if: steps.tasks.outputs.rebuild_indexes == 'true'
|
||||
run: |
|
||||
echo "## 📚 Documentation Index Rebuild" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ ! -d "docs" ]; then
|
||||
echo "⏭️ No docs/ directory — skipping" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
UPDATED=0
|
||||
# Generate index.md for each docs/ subdirectory
|
||||
find docs -type d | while read -r dir; do
|
||||
INDEX="${dir}/index.md"
|
||||
FILES=$(find "$dir" -maxdepth 1 -name "*.md" ! -name "index.md" -printf "- [%f](./%f)\n" 2>/dev/null | sort)
|
||||
if [ -z "$FILES" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
cat > "$INDEX" << INDEXEOF
|
||||
# $(basename "$dir")
|
||||
|
||||
## Documents
|
||||
|
||||
${FILES}
|
||||
|
||||
---
|
||||
*Auto-generated by repository-cleanup workflow*
|
||||
INDEXEOF
|
||||
# Dedent
|
||||
sed -i 's/^ //' "$INDEX"
|
||||
UPDATED=$((UPDATED+1))
|
||||
done
|
||||
|
||||
if [ "$UPDATED" -gt 0 ]; then
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add docs/
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "docs: rebuild documentation indexes [skip ci]" \
|
||||
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
|
||||
git push
|
||||
echo "✅ ${UPDATED} index file(s) rebuilt and committed" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "✅ All indexes already up to date" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
else
|
||||
echo "✅ No indexes to rebuild" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# ── VERSION DRIFT DETECTION ──────────────────────────────────────────
|
||||
- name: Check for version drift
|
||||
run: |
|
||||
echo "## 📦 Version Drift Check" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ ! -f "README.md" ]; then
|
||||
echo "⏭️ No README.md — skipping" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
README_VERSION=$(grep -oP '^\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' README.md 2>/dev/null | head -1)
|
||||
if [ -z "$README_VERSION" ]; then
|
||||
echo "⚠️ No VERSION found in README.md FILE INFORMATION block" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "**README version:** \`${README_VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
DRIFT=0
|
||||
CHECKED=0
|
||||
|
||||
# Check all files with FILE INFORMATION blocks
|
||||
while IFS= read -r -d '' file; do
|
||||
FILE_VERSION=$(grep -oP '^\s*\*?\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' "$file" 2>/dev/null | head -1)
|
||||
[ -z "$FILE_VERSION" ] && continue
|
||||
CHECKED=$((CHECKED+1))
|
||||
if [ "$FILE_VERSION" != "$README_VERSION" ]; then
|
||||
echo " ⚠️ \`${file}\`: \`${FILE_VERSION}\` (expected \`${README_VERSION}\`)" >> $GITHUB_STEP_SUMMARY
|
||||
DRIFT=$((DRIFT+1))
|
||||
fi
|
||||
done < <(find . -maxdepth 4 -type f \( -name "*.php" -o -name "*.md" -o -name "*.yml" \) ! -path "./.git/*" ! -path "./vendor/*" ! -path "./node_modules/*" -print0 2>/dev/null)
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$DRIFT" -gt 0 ]; then
|
||||
echo "⚠️ **${DRIFT}** file(s) out of ${CHECKED} have version drift" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Run \`sync-version-on-merge\` workflow or update manually" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "✅ All ${CHECKED} file(s) match README version \`${README_VERSION}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# ── PROTECT CUSTOM WORKFLOWS ────────────────────────────────────────
|
||||
- name: Ensure custom workflow directory exists
|
||||
run: |
|
||||
echo "## 🔧 Custom Workflows" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
if [ ! -d ".github/workflows/custom" ]; then
|
||||
mkdir -p .github/workflows/custom
|
||||
cat > .github/workflows/custom/README.md << 'CWEOF'
|
||||
# Custom Workflows
|
||||
|
||||
Place repo-specific workflows here. Files in this directory are:
|
||||
- **Never overwritten** by MokoStandards bulk sync
|
||||
- **Never deleted** by the repository-cleanup workflow
|
||||
- Safe for custom CI, notifications, or repo-specific automation
|
||||
|
||||
Synced workflows live in `.github/workflows/` (parent directory).
|
||||
CWEOF
|
||||
sed -i 's/^ //' .github/workflows/custom/README.md
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add .github/workflows/custom/
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "chore: create .github/workflows/custom/ for repo-specific workflows [skip ci]" \
|
||||
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
|
||||
git push
|
||||
echo "✅ Created \`.github/workflows/custom/\` directory" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
else
|
||||
CUSTOM_COUNT=$(find .github/workflows/custom -name "*.yml" -o -name "*.yaml" 2>/dev/null | wc -l)
|
||||
echo "✅ Custom workflow directory exists (${CUSTOM_COUNT} workflow(s))" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# ── DELETE CLOSED ISSUES ──────────────────────────────────────────────
|
||||
- name: Delete old closed issues
|
||||
if: steps.tasks.outputs.delete_closed_issues == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
run: |
|
||||
REPO="${{ github.repository }}"
|
||||
CUTOFF=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-30d +%Y-%m-%dT%H:%M:%SZ)
|
||||
echo "## 🗑️ Closed Issue Cleanup" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Deleting issues closed before: ${CUTOFF}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
DELETED=0
|
||||
gh api "repos/${REPO}/issues?state=closed&since=1970-01-01T00:00:00Z&per_page=100&sort=updated&direction=asc" \
|
||||
--jq ".[] | select(.closed_at < \"${CUTOFF}\") | .number" 2>/dev/null | while read -r num; do
|
||||
# Lock and close with "not_planned" to mark as cleaned up
|
||||
gh api "repos/${REPO}/issues/${num}/lock" -X PUT -f lock_reason="resolved" --silent 2>/dev/null || true
|
||||
echo " Locked issue #${num}" >> $GITHUB_STEP_SUMMARY
|
||||
DELETED=$((DELETED+1))
|
||||
done
|
||||
|
||||
if [ "$DELETED" -eq 0 ] 2>/dev/null; then
|
||||
echo "✅ No old closed issues found" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "✅ Locked ${DELETED} old closed issue(s)" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "---" >> $GITHUB_STEP_SUMMARY
|
||||
echo "*Run by @${{ github.actor }} — trigger: ${{ github.event_name }}*" >> $GITHUB_STEP_SUMMARY
|
||||
+1190
-1163
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@
|
||||
# INGROUP: MokoStandards.Automation
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/workflows/shared/sync-version-on-merge.yml
|
||||
# VERSION: 04.00.35
|
||||
# VERSION: 04.04.01
|
||||
# BRIEF: Auto-bump patch version on every push to main and propagate to all file headers
|
||||
# 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.
|
||||
@@ -32,6 +32,9 @@ permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
sync-version:
|
||||
name: Propagate README version
|
||||
@@ -45,7 +48,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up PHP
|
||||
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.31.0
|
||||
uses: shivammathur/setup-php@fcafdd6392932010c2bd5094439b8e33be2a8a09 # v2.37.0
|
||||
with:
|
||||
php-version: '8.1'
|
||||
tools: composer
|
||||
@@ -55,7 +58,7 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
|
||||
COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN || github.token }}"}}'
|
||||
run: |
|
||||
git clone --depth 1 --quiet \
|
||||
git clone --depth 1 --branch version/04.04 --quiet \
|
||||
"https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \
|
||||
/tmp/mokostandards
|
||||
cd /tmp/mokostandards
|
||||
@@ -64,31 +67,20 @@ jobs:
|
||||
- name: Auto-bump patch version
|
||||
if: ${{ github.event_name == 'push' && github.actor != 'github-actions[bot]' }}
|
||||
run: |
|
||||
# If README.md was part of this push, the author already bumped the version — skip.
|
||||
if git diff --name-only HEAD~1 HEAD 2>/dev/null | grep -q '^README\.md$'; then
|
||||
echo "README.md changed in this push — skipping auto-bump"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CURRENT=$(grep -oP '^\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' README.md | head -1)
|
||||
if [ -z "$CURRENT" ]; then
|
||||
echo "⚠️ No VERSION found in README.md — skipping auto-bump"
|
||||
RESULT=$(php /tmp/mokostandards/api/cli/version_bump.php --path .) || {
|
||||
echo "⚠️ Could not bump version — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Increment the patch component (zero-padded to 2 digits)
|
||||
MAJOR=$(echo "$CURRENT" | cut -d. -f1)
|
||||
MINOR=$(echo "$CURRENT" | cut -d. -f2)
|
||||
PATCH=$(echo "$CURRENT" | cut -d. -f3)
|
||||
NEW_PATCH=$(printf '%02d' $(( 10#$PATCH + 1 )))
|
||||
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
|
||||
|
||||
echo "Auto-bumping patch: $CURRENT → $NEW_VERSION"
|
||||
sed -i "s/^\(\s*VERSION:\s*\)${CURRENT}/\1${NEW_VERSION}/" README.md
|
||||
}
|
||||
echo "Auto-bumping patch: $RESULT"
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git add README.md
|
||||
git commit -m "chore(version): auto-bump patch ${CURRENT} → ${NEW_VERSION} [skip ci]" \
|
||||
git commit -m "chore(version): auto-bump patch ${RESULT} [skip ci]" \
|
||||
--author="github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
|
||||
git push
|
||||
|
||||
@@ -96,7 +88,7 @@ jobs:
|
||||
id: readme_version
|
||||
run: |
|
||||
git pull --ff-only 2>/dev/null || true
|
||||
VERSION=$(grep -oP '^\s*VERSION:\s*\K[0-9]{2}\.[0-9]{2}\.[0-9]{2}' README.md | head -1)
|
||||
VERSION=$(php /tmp/mokostandards/api/cli/version_read.php --path . 2>/dev/null)
|
||||
if [ -z "$VERSION" ]; then
|
||||
echo "⚠️ No VERSION in README.md — skipping propagation"
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
@@ -119,8 +111,9 @@ jobs:
|
||||
- name: Commit updated files
|
||||
if: ${{ steps.readme_version.outputs.skip != 'true' && inputs.dry_run != true }}
|
||||
run: |
|
||||
git pull --ff-only 2>/dev/null || true
|
||||
if git diff --quiet; then
|
||||
echo "ℹ️ No version changes needed"
|
||||
echo "ℹ️ No version changes needed — already up to date"
|
||||
exit 0
|
||||
fi
|
||||
VERSION="${{ steps.readme_version.outputs.version }}"
|
||||
|
||||
@@ -903,3 +903,8 @@ modulebuilder.txt
|
||||
# Keep-empty folders helper
|
||||
# ============================================================
|
||||
!.gitkeep
|
||||
|
||||
# ── MokoStandards sync (auto-appended) ────────────────────────────────
|
||||
.claude/
|
||||
|
||||
!src/media/vendor/
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: MokoStandards.Templates.Config
|
||||
# INGROUP: MokoStandards.Templates
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/configs/moko-standards.yml
|
||||
# VERSION: 04.04.01
|
||||
# BRIEF: Governance attachment template — synced to .mokostandards in every governed repository
|
||||
# NOTE: Tokens replaced at sync time: mokoconsulting-tech, MokoJoomHero, waas-component, 04.04.00
|
||||
#
|
||||
# This file is managed automatically by MokoStandards bulk sync.
|
||||
# Do not edit manually — changes will be overwritten on the next sync.
|
||||
# To update governance settings, open a PR in MokoStandards instead:
|
||||
# https://github.com/mokoconsulting-tech/MokoStandards
|
||||
|
||||
standards_source: "https://github.com/mokoconsulting-tech/MokoStandards"
|
||||
standards_version: "04.04.00"
|
||||
platform: "waas-component"
|
||||
governed_repo: "mokoconsulting-tech/MokoJoomHero"
|
||||
+58
-50
@@ -1,79 +1,87 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
<!-- Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
|
||||
## Our Pledge
|
||||
This file is part of a Moko Consulting project.
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
SPDX-LICENSE-IDENTIFIER: GPL-3.0-or-later
|
||||
|
||||
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
||||
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.
|
||||
|
||||
## Our Standards
|
||||
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.
|
||||
|
||||
Examples of behavior that contributes to a positive environment for our community include:
|
||||
You should have received a copy of the GNU General Public License (./LICENSE.md).
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall community
|
||||
# FILE INFORMATION
|
||||
DEFGROUP:
|
||||
INGROUP: Project.Documentation
|
||||
REPO:
|
||||
VERSION: 04.04.01
|
||||
PATH: ./CODE_OF_CONDUCT.md
|
||||
BRIEF: Reference + packaging repo for Moko Consulting Developer GPT Other Default
|
||||
-->
|
||||
# Code of Conduct
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
## 1. Purpose
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a professional setting
|
||||
The purpose of this Code of Conduct is to ensure a safe, inclusive, and respectful environment for all contributors and participants in Moko Consulting projects. This applies to all interactions, whether in repositories, issue trackers, documentation, meetings, or community spaces.
|
||||
|
||||
## Enforcement Responsibilities
|
||||
## 2. Our Standards
|
||||
|
||||
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
||||
Participants are expected to uphold behaviors that strengthen our community, including:
|
||||
|
||||
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
|
||||
Demonstrating empathy and respect toward others.
|
||||
Being inclusive of diverse viewpoints and backgrounds.
|
||||
Gracefully accepting constructive feedback.
|
||||
Prioritizing collaboration over conflict.
|
||||
Showing professionalism in all interactions.
|
||||
|
||||
## Scope
|
||||
### Unacceptable behavior includes:
|
||||
|
||||
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
||||
Harassment, discrimination, or derogatory comments.
|
||||
Threatening or violent language or actions.
|
||||
Disruptive, aggressive, or intentionally harmful behavior.
|
||||
Publishing others’ private information without permission.
|
||||
Any behavior that violates applicable laws.
|
||||
|
||||
## Enforcement
|
||||
## 3. Responsibilities of Maintainers
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at hello@mokoconsulting.tech. All complaints will be reviewed and investigated promptly and fairly.
|
||||
Maintainers are responsible for:
|
||||
|
||||
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
||||
Clarifying acceptable behavior.
|
||||
Taking appropriate corrective action when unacceptable behavior occurs.
|
||||
Removing, editing, or rejecting contributions that violate this Code.
|
||||
Temporarily or permanently banning contributors who engage in repeated or severe violations.
|
||||
|
||||
## Enforcement Guidelines
|
||||
## 4. Scope
|
||||
|
||||
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
|
||||
This Code applies to:
|
||||
|
||||
### 1. Correction
|
||||
All Moko Consulting repositories.
|
||||
All documentation and collaboration platforms.
|
||||
Public and private communication related to project activities.
|
||||
Any representation of Moko Consulting in online or offline spaces.
|
||||
|
||||
**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
|
||||
## 5. Enforcement
|
||||
|
||||
**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
|
||||
Instances of misconduct may be reported to:
|
||||
**[hello@mokoconsulting.tech](mailto:hello@mokoconsulting.tech)**
|
||||
|
||||
### 2. Warning
|
||||
All reports will be reviewed and investigated promptly and fairly. Maintainers are obligated to maintain confidentiality where possible.
|
||||
|
||||
**Community Impact**: A violation through a single incident or series of actions.
|
||||
Consequences may include:
|
||||
|
||||
**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
|
||||
A warning.
|
||||
Required training or mediation.
|
||||
Temporary or permanent bans.
|
||||
Escalation to legal authorities when required.
|
||||
|
||||
### 3. Temporary Ban
|
||||
## 6. Acknowledgements
|
||||
|
||||
**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
|
||||
This Code of Conduct is inspired by widely adopted community guidelines, including the Contributor Covenant and major open-source collaboration standards.
|
||||
|
||||
**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
|
||||
## 7. Related Documents
|
||||
|
||||
### 4. Permanent Ban
|
||||
[Governance Guide](./docs-governance.md)
|
||||
[Contributor Guide](./docs-contributing.md)
|
||||
[Documentation Index](./docs-index.md)
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within the community.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||
|
||||
Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
|
||||
This Code of Conduct is a living document and may be updated following the established Change Management process.
|
||||
|
||||
+86
-231
@@ -1,273 +1,128 @@
|
||||
# Contributing to MokoStandards-Template-Joomla-Module
|
||||
<!--
|
||||
Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
|
||||
Thank you for your interest in contributing to this project! This document provides guidelines and instructions for contributing.
|
||||
This file is part of a Moko Consulting project.
|
||||
|
||||
## Table of Contents
|
||||
SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Development Process](#development-process)
|
||||
- [Coding Standards](#coding-standards)
|
||||
- [Commit Guidelines](#commit-guidelines)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Reporting Issues](#reporting-issues)
|
||||
- [Questions and Support](#questions-and-support)
|
||||
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.
|
||||
|
||||
## Code of Conduct
|
||||
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.
|
||||
|
||||
This project and everyone participating in it is governed by our [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to hello@mokoconsulting.tech.
|
||||
You should have received a copy of the GNU General Public License (./LICENSE).
|
||||
|
||||
## Getting Started
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: {{DEFGROUP}}
|
||||
INGROUP: Project.Documentation
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoJoomHero
|
||||
VERSION: 04.04.00
|
||||
PATH: ./CONTRIBUTING.md
|
||||
BRIEF: How to contribute; branch strategy, commit conventions, PR workflow, and release pipeline
|
||||
-->
|
||||
|
||||
### Prerequisites
|
||||
# Contributing
|
||||
|
||||
Before contributing, ensure you have:
|
||||
Thank you for your interest in contributing to **MokoJoomHero**!
|
||||
|
||||
- PHP 7.4 or higher installed
|
||||
- Composer installed (for dependency management)
|
||||
- PHP CodeSniffer installed (`composer global require squizlabs/php_codesniffer`)
|
||||
- A working Joomla installation for testing
|
||||
- Git installed and configured
|
||||
This repository is governed by **[MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards)** — the authoritative source of coding standards, workflows, and policies for all Moko Consulting repositories.
|
||||
|
||||
### Setting Up Your Development Environment
|
||||
## Branch Strategy
|
||||
|
||||
1. **Fork the repository** on GitHub
|
||||
| Branch | Purpose | Deploys To |
|
||||
|--------|---------|------------|
|
||||
| `main` | Bleeding edge — all development merges here | CI only |
|
||||
| `dev/XX.YY.ZZ` | Feature development | Dev server (version: "development") |
|
||||
| `version/XX.YY` | Stable frozen snapshot | Demo + RS servers |
|
||||
|
||||
2. **Clone your fork**:
|
||||
```bash
|
||||
git clone https://github.com/your-username/MokoStandards-Template-Joomla-Module.git
|
||||
cd MokoStandards-Template-Joomla-Module
|
||||
```
|
||||
### Development Workflow
|
||||
|
||||
3. **Add the upstream remote**:
|
||||
```bash
|
||||
git remote add upstream https://github.com/mokoconsulting-tech/MokoStandards-Template-Joomla-Module.git
|
||||
```
|
||||
```
|
||||
1. Create branch: git checkout -b dev/XX.YY.ZZ/my-feature
|
||||
2. Develop + test (dev server auto-deploys on push)
|
||||
3. Open PR → main (squash merge only)
|
||||
4. Auto-release (version branch + tag + GitHub Release created automatically)
|
||||
```
|
||||
|
||||
4. **Configure git commit template**:
|
||||
```bash
|
||||
git config commit.template .gitmessage
|
||||
```
|
||||
### Branch Naming
|
||||
|
||||
5. **Install development dependencies**:
|
||||
```bash
|
||||
composer install
|
||||
```
|
||||
| Prefix | Use |
|
||||
|--------|-----|
|
||||
| `dev/XX.YY.ZZ` | Feature development (e.g., `dev/02.00.00/add-extrafields`) |
|
||||
| `version/XX.YY` | Stable release (auto-created, never manually pushed) |
|
||||
| `chore/` | Automated sync branches (managed by MokoStandards) |
|
||||
|
||||
## Development Process
|
||||
> **Never use** `feature/`, `hotfix/`, or `release/` prefixes — they are not part of the MokoStandards branch strategy.
|
||||
|
||||
### Branching Strategy
|
||||
## Commit Conventions
|
||||
|
||||
- `main` - Stable, production-ready code
|
||||
- `feat/*` - New features
|
||||
- `fix/*` - Bug fixes
|
||||
- `docs/*` - Documentation updates
|
||||
- `refactor/*` - Code refactoring
|
||||
- `chore/*` - Maintenance tasks
|
||||
Use [conventional commits](https://www.conventionalcommits.org/):
|
||||
|
||||
### Workflow
|
||||
```
|
||||
feat(scope): add new extrafield for invoice tracking
|
||||
fix(sql): correct column type in llx_mytable
|
||||
docs(readme): update installation instructions
|
||||
chore(deps): bump enterprise library to 04.02.30
|
||||
```
|
||||
|
||||
1. **Create a branch** from `main`:
|
||||
```bash
|
||||
git checkout main
|
||||
git pull upstream main
|
||||
git checkout -b feat/your-feature-name
|
||||
```
|
||||
**Valid types:** `feat` | `fix` | `docs` | `chore` | `ci` | `refactor` | `style` | `test` | `perf` | `revert` | `build`
|
||||
|
||||
2. **Make your changes** following our coding standards
|
||||
## Pull Request Workflow
|
||||
|
||||
3. **Test your changes**:
|
||||
```bash
|
||||
make validate # Run linters and code standards checks
|
||||
make build # Build the module package
|
||||
```
|
||||
1. **Branch** from `main` using `dev/XX.YY.ZZ/description` format
|
||||
2. **Bump** the patch version in `README.md` before opening the PR
|
||||
3. **Title** must be a valid conventional commit subject line
|
||||
4. **Target** `main` — squash merge only (merge commits are disabled)
|
||||
5. **CI checks** must pass before merge
|
||||
|
||||
4. **Commit your changes** using conventional commits (see below)
|
||||
### What Happens on Merge
|
||||
|
||||
5. **Push to your fork**:
|
||||
```bash
|
||||
git push origin feat/your-feature-name
|
||||
```
|
||||
When your PR is merged to `main`, these workflows run automatically:
|
||||
|
||||
6. **Open a Pull Request** from your branch to `main`
|
||||
1. **sync-version-on-merge** — auto-bumps patch version, propagates to all file headers
|
||||
2. **auto-release** — creates `version/XX.YY` branch, git tag, and GitHub Release
|
||||
3. **deploy-demo / deploy-rs** — deploys to demo and RS servers (if `src/**` changed)
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### PHP Standards
|
||||
All contributions must follow [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards):
|
||||
|
||||
- Follow [Joomla Coding Standards](https://developer.joomla.org/coding-standards.html)
|
||||
- Use tabs for indentation (width: 2 spaces)
|
||||
- Use UTF-8 encoding without BOM
|
||||
- Use LF (Unix) line endings
|
||||
- Include proper DocBlocks for classes, methods, and properties
|
||||
- Use type hints where applicable
|
||||
| Standard | Reference |
|
||||
|----------|-----------|
|
||||
| Coding Style | [coding-style-guide.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/coding-style-guide.md) |
|
||||
| File Headers | [file-header-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/file-header-standards.md) |
|
||||
| Branching | [branch-release-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/branch-release-strategy.md) |
|
||||
| Merge Strategy | [merge-strategy.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/merge-strategy.md) |
|
||||
| Scripting | [scripting-standards.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/policy/scripting-standards.md) |
|
||||
| Build & Release | [build-release.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/workflows/build-release.md) |
|
||||
|
||||
### File Headers
|
||||
## PR Checklist
|
||||
|
||||
All PHP files should include the following header:
|
||||
- [ ] Branch named `dev/XX.YY.ZZ/description`
|
||||
- [ ] Patch version bumped in `README.md`
|
||||
- [ ] Conventional commit format for PR title
|
||||
- [ ] All new files have FILE INFORMATION headers
|
||||
- [ ] `declare(strict_types=1)` in all PHP files
|
||||
- [ ] PHPDoc on all public methods
|
||||
- [ ] Tests pass
|
||||
- [ ] CHANGELOG.md updated
|
||||
- [ ] No secrets, tokens, or credentials committed
|
||||
|
||||
```php
|
||||
<?php
|
||||
/**
|
||||
* @package ModuleName
|
||||
* @subpackage mod_modulename
|
||||
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
||||
* @license GNU General Public License version 3 or later; see LICENSE
|
||||
*/
|
||||
## Custom Workflows
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
Before submitting:
|
||||
|
||||
- Run PHP syntax check: `make lint`
|
||||
- Run PHP CodeSniffer: `make phpcs`
|
||||
- Run all checks: `make validate`
|
||||
- Fix auto-fixable issues: `make phpcbf`
|
||||
|
||||
## Commit Guidelines
|
||||
|
||||
We follow [Conventional Commits](https://www.conventionalcommits.org/) specification.
|
||||
|
||||
### Commit Message Format
|
||||
Place repo-specific workflows in `.github/workflows/custom/` — they are **never overwritten or deleted** by MokoStandards sync:
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
.github/workflows/
|
||||
├── deploy-dev.yml ← Synced from MokoStandards
|
||||
├── auto-release.yml ← Synced from MokoStandards
|
||||
└── custom/ ← Your custom workflows (safe)
|
||||
└── my-custom-ci.yml
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
- **feat**: A new feature
|
||||
- **fix**: A bug fix
|
||||
- **docs**: Documentation only changes
|
||||
- **style**: Code style changes (formatting, missing semicolons, etc.)
|
||||
- **refactor**: Code change that neither fixes a bug nor adds a feature
|
||||
- **perf**: Performance improvements
|
||||
- **test**: Adding or updating tests
|
||||
- **build**: Changes to build system or dependencies
|
||||
- **ci**: Changes to CI configuration
|
||||
- **chore**: Other changes that don't modify src or test files
|
||||
- **revert**: Reverts a previous commit
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# Feature
|
||||
feat(module): add contact form validation
|
||||
|
||||
# Bug fix
|
||||
fix(display): correct responsive layout on mobile devices
|
||||
|
||||
# Documentation
|
||||
docs(readme): update installation instructions
|
||||
|
||||
# Breaking change
|
||||
feat(api)!: change module configuration structure
|
||||
|
||||
BREAKING CHANGE: Configuration now uses nested arrays instead of flat structure
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- Use imperative, present tense: "change" not "changed" nor "changes"
|
||||
- Don't capitalize first letter
|
||||
- No period (.) at the end
|
||||
- Keep subject line under 50 characters
|
||||
- Wrap body at 72 characters
|
||||
- Separate subject from body with a blank line
|
||||
- Use body to explain what and why vs. how
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
### Before Submitting
|
||||
|
||||
1. **Update documentation** if you've changed functionality
|
||||
2. **Update CHANGELOG.md** with a description of changes
|
||||
3. **Ensure all tests pass**: `make validate`
|
||||
4. **Ensure no merge conflicts** with `main`
|
||||
5. **Keep commits atomic** - one logical change per commit
|
||||
6. **Squash commits** if you have multiple small commits for one change
|
||||
|
||||
### Pull Request Guidelines
|
||||
|
||||
- **Title**: Use conventional commit format
|
||||
- **Description**: Explain what and why (not how)
|
||||
- **Link issues**: Use "Closes #123" or "Fixes #123"
|
||||
- **Screenshots**: Include for UI changes
|
||||
- **Breaking changes**: Clearly document any breaking changes
|
||||
- **Documentation**: Include relevant documentation updates
|
||||
|
||||
### Review Process
|
||||
|
||||
1. At least one maintainer must review and approve
|
||||
2. All CI checks must pass
|
||||
3. No unresolved review comments
|
||||
4. Branch must be up to date with `main`
|
||||
|
||||
### After Merge
|
||||
|
||||
- Delete your feature branch
|
||||
- Pull the latest `main` branch
|
||||
- Update your fork
|
||||
|
||||
## Reporting Issues
|
||||
|
||||
### Bug Reports
|
||||
|
||||
When reporting bugs, include:
|
||||
|
||||
- **Clear title**: Brief description of the issue
|
||||
- **Environment**: PHP version, Joomla version, OS
|
||||
- **Steps to reproduce**: Numbered list of steps
|
||||
- **Expected behavior**: What should happen
|
||||
- **Actual behavior**: What actually happens
|
||||
- **Screenshots**: If applicable
|
||||
- **Error messages**: Full error text or stack traces
|
||||
|
||||
### Feature Requests
|
||||
|
||||
When requesting features, include:
|
||||
|
||||
- **Clear title**: Brief description of the feature
|
||||
- **Problem statement**: What problem does this solve?
|
||||
- **Proposed solution**: How should it work?
|
||||
- **Alternatives considered**: Other approaches you've thought about
|
||||
- **Additional context**: Any other relevant information
|
||||
|
||||
### Issue Labels
|
||||
|
||||
- `bug` - Something isn't working
|
||||
- `enhancement` - New feature or request
|
||||
- `documentation` - Improvements or additions to documentation
|
||||
- `good first issue` - Good for newcomers
|
||||
- `help wanted` - Extra attention is needed
|
||||
- `question` - Further information is requested
|
||||
|
||||
## Questions and Support
|
||||
|
||||
- **Documentation**: Check the [docs/](docs/) directory first
|
||||
- **Issues**: Search existing issues before creating new ones
|
||||
- **Email**: hello@mokoconsulting.tech
|
||||
- **Discussions**: Use GitHub Discussions for general questions
|
||||
|
||||
## Development Resources
|
||||
|
||||
- [Joomla Developer Documentation](https://docs.joomla.org/Developer_Documentation)
|
||||
- [Joomla Coding Standards](https://developer.joomla.org/coding-standards.html)
|
||||
- [PHP The Right Way](https://phptherightway.com/)
|
||||
- [Conventional Commits](https://www.conventionalcommits.org/)
|
||||
- [Semantic Versioning](https://semver.org/)
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the GNU General Public License v3.0 or later.
|
||||
By contributing, you agree that your contributions will be licensed under the [GPL-3.0-or-later](LICENSE) license.
|
||||
|
||||
---
|
||||
|
||||
Thank you for contributing to MokoStandards-Template-Joomla-Module!
|
||||
*This file is synced from [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards). Do not edit directly — changes will be overwritten on the next sync.*
|
||||
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
<!--
|
||||
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: [PROJECT_NAME]
|
||||
INGROUP: [PROJECT_NAME].Documentation
|
||||
REPO: [REPOSITORY_URL]
|
||||
PATH: /SECURITY.md
|
||||
VERSION: 04.04.01
|
||||
BRIEF: Security vulnerability reporting and handling policy
|
||||
-->
|
||||
|
||||
# Security Policy
|
||||
|
||||
## Purpose and Scope
|
||||
|
||||
This document defines the security vulnerability reporting, response, and disclosure policy for [PROJECT_NAME] and all repositories governed by these standards. It establishes the authoritative process for responsible disclosure, assessment, remediation, and communication of security issues.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Security updates are provided for the following versions:
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| [X.x.x] | :white_check_mark: |
|
||||
| < [X.0] | :x: |
|
||||
|
||||
Only the current major version receives security updates. Users should upgrade to the latest supported version to receive security patches.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
### Where to Report
|
||||
|
||||
**DO NOT** create public GitHub issues for security vulnerabilities.
|
||||
|
||||
Report security vulnerabilities privately to:
|
||||
|
||||
**Email**: `security@[DOMAIN]`
|
||||
|
||||
**Subject Line**: `[SECURITY] Brief Description`
|
||||
|
||||
### What to Include
|
||||
|
||||
A complete vulnerability report should include:
|
||||
|
||||
1. **Description**: Clear explanation of the vulnerability
|
||||
2. **Impact**: Potential security impact and severity assessment
|
||||
3. **Affected Versions**: Which versions are vulnerable
|
||||
4. **Reproduction Steps**: Detailed steps to reproduce the issue
|
||||
5. **Proof of Concept**: Code, configuration, or demonstration (if applicable)
|
||||
6. **Suggested Fix**: Proposed remediation (if known)
|
||||
7. **Disclosure Timeline**: Your expectations for public disclosure
|
||||
|
||||
### Response Timeline
|
||||
|
||||
* **Initial Response**: Within 3 business days
|
||||
* **Assessment Complete**: Within 7 business days
|
||||
* **Fix Timeline**: Depends on severity (see below)
|
||||
* **Disclosure**: Coordinated with reporter
|
||||
|
||||
## Severity Classification
|
||||
|
||||
Vulnerabilities are classified using the following severity levels:
|
||||
|
||||
### Critical
|
||||
* Remote code execution
|
||||
* Authentication bypass
|
||||
* Data breach or exposure of sensitive information
|
||||
* **Fix Timeline**: 7 days
|
||||
|
||||
### High
|
||||
* Privilege escalation
|
||||
* SQL injection or command injection
|
||||
* Cross-site scripting (XSS) with significant impact
|
||||
* **Fix Timeline**: 14 days
|
||||
|
||||
### Medium
|
||||
* Information disclosure (limited scope)
|
||||
* Denial of service
|
||||
* Security misconfigurations with moderate impact
|
||||
* **Fix Timeline**: 30 days
|
||||
|
||||
### Low
|
||||
* Security best practice violations
|
||||
* Minor information leaks
|
||||
* Issues requiring user interaction or complex preconditions
|
||||
* **Fix Timeline**: 60 days or next release
|
||||
|
||||
## Remediation Process
|
||||
|
||||
1. **Acknowledgment**: Security team confirms receipt and begins investigation
|
||||
2. **Assessment**: Vulnerability is validated, severity assigned, and impact analyzed
|
||||
3. **Development**: Security patch is developed and tested
|
||||
4. **Review**: Patch undergoes security review and validation
|
||||
5. **Release**: Fixed version is released with security advisory
|
||||
6. **Disclosure**: Public disclosure follows coordinated timeline
|
||||
|
||||
## Security Advisories
|
||||
|
||||
Security advisories are published via:
|
||||
|
||||
* GitHub Security Advisories
|
||||
* Release notes and CHANGELOG.md
|
||||
* Security mailing list (when established)
|
||||
|
||||
Advisories include:
|
||||
|
||||
* CVE identifier (if applicable)
|
||||
* Severity rating
|
||||
* Affected versions
|
||||
* Fixed versions
|
||||
* Mitigation steps
|
||||
* Attribution (with reporter consent)
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
For repositories adopting MokoStandards:
|
||||
|
||||
### Required Controls
|
||||
|
||||
* Enable GitHub security features (Dependabot, code scanning)
|
||||
* Implement branch protection on `main`
|
||||
* Require code review for all changes
|
||||
* Enforce signed commits (recommended)
|
||||
* Use secrets management (never commit credentials)
|
||||
* Maintain security documentation
|
||||
* Follow secure coding standards defined in `/docs/policy/`
|
||||
|
||||
### CI/CD Security
|
||||
|
||||
* Validate all inputs
|
||||
* Sanitize outputs
|
||||
* Use least privilege access
|
||||
* Pin dependencies with hash verification
|
||||
* Scan for vulnerabilities in dependencies
|
||||
* Audit third-party actions and tools
|
||||
|
||||
#### Automated Security Scanning
|
||||
|
||||
All repositories MUST implement:
|
||||
|
||||
**CodeQL Analysis**:
|
||||
* Enabled for all supported languages (Python, JavaScript, TypeScript, Java, C/C++, C#, Go, Ruby)
|
||||
* Runs on: push to main, pull requests, weekly schedule
|
||||
* Query sets: `security-extended` and `security-and-quality`
|
||||
* Configuration: `.github/workflows/codeql-analysis.yml`
|
||||
|
||||
**Dependabot Security Updates**:
|
||||
* Weekly scans for vulnerable dependencies
|
||||
* Automated pull requests for security patches
|
||||
* Configuration: `.github/dependabot.yml`
|
||||
|
||||
**Secret Scanning**:
|
||||
* Enabled by default with push protection
|
||||
* Prevents accidental credential commits
|
||||
* Partner patterns enabled
|
||||
|
||||
**Dependency Review**:
|
||||
* Required for all pull requests
|
||||
* Blocks introduction of known vulnerable dependencies
|
||||
* Automatic license compliance checking
|
||||
|
||||
See [Security Scanning Policy](docs/policy/security-scanning.md) for detailed requirements.
|
||||
|
||||
### Dependency Management
|
||||
|
||||
* Keep dependencies up to date
|
||||
* Monitor security advisories for dependencies
|
||||
* Remove unused dependencies
|
||||
* Audit new dependencies before adoption
|
||||
* Document security-critical dependencies
|
||||
|
||||
## Compliance and Governance
|
||||
|
||||
This security policy is binding for all repositories governed by MokoStandards. Deviations require documented justification and approval from the Security Owner.
|
||||
|
||||
Security policies are reviewed and updated at least annually or following significant security incidents.
|
||||
|
||||
## Attribution and Recognition
|
||||
|
||||
We acknowledge and appreciate responsible disclosure. With your permission, we will:
|
||||
|
||||
* Credit you in security advisories
|
||||
* List you in CHANGELOG.md for the fix release
|
||||
* Recognize your contribution publicly (if desired)
|
||||
|
||||
## Contact and Escalation
|
||||
|
||||
* **Security Team**: security@[DOMAIN]
|
||||
* **Primary Contact**: [CONTACT_EMAIL]
|
||||
* **Escalation**: For urgent matters requiring immediate attention, contact the maintainer directly via GitHub
|
||||
|
||||
## Out of Scope
|
||||
|
||||
The following are explicitly out of scope:
|
||||
|
||||
* Issues in third-party dependencies (report directly to maintainers)
|
||||
* Social engineering attacks
|
||||
* Physical security issues
|
||||
* Denial of service via resource exhaustion without amplification
|
||||
* Issues requiring physical access to systems
|
||||
* Theoretical vulnerabilities without proof of exploitability
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
| ------------ | ----------------------------------------------- |
|
||||
| Document | Security Policy |
|
||||
| Path | /SECURITY.md |
|
||||
| Repository | [REPOSITORY_URL] |
|
||||
| Owner | [OWNER_NAME] |
|
||||
| Scope | Security vulnerability handling |
|
||||
| Applies To | All repositories governed by MokoStandards |
|
||||
| Status | Active |
|
||||
| Effective | [YYYY-MM-DD] |
|
||||
|
||||
## Revision History
|
||||
|
||||
| Date | Change Description | Author |
|
||||
| ---------- | ------------------------------------------------- | --------------- |
|
||||
| [YYYY-MM-DD] | Initial creation | [AUTHOR_NAME] |
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"mokoconsulting-tech/enterprise": "^4.0"
|
||||
"mokoconsulting-tech/enterprise": "dev-version/04.02.00"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^10.5",
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<!--
|
||||
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
|
||||
|
||||
# FILE INFORMATION
|
||||
DEFGROUP: MokoJoomHero.Documentation
|
||||
INGROUP: MokoStandards.Templates
|
||||
REPO: https://github.com/mokoconsulting-tech/MokoJoomHero
|
||||
PATH: /docs/update-server.md
|
||||
VERSION: 04.04.00
|
||||
BRIEF: How this extension's Joomla update server file (update.xml) is managed
|
||||
-->
|
||||
|
||||
# Joomla Update Server
|
||||
|
||||
[](https://github.com/mokoconsulting-tech/MokoStandards)
|
||||
|
||||
This document explains how `update.xml` is automatically managed for this Joomla extension following the [Joomla Update Server specification](https://docs.joomla.org/Deploying_an_Update_Server).
|
||||
|
||||
## How It Works
|
||||
|
||||
Joomla checks for extension updates by fetching an XML file from the URL defined in the `<updateservers>` tag in the extension's XML manifest. MokoStandards generates this file automatically.
|
||||
|
||||
### Automatic Generation
|
||||
|
||||
| Event | Workflow | `<tag>` | `<version>` |
|
||||
|-------|----------|---------|-------------|
|
||||
| Merge to `main` | `auto-release.yml` | `stable` | `XX.YY.ZZ` |
|
||||
| Push to `dev/**` | `deploy-dev.yml` | `development` | `development` |
|
||||
| Push to `rc/**` | `deploy-dev.yml` | `rc` | `XX.YY.ZZ-rc` |
|
||||
|
||||
### Generated XML Structure
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<updates>
|
||||
<update>
|
||||
<name>Extension Name</name>
|
||||
<description>Extension Name update</description>
|
||||
<element>com_extensionname</element>
|
||||
<type>component</type>
|
||||
<version>01.02.03</version>
|
||||
<client>site</client>
|
||||
<folder>system</folder> <!-- plugins only -->
|
||||
<tags>
|
||||
<tag>stable</tag>
|
||||
</tags>
|
||||
<infourl title="Extension Name">https://github.com/.../releases/tag/v01.02.03</infourl>
|
||||
<downloads>
|
||||
<downloadurl type="full" format="zip">https://github.com/.../releases/download/v01.02.03/com_ext-01.02.03.zip</downloadurl>
|
||||
</downloads>
|
||||
<targetplatform name="joomla" version="((4\.[3-9])|(5\.[0-9]))" />
|
||||
<php_minimum>8.1</php_minimum> <!-- if present in manifest -->
|
||||
<maintainer>Moko Consulting</maintainer>
|
||||
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||
</update>
|
||||
</updates>
|
||||
```
|
||||
|
||||
### Metadata Source
|
||||
|
||||
All metadata is extracted from the extension's XML manifest (`src/*.xml`) at build time:
|
||||
|
||||
| XML Element | Source | Notes |
|
||||
|-------------|--------|-------|
|
||||
| `<name>` | `<name>` in manifest | Extension display name |
|
||||
| `<element>` | `<element>` in manifest | Must match installed extension identifier |
|
||||
| `<type>` | `type` attribute on `<extension>` | `component`, `module`, `plugin`, `library`, `package`, `template` |
|
||||
| `<client>` | `client` attribute on `<extension>` | `site` or `administrator` — **required for plugins and modules** |
|
||||
| `<folder>` | `group` attribute on `<extension>` | Plugin group (e.g., `system`, `content`) — **required for plugins** |
|
||||
| `<targetplatform>` | `<targetplatform>` in manifest | Falls back to Joomla 4.3+ / 5.x if not specified |
|
||||
| `<php_minimum>` | `<php_minimum>` in manifest | Included only if present |
|
||||
|
||||
### Extension Manifest Setup
|
||||
|
||||
Your XML manifest must include an `<updateservers>` tag pointing to the `update.xml` on the `main` branch:
|
||||
|
||||
```xml
|
||||
<extension type="component" client="site" method="upgrade">
|
||||
<name>My Extension</name>
|
||||
<element>com_myextension</element>
|
||||
<!-- ... -->
|
||||
<updateservers>
|
||||
<server type="extension" name="My Extension Updates">
|
||||
https://raw.githubusercontent.com/mokoconsulting-tech/MokoJoomHero/main/update.xml
|
||||
</server>
|
||||
</updateservers>
|
||||
</extension>
|
||||
```
|
||||
|
||||
### Branch Lifecycle
|
||||
|
||||
```
|
||||
dev/XX.YY.ZZ → rc/XX.YY.ZZ → main → version/XX.YY
|
||||
(development) (rc) (stable) (frozen snapshot)
|
||||
```
|
||||
|
||||
1. **Development** (`dev/**`): `update.xml` with `<tag>development</tag>`, download points to branch archive
|
||||
2. **Release Candidate** (`rc/**`): `update.xml` with `<tag>rc</tag>`, version set to `XX.YY.ZZ-rc`
|
||||
3. **Stable Release** (merge to `main`): `update.xml` with `<tag>stable</tag>`, download points to GitHub Release asset
|
||||
4. **Frozen Snapshot** (`version/XX.YY`): immutable, never force-pushed
|
||||
|
||||
### Health Checks
|
||||
|
||||
The `repo_health.yml` workflow verifies on every commit:
|
||||
|
||||
- `update.xml` exists in the repository root
|
||||
- XML manifest exists with `<extension>` tag
|
||||
- `<version>`, `<name>`, `<author>`, `<namespace>` tags present
|
||||
- Extension `type` attribute is valid
|
||||
- Language `.ini` files exist
|
||||
- `index.html` directory listing protection in `src/`, `src/admin/`, `src/site/`
|
||||
|
||||
---
|
||||
|
||||
*Managed by [MokoStandards](https://github.com/mokoconsulting-tech/MokoStandards). See [docs/workflows/update-server.md](https://github.com/mokoconsulting-tech/MokoStandards/blob/main/docs/workflows/update-server.md) for the full specification.*
|
||||
Reference in New Issue
Block a user