diff --git a/.github/.mokostandards b/.github/.mokostandards new file mode 100644 index 0000000..ed4a1bd --- /dev/null +++ b/.github/.mokostandards @@ -0,0 +1,20 @@ +# Copyright (C) 2026 Moko Consulting +# 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" diff --git a/.github/ISSUE_TEMPLATE/firewall-request.md b/.github/ISSUE_TEMPLATE/firewall-request.md index 4a43395..38be866 100644 --- a/.github/ISSUE_TEMPLATE/firewall-request.md +++ b/.github/ISSUE_TEMPLATE/firewall-request.md @@ -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'] --- diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index e17850b..74df7a0 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -3,7 +3,7 @@ name: Question about: Ask a question about usage, features, or best practices title: '[QUESTION] ' labels: ['question'] -assignees: [] +assignees: ['jmiller-moko'] --- diff --git a/.github/ISSUE_TEMPLATE/request-license.md b/.github/ISSUE_TEMPLATE/request-license.md index 52c3b74..a9c87a7 100644 --- a/.github/ISSUE_TEMPLATE/request-license.md +++ b/.github/ISSUE_TEMPLATE/request-license.md @@ -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'] --- diff --git a/.github/workflows/auto-dev-issue.yml b/.github/workflows/auto-dev-issue.yml new file mode 100644 index 0000000..8da1d1a --- /dev/null +++ b/.github/workflows/auto-dev-issue.yml @@ -0,0 +1,102 @@ +# Copyright (C) 2026 Moko Consulting +# +# 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 diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 0000000..6afd4cd --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,439 @@ +# Copyright (C) 2026 Moko Consulting +# +# 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 )║ +# ║ 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 '/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 ']+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 '/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 '\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "${{ github.event.repository.name }}") + EXT_TYPE=$(grep -oP ']+type="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "component") + EXT_ELEMENT=$(grep -oP '\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "") + EXT_CLIENT=$(grep -oP ']+client="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "") + EXT_FOLDER=$(grep -oP ']+group="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "") + TARGET_PLATFORM=$(grep -oP '' "$MANIFEST" 2>/dev/null | head -1 || echo "") + PHP_MINIMUM=$(grep -oP '\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 site + CLIENT_TAG="" + if [ -n "$EXT_CLIENT" ]; then + CLIENT_TAG="${EXT_CLIENT}" + elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then + CLIENT_TAG="site" + 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="${EXT_FOLDER}" + fi + + # Build targetplatform (fallback to Joomla 4+5 if not in manifest) + if [ -z "$TARGET_PLATFORM" ]; then + TARGET_PLATFORM='' + fi + + # Build php_minimum tag + PHP_TAG="" + if [ -n "$PHP_MINIMUM" ]; then + PHP_TAG="${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' '' + printf '%s\n' '' + printf '%s\n' ' ' + printf '%s\n' " ${EXT_NAME}" + printf '%s\n' " ${EXT_NAME} update" + printf '%s\n' " ${EXT_ELEMENT}" + printf '%s\n' " ${EXT_TYPE}" + printf '%s\n' " ${VERSION}" + [ -n "$CLIENT_TAG" ] && printf '%s\n' " ${CLIENT_TAG}" + [ -n "$FOLDER_TAG" ] && printf '%s\n' " ${FOLDER_TAG}" + printf '%s\n' ' ' + printf '%s\n' ' stable' + printf '%s\n' ' ' + printf '%s\n' " ${INFO_URL}" + printf '%s\n' ' ' + printf '%s\n' " ${DOWNLOAD_URL}" + printf '%s\n' ' ' + printf '%s\n' " ${TARGET_PLATFORM}" + [ -n "$PHP_TAG" ] && printf '%s\n' " ${PHP_TAG}" + printf '%s\n' ' Moko Consulting' + printf '%s\n' ' https://mokoconsulting.tech' + printf '%s\n' ' ' + printf '%s\n' '' + } > 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] " + 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 diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 04c7ef9..7b591b6 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -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. diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml new file mode 100644 index 0000000..8ea02a3 --- /dev/null +++ b/.github/workflows/deploy-demo.yml @@ -0,0 +1,731 @@ +# Copyright (C) 2026 Moko Consulting +# +# 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 . +# +# 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' + 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 '/dev/null | head -1 || true) + if [ -n "$MANIFEST" ]; then + EXT_NAME=$(grep -oP '\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "${{ github.event.repository.name }}") + EXT_TYPE=$(grep -oP ']+type="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "component") + EXT_ELEMENT=$(grep -oP '\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || basename "$MANIFEST" .xml) + EXT_CLIENT=$(grep -oP ']+client="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "") + EXT_FOLDER=$(grep -oP ']+group="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "") + TARGET_PLATFORM=$(grep -oP '' "$MANIFEST" 2>/dev/null | head -1 || true) + [ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM='' + + CLIENT_TAG="" + if [ -n "$EXT_CLIENT" ]; then CLIENT_TAG="${EXT_CLIENT}"; elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then CLIENT_TAG="site"; fi + FOLDER_TAG="" + if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then FOLDER_TAG="${EXT_FOLDER}"; fi + + DOWNLOAD_URL="https://github.com/${REPO}/releases/download/v${VERSION}/${EXT_ELEMENT}-${VERSION}.zip" + { + printf '%s\n' '' + printf '%s\n' '' + printf '%s\n' ' ' + printf '%s\n' " ${EXT_NAME}" + printf '%s\n' " ${EXT_NAME} update" + printf '%s\n' " ${EXT_ELEMENT}" + printf '%s\n' " ${EXT_TYPE}" + printf '%s\n' " ${VERSION}" + [ -n "$CLIENT_TAG" ] && printf '%s\n' " ${CLIENT_TAG}" + [ -n "$FOLDER_TAG" ] && printf '%s\n' " ${FOLDER_TAG}" + printf '%s\n' ' ' + printf '%s\n' ' stable' + printf '%s\n' ' ' + printf '%s\n' " https://github.com/${REPO}" + printf '%s\n' ' ' + printf '%s\n' " ${DOWNLOAD_URL}" + printf '%s\n' ' ' + printf '%s\n' " ${TARGET_PLATFORM}" + printf '%s\n' ' Moko Consulting' + printf '%s\n' ' https://mokoconsulting.tech' + printf '%s\n' ' ' + printf '%s\n' '' + } > 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 diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index c93b7a9..07fdcd6 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -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 '/dev/null | head -1 || true) + if [ -n "$MANIFEST" ]; then + EXT_NAME=$(grep -oP '\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "${{ github.event.repository.name }}") + EXT_TYPE=$(grep -oP ']+type="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "component") + EXT_ELEMENT=$(grep -oP '\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || basename "$MANIFEST" .xml) + EXT_CLIENT=$(grep -oP ']+client="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "") + EXT_FOLDER=$(grep -oP ']+group="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "") + TARGET_PLATFORM=$(grep -oP '' "$MANIFEST" 2>/dev/null | head -1 || true) + [ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM='' + + CLIENT_TAG="" + if [ -n "$EXT_CLIENT" ]; then + CLIENT_TAG="${EXT_CLIENT}" + elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then + CLIENT_TAG="site" + fi + + FOLDER_TAG="" + if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then + FOLDER_TAG="${EXT_FOLDER}" + fi + + DOWNLOAD_URL="https://github.com/${REPO}/archive/refs/heads/${BRANCH}.zip" + + { + printf '%s\n' '' + printf '%s\n' '' + printf '%s\n' ' ' + printf '%s\n' " ${EXT_NAME}" + printf '%s\n' " ${EXT_NAME} ${STABILITY} build" + printf '%s\n' " ${EXT_ELEMENT}" + printf '%s\n' " ${EXT_TYPE}" + printf '%s\n' " ${VERSION_LABEL}" + [ -n "$CLIENT_TAG" ] && printf '%s\n' " ${CLIENT_TAG}" + [ -n "$FOLDER_TAG" ] && printf '%s\n' " ${FOLDER_TAG}" + printf '%s\n' ' ' + printf '%s\n' " ${STABILITY}" + printf '%s\n' ' ' + printf '%s\n' " https://github.com/${REPO}/tree/${BRANCH}" + printf '%s\n' ' ' + printf '%s\n' " ${DOWNLOAD_URL}" + printf '%s\n' ' ' + printf '%s\n' " ${TARGET_PLATFORM}" + printf '%s\n' ' Moko Consulting' + printf '%s\n' ' https://mokoconsulting.tech' + printf '%s\n' ' ' + printf '%s\n' '' + } > 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 diff --git a/.github/workflows/deploy-rs.yml b/.github/workflows/deploy-rs.yml new file mode 100644 index 0000000..9f3c212 --- /dev/null +++ b/.github/workflows/deploy-rs.yml @@ -0,0 +1,659 @@ +# Copyright (C) 2026 Moko Consulting +# +# 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 . +# +# 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' + 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 diff --git a/.github/workflows/enterprise-firewall-setup.yml b/.github/workflows/enterprise-firewall-setup.yml index 168ce3b..dbce33c 100644 --- a/.github/workflows/enterprise-firewall-setup.yml +++ b/.github/workflows/enterprise-firewall-setup.yml @@ -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. diff --git a/.github/workflows/repo_health.yml b/.github/workflows/repo_health.yml new file mode 100644 index 0000000..946349a --- /dev/null +++ b/.github/workflows/repo_health.yml @@ -0,0 +1,795 @@ +# ============================================================================ +# Copyright (C) 2025 Moko Consulting +# +# 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/ 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/)") + 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 tag)") + else + # Check tag exists + if ! grep -qP '' "${MANIFEST}"; then + joomla_findings+=("XML manifest: 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 tag + if ! grep -qP '' "${MANIFEST}"; then + joomla_findings+=("XML manifest: tag missing") + fi + # Check tag + if ! grep -qP '' "${MANIFEST}"; then + joomla_findings+=("XML manifest: tag missing") + fi + # Check for Joomla 4+ + if ! grep -qP ' 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}" diff --git a/.github/workflows/repository-cleanup.yml b/.github/workflows/repository-cleanup.yml new file mode 100644 index 0000000..f53bdff --- /dev/null +++ b/.github/workflows/repository-cleanup.yml @@ -0,0 +1,519 @@ +# Copyright (C) 2026 Moko Consulting +# +# 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] " + 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 '^$/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] " + 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] " + 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] " + 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 diff --git a/.github/workflows/standards-compliance.yml b/.github/workflows/standards-compliance.yml index bc0516a..0d85ae4 100644 --- a/.github/workflows/standards-compliance.yml +++ b/.github/workflows/standards-compliance.yml @@ -5,16 +5,42 @@ # INGROUP: MokoStandards.Compliance # REPO: https://github.com/mokoconsulting-tech/MokoStandards # PATH: /.github/workflows/standards-compliance.yml -# VERSION: 04.00.05 +# VERSION: 04.04.01 # BRIEF: MokoStandards compliance validation workflow # NOTE: Validates repository structure, documentation, and coding standards name: Standards Compliance +# ╔════════════════════════════════════════════════════════════════════════╗ +# ║ MOKOSTANDARDS COMPLIANCE WORKFLOW ║ +# ╠════════════════════════════════════════════════════════════════════════╣ +# ║ ║ +# ║ 28 checks across 4 priority tiers: ║ +# ║ ║ +# ║ TIER 1 — CRITICAL (must pass) ║ +# ║ secret-scanning, license-compliance, repository-structure, ║ +# ║ coding-standards, version-consistency ║ +# ║ ║ +# ║ TIER 2 — IMPORTANT (should pass) ║ +# ║ workflow-validation, documentation-quality, readme-completeness, ║ +# ║ git-hygiene, script-integrity ║ +# ║ ║ +# ║ TIER 3 — QUALITY (code metrics) ║ +# ║ line-length, file-naming, insecure-patterns, complexity, ║ +# ║ duplication, dead-code ║ +# ║ ║ +# ║ TIER 4 — SUPPLEMENTARY (informational) ║ +# ║ file-size, binary, todo, deps, links, api-docs, accessibility, ║ +# ║ performance, enterprise, health, terraform ║ +# ║ ║ +# ║ File size: warning >15MB, critical >20MB ║ +# ║ Exempt: .mmdb, .woff2, .woff, .ttf, .otf ║ +# ║ ║ +# ╚════════════════════════════════════════════════════════════════════════╝ + env: - ACTIONS_STEP_DEBUG: true - ACTIONS_RUNNER_DEBUG: true - WORKFLOW_VERSION: "04.00.05" + WORKFLOW_VERSION: "04.04.01" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true # MokoStandards Policy Compliance: # - File formatting: Enforces organizational coding standards @@ -63,22 +89,188 @@ env: on: push: - branches: - - main - - dev/** - - rc/** + branches: [main, dev/**, rc/**, version/**] pull_request: - branches: - - main - - dev/** - - rc/** + branches: [main, dev/**, rc/**] workflow_dispatch: permissions: contents: read pull-requests: write + issues: write jobs: + # ════════════════════════════════════════════════════════════════════════ + # TIER 1 — CRITICAL (must pass, blocks merge) + # ════════════════════════════════════════════════════════════════════════ + secret-scanning: + name: Secret Scanning + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Scan for Secrets + run: | + set -x + echo "## 🔒 Secret Scanning" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Scanning for hardcoded secrets and credentials." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Define secret patterns + VIOLATIONS=0 + + # Check for common secret patterns + echo "### Secret Patterns" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Helper: scan with a pattern, show results with file:line, return count + scan_pattern() { + local label="$1" icon="$2" tmpfile="$3" + local count=0 + if [ -f "$tmpfile" ]; then + count=$(wc -l < "$tmpfile") + fi + if [ "$count" -gt 0 ]; then + echo "${icon} **${label}**: ${count} finding(s)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + echo "View locations" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| File | Line | Match |" >> $GITHUB_STEP_SUMMARY + echo "|------|------|-------|" >> $GITHUB_STEP_SUMMARY + head -20 "$tmpfile" | while IFS= read -r line; do + FILE=$(echo "$line" | cut -d: -f1 | sed 's|^\./||') + LINENO=$(echo "$line" | cut -d: -f2) + MATCH=$(echo "$line" | cut -d: -f3- | head -c 80 | sed 's/|/\\|/g') + echo "| \`${FILE}\` | ${LINENO} | \`${MATCH}\` |" >> $GITHUB_STEP_SUMMARY + done + if [ "$count" -gt 20 ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "*... and $((count - 20)) more*" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + VIOLATIONS=$((VIOLATIONS + count)) + fi + } + + # Pattern 1: password/secret assignments + grep -r -n -E "(password|passwd|pwd|secret|api[_-]?key|token).*=.*['\"]" . \ + --include="*.php" --include="*.py" --include="*.js" --include="*.ts" \ + --exclude-dir=".git" --exclude-dir="vendor" --exclude-dir="node_modules" 2>/dev/null | \ + grep -v -E '(test|example|sample|getenv|getString|getArgument|config\[|/\.\*/|^\s*//|^\s*\*|CREDENTIAL_PATTERNS|SecurityValidator|SECRET_PATTERN|===|!==|ApiClient|str_contains|gen_wrappers)' | \ + grep -v "= ''" | grep -v '= ""' | grep -v '\$this->config' > /tmp/secrets1.txt 2>/dev/null || true + scan_pattern "Secret assignments" "⚠️" /tmp/secrets1.txt + + # Pattern 2: Private keys + grep -r -n "BEGIN.*PRIVATE KEY" . \ + --include="*.pem" --include="*.key" --include="*.txt" \ + --exclude-dir=".git" --exclude-dir="vendor" --exclude-dir="node_modules" 2>/dev/null > /tmp/secrets2.txt || true + scan_pattern "Private keys" "❌" /tmp/secrets2.txt + + # Pattern 3: AWS keys + grep -r -n -E "AKIA[0-9A-Z]{16}" . \ + --include="*.php" --include="*.py" --include="*.js" --include="*.txt" --include="*.env" \ + --exclude-dir=".git" --exclude-dir="vendor" --exclude-dir="node_modules" 2>/dev/null > /tmp/secrets3.txt || true + scan_pattern "AWS access keys" "❌" /tmp/secrets3.txt + + # Pattern 4: GitHub tokens + grep -r -n -E "gh[ps]_[a-zA-Z0-9]{36}" . \ + --include="*.php" --include="*.py" --include="*.js" --include="*.txt" --include="*.env" \ + --exclude-dir=".git" --exclude-dir="vendor" --exclude-dir="node_modules" 2>/dev/null > /tmp/secrets4.txt || true + scan_pattern "GitHub tokens" "❌" /tmp/secrets4.txt + + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "$VIOLATIONS" -gt 0 ]; then + echo "**Total Violations**: $VIOLATIONS" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + echo "View detected secrets (file paths only)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + cat /tmp/secrets*.txt 2>/dev/null | cut -d: -f1 | sort -u >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Action Required**: Remove hardcoded secrets immediately!" >> $GITHUB_STEP_SUMMARY + echo "Use environment variables or secrets management instead." >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "✅ No hardcoded secrets detected" >> $GITHUB_STEP_SUMMARY + fi + + license-compliance: + name: License Header Validation + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Check SPDX Headers + run: | + set -x + echo "### SPDX License Header Check" >> $GITHUB_STEP_SUMMARY + + # Count source files with and without SPDX headers + TOTAL_PHP=0 + WITH_SPDX_PHP=0 + + if find . -name "*.php" -type f ! -path "./vendor/*" | head -1 | grep -q .; then + TOTAL_PHP=$(find . -name "*.php" -type f ! -path "./vendor/*" | wc -l) + WITH_SPDX_PHP=$(find . -name "*.php" -type f ! -path "./vendor/*" -exec grep -l "SPDX-License-Identifier" {} \; | wc -l) + fi + + if [ "$TOTAL_PHP" -gt 0 ]; then + PERCENT=$((WITH_SPDX_PHP * 100 / TOTAL_PHP)) + echo "- PHP files: $WITH_SPDX_PHP/$TOTAL_PHP ($PERCENT%) with SPDX headers" >> $GITHUB_STEP_SUMMARY + + if [ "$PERCENT" -lt 80 ]; then + echo "⚠️ Less than 80% of PHP files have SPDX headers" >> $GITHUB_STEP_SUMMARY + else + echo "✅ Good SPDX header coverage" >> $GITHUB_STEP_SUMMARY + fi + fi + + - name: Validate License File + run: | + set -x + echo "" >> $GITHUB_STEP_SUMMARY + echo "### License File Validation" >> $GITHUB_STEP_SUMMARY + + if [ ! -f "LICENSE" ]; then + echo "❌ LICENSE file not found" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### ❌ Validation Failed: LICENSE File Missing" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Error:** LICENSE file is required for all MokoStandards-compliant repositories" >> $GITHUB_STEP_SUMMARY + echo "**Action Required:** Add LICENSE file with appropriate open-source license (GPL-3.0-or-later recommended)" >> $GITHUB_STEP_SUMMARY + echo "" + echo "❌ ERROR: LICENSE file not found - This is a critical requirement" + exit 1 + fi + + # Check license type + if grep -qi "GNU GENERAL PUBLIC LICENSE" LICENSE; then + VERSION=$(grep -i "Version 3" LICENSE || echo "") + if [ -n "$VERSION" ]; then + echo "✅ GPL-3.0-or-later license detected" >> $GITHUB_STEP_SUMMARY + else + echo "⚠️ GPL license detected but version unclear" >> $GITHUB_STEP_SUMMARY + fi + elif grep -qi "MIT License" LICENSE; then + echo "✅ MIT license detected" >> $GITHUB_STEP_SUMMARY + elif grep -qi "Apache License" LICENSE; then + echo "✅ Apache license detected" >> $GITHUB_STEP_SUMMARY + else + echo "ℹ️ License type could not be automatically detected" >> $GITHUB_STEP_SUMMARY + fi + repository-structure: name: Repository Structure Validation runs-on: ubuntu-latest @@ -206,6 +398,387 @@ jobs: exit 1 fi + coding-standards: + name: Coding Standards Check + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Check for Tab Characters + run: | + set -x + echo "### Tab Character Detection" >> $GITHUB_STEP_SUMMARY + + # Policy: Tabs are DEFAULT. Only check for tabs in files that REQUIRE spaces. + # Languages requiring spaces: YAML, Python, Haskell, F#, CoffeeScript, Nim, JSON, RST + TABS_IN_SPACES_FILES=$(find . -type f \ + \( -name "*.yml" -o -name "*.yaml" \ + -o -name "*.py" \ + -o -name "*.hs" -o -name "*.lhs" \ + -o -name "*.fs" -o -name "*.fsx" -o -name "*.fsi" \ + -o -name "*.coffee" -o -name "*.litcoffee" \ + -o -name "*.nim" -o -name "*.nims" -o -name "*.nimble" \ + -o -name "*.json" \ + -o -name "*.rst" \) \ + ! -path "./vendor/*" \ + ! -path "./node_modules/*" \ + ! -path "./.git/*" \ + -exec grep -l $'\t' {} \; 2>/dev/null | head -10) + + if [ -n "$TABS_IN_SPACES_FILES" ]; then + echo "⚠️ Tab characters found in files that require spaces:" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "$TABS_IN_SPACES_FILES" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "These languages require spaces (tabs will break): YAML, Python, Haskell, F#, CoffeeScript, Nim, JSON, RST" >> $GITHUB_STEP_SUMMARY + echo "All other files (including .md, .ps1, LICENSE, etc.) may use tabs per MokoStandards policy" >> $GITHUB_STEP_SUMMARY + else + echo "✅ No tabs found in files requiring spaces" >> $GITHUB_STEP_SUMMARY + echo "Note: Tabs are allowed in most files (policy default). Only checked files requiring spaces." >> $GITHUB_STEP_SUMMARY + fi + + - name: Check File Encoding + run: | + set -x + echo "" >> $GITHUB_STEP_SUMMARY + echo "### File Encoding Check" >> $GITHUB_STEP_SUMMARY + + # Check for UTF-8 encoding (ASCII is a subset of UTF-8 and is acceptable) + NON_UTF8=$(find . -type f \( -name "*.php" -o -name "*.js" -o -name "*.md" \) \ + ! -path "./vendor/*" \ + ! -path "./node_modules/*" \ + ! -path "./.git/*" \ + -exec file {} \; | grep -v "UTF-8" | grep -v "ASCII" | head -5) + + if [ -n "$NON_UTF8" ]; then + echo "⚠️ Non-UTF-8 files detected:" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "$NON_UTF8" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + else + echo "✅ All source files appear to be UTF-8 encoded" >> $GITHUB_STEP_SUMMARY + fi + + - name: Check Line Endings + run: | + set -x + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Line Ending Check" >> $GITHUB_STEP_SUMMARY + + # Check for CRLF line endings + CRLF_FILES=$(find . -type f \( -name "*.php" -o -name "*.js" -o -name "*.md" \) \ + ! -path "./vendor/*" \ + ! -path "./node_modules/*" \ + ! -path "./.git/*" \ + -exec file {} \; | grep "CRLF" | head -5) + + if [ -n "$CRLF_FILES" ]; then + echo "⚠️ Files with CRLF line endings found:" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "$CRLF_FILES" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "MokoStandards requires LF line endings" >> $GITHUB_STEP_SUMMARY + else + echo "✅ Line endings are consistent (LF)" >> $GITHUB_STEP_SUMMARY + fi + + version-consistency: + name: Version Consistency Check + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up PHP + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.31.0 + with: + php-version: '8.1' + extensions: json + tools: composer + coverage: none + + - name: Install API Package + run: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}' + + - name: Run Version Consistency Check + id: version_check + run: | + set -x + echo "## 🔢 Version Consistency Validation" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Use PHP enterprise library for version consistency check + if [ -f "vendor/bin/moko" ]; then + php vendor/bin/moko check:version -- --path . --verbose 2>&1 | tee /tmp/version-check.log + EXIT_CODE=${PIPESTATUS[0]} + elif [ -f "/tmp/mokostandards/api/validate/check_version_consistency.php" ]; then + php /tmp/mokostandards/api/validate/check_version_consistency.php --path . --verbose 2>&1 | tee /tmp/version-check.log + EXIT_CODE=${PIPESTATUS[0]} + elif [ -f "api/validate/check_version_consistency.php" ]; then + php api/validate/check_version_consistency.php --path . --verbose 2>&1 | tee /tmp/version-check.log + EXIT_CODE=${PIPESTATUS[0]} + else + echo "⏭️ Install mokoconsulting-tech/enterprise via Composer for version checks" >> $GITHUB_STEP_SUMMARY + exit 0 + fi + + echo '```' >> $GITHUB_STEP_SUMMARY + cat /tmp/version-check.log >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + + if [ "$EXIT_CODE" -eq 0 ]; then + echo "✅ All version numbers are consistent" >> $GITHUB_STEP_SUMMARY + else + echo "❌ Version drift detected" >> $GITHUB_STEP_SUMMARY + exit 1 + fi + + + # ════════════════════════════════════════════════════════════════════════ + # TIER 2 — IMPORTANT (should pass) + # ════════════════════════════════════════════════════════════════════════ + workflow-validation: + name: Workflow Configuration Check + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Check Required Workflows + run: | + set -x + echo "### GitHub Actions Workflows" >> $GITHUB_STEP_SUMMARY + + WORKFLOWS_DIR=".github/workflows" + + if [ ! -d "$WORKFLOWS_DIR" ]; then + echo "❌ No workflows directory found" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### ❌ Validation Failed: Workflows Directory Missing" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Error:** .github/workflows directory is required for CI/CD automation" >> $GITHUB_STEP_SUMMARY + echo "**Action Required:** Create .github/workflows directory and add GitHub Actions workflows" >> $GITHUB_STEP_SUMMARY + echo "" + echo "❌ ERROR: .github/workflows directory not found" + exit 1 + fi + + # Check for recommended workflows + CI_FOUND=false + for wf in ci.yml build.yml ci-dolibarr.yml ci-joomla.yml; do + if [ -f "$WORKFLOWS_DIR/$wf" ]; then + echo "✅ CI workflow present ($wf)" >> $GITHUB_STEP_SUMMARY + CI_FOUND=true + break + fi + done + if [ "$CI_FOUND" = "false" ]; then + echo "⚠️ No CI workflow found (ci.yml, build.yml, ci-dolibarr.yml, or ci-joomla.yml)" >> $GITHUB_STEP_SUMMARY + fi + + if [ -f "$WORKFLOWS_DIR/codeql-analysis.yml" ]; then + echo "✅ CodeQL security scanning present" >> $GITHUB_STEP_SUMMARY + else + echo "⚠️ CodeQL workflow not found" >> $GITHUB_STEP_SUMMARY + fi + + # Check for MokoStandards-synced workflows + for wf in deploy-dev.yml deploy-demo.yml deploy-rs.yml sync-version-on-merge.yml auto-release.yml standards-compliance.yml enterprise-firewall-setup.yml; do + if [ -f "$WORKFLOWS_DIR/$wf" ]; then + echo "✅ ${wf}" >> $GITHUB_STEP_SUMMARY + else + echo "⚠️ ${wf} not found (synced from MokoStandards)" >> $GITHUB_STEP_SUMMARY + fi + done + + - name: Validate Workflow Syntax + run: | + set -x + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Workflow YAML Syntax" >> $GITHUB_STEP_SUMMARY + + INVALID=0 + for workflow in $(find .github/workflows -maxdepth 1 -type f \( -name "*.yml" -o -name "*.yaml" \) 2>/dev/null); do + if [ -f "$workflow" ]; then + if python3 -c "import yaml, sys; yaml.safe_load(open(sys.argv[1]))" "$workflow" 2>/dev/null; then + echo "✅ $(basename $workflow)" >> $GITHUB_STEP_SUMMARY + else + echo "❌ $(basename $workflow) - invalid YAML" >> $GITHUB_STEP_SUMMARY + INVALID=$((INVALID + 1)) + fi + fi + done + + if [ "$INVALID" -gt 0 ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "### ❌ Validation Failed: Invalid Workflow YAML Syntax" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Error:** $INVALID workflow file(s) have invalid YAML syntax" >> $GITHUB_STEP_SUMMARY + echo "**Action Required:** Fix YAML syntax errors in the marked workflow files" >> $GITHUB_STEP_SUMMARY + echo "**Tool:** Run \`python3 -c \"import yaml; yaml.safe_load(open('.github/workflows/FILE.yml'))\"\` locally" >> $GITHUB_STEP_SUMMARY + echo "" + echo "❌ ERROR: $INVALID workflow file(s) with invalid YAML syntax" + exit 1 + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "### ✅ All Workflow Files Have Valid YAML Syntax" >> $GITHUB_STEP_SUMMARY + echo "" + echo "✅ SUCCESS: All workflow files passed YAML validation" + + - name: Validate CodeQL Configuration + if: hashFiles('.github/workflows/codeql-analysis.yml') != '' + run: | + set -e + echo "" >> $GITHUB_STEP_SUMMARY + echo "### CodeQL Language Configuration" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Inline validation (rewritten from Python to bash for PHP-only architecture) + CODEQL_FILE=".github/workflows/codeql-analysis.yml" + + if [ ! -f "$CODEQL_FILE" ]; then + echo "⚠️ CodeQL workflow file not found" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### ⚠️ CodeQL Workflow Not Found" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Status:** CodeQL workflow file not present - skipping language validation" >> $GITHUB_STEP_SUMMARY + echo "" + echo "⚠️ INFO: CodeQL workflow not found - Skipping validation" + exit 0 + fi + + echo "**CodeQL Configuration Analysis**" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Extract configured languages from workflow + LANGUAGES=$(grep -A5 "language:" "$CODEQL_FILE" | grep -oP "(?<=')[^']+(?=')" | tr '\n' ' ' || echo "") + + # Check if this is a configuration-only scan (no languages specified) + if grep -q "category.*language:config" "$CODEQL_FILE"; then + echo "**Scan Type:** Configuration-only (no language matrix)" >> $GITHUB_STEP_SUMMARY + echo "**Status:** ✅ Valid configuration for PHP-only repository" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "This CodeQL workflow scans YAML, JSON, shell scripts for security issues." >> $GITHUB_STEP_SUMMARY + echo "PHP security is handled by SecurityValidator enterprise library." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "✅ SUCCESS: CodeQL configuration-only scan properly configured" + exit 0 + fi + + if [ -z "$LANGUAGES" ]; then + echo "❌ No languages configured in CodeQL workflow" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### ❌ Validation Failed: CodeQL Languages Not Configured" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Error:** CodeQL workflow exists but has no languages configured" >> $GITHUB_STEP_SUMMARY + echo "**Action Required:** Configure appropriate languages in codeql-analysis.yml" >> $GITHUB_STEP_SUMMARY + echo "" + echo "❌ ERROR: No languages configured in CodeQL workflow" + exit 1 + fi + + echo "**Configured Languages:** $LANGUAGES" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Validate language presence in repository + INVALID_LANGS="" + VALID_LANGS="" + + for LANG in $LANGUAGES; do + case "$LANG" in + python) + # Check for Python files (should be none in v04.00.04) + if find . -name "*.py" -type f ! -path "./.git/*" | grep -q .; then + VALID_LANGS="$VALID_LANGS python" + echo "✅ Python: Found Python files" >> $GITHUB_STEP_SUMMARY + else + INVALID_LANGS="$INVALID_LANGS python" + echo "❌ Python: No Python files found (PHP-only repository)" >> $GITHUB_STEP_SUMMARY + fi + ;; + javascript|typescript) + # Check for JS/TS files + if find . \( -name "*.js" -o -name "*.ts" -o -name "*.json" \) -type f ! -path "./.git/*" ! -path "./node_modules/*" | grep -q .; then + VALID_LANGS="$VALID_LANGS $LANG" + echo "✅ $LANG: Found JavaScript/TypeScript/JSON files" >> $GITHUB_STEP_SUMMARY + else + INVALID_LANGS="$INVALID_LANGS $LANG" + echo "⚠️ $LANG: No JavaScript/TypeScript files found" >> $GITHUB_STEP_SUMMARY + fi + ;; + java) + if find . -name "*.java" -type f ! -path "./.git/*" | grep -q .; then + VALID_LANGS="$VALID_LANGS java" + echo "✅ Java: Found Java files" >> $GITHUB_STEP_SUMMARY + else + INVALID_LANGS="$INVALID_LANGS java" + echo "⚠️ Java: No Java files found" >> $GITHUB_STEP_SUMMARY + fi + ;; + go) + if find . -name "*.go" -type f ! -path "./.git/*" | grep -q .; then + VALID_LANGS="$VALID_LANGS go" + echo "✅ Go: Found Go files" >> $GITHUB_STEP_SUMMARY + else + INVALID_LANGS="$INVALID_LANGS go" + echo "⚠️ Go: No Go files found" >> $GITHUB_STEP_SUMMARY + fi + ;; + cpp|c) + if find . \( -name "*.cpp" -o -name "*.c" -o -name "*.h" \) -type f ! -path "./.git/*" | grep -q .; then + VALID_LANGS="$VALID_LANGS $LANG" + echo "✅ $LANG: Found C/C++ files" >> $GITHUB_STEP_SUMMARY + else + INVALID_LANGS="$INVALID_LANGS $LANG" + echo "⚠️ $LANG: No C/C++ files found" >> $GITHUB_STEP_SUMMARY + fi + ;; + ruby) + if find . -name "*.rb" -type f ! -path "./.git/*" | grep -q .; then + VALID_LANGS="$VALID_LANGS ruby" + echo "✅ Ruby: Found Ruby files" >> $GITHUB_STEP_SUMMARY + else + INVALID_LANGS="$INVALID_LANGS ruby" + echo "⚠️ Ruby: No Ruby files found" >> $GITHUB_STEP_SUMMARY + fi + ;; + *) + echo "⚠️ $LANG: Unknown language, skipping validation" >> $GITHUB_STEP_SUMMARY + ;; + esac + done + + echo "" >> $GITHUB_STEP_SUMMARY + + # Report results + if [ -n "$INVALID_LANGS" ]; then + echo "**⚠️ Warning:** Some configured languages may not have corresponding files:" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "Invalid languages: $INVALID_LANGS" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Note:** This is informational. CodeQL will skip languages without source files." >> $GITHUB_STEP_SUMMARY + echo "For PHP repository (v04.00.04), JavaScript language covers JSON/YAML/shell scripts." >> $GITHUB_STEP_SUMMARY + else + echo "✅ **All configured CodeQL languages have corresponding source files**" >> $GITHUB_STEP_SUMMARY + fi + + # Always succeed - this is informational only + echo "" >> $GITHUB_STEP_SUMMARY + echo "### ✅ CodeQL Configuration Validation Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Status:** CodeQL language configuration reviewed successfully" >> $GITHUB_STEP_SUMMARY + echo "" + echo "✅ SUCCESS: CodeQL validation complete" + exit 0 + documentation-quality: name: Documentation Quality Check runs-on: ubuntu-latest @@ -407,92 +980,164 @@ jobs: echo "⚠️ No documentation index (docs/index.md or docs/README.md)" >> $GITHUB_STEP_SUMMARY fi - coding-standards: - name: Coding Standards Check + readme-completeness: + name: README Completeness Check runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Check for Tab Characters + - name: Check README Sections run: | set -x - echo "### Tab Character Detection" >> $GITHUB_STEP_SUMMARY + echo "## 📄 README Completeness Check" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY - # Policy: Tabs are DEFAULT. Only check for tabs in files that REQUIRE spaces. - # Languages requiring spaces: YAML, Python, Haskell, F#, CoffeeScript, Nim, JSON, RST - TABS_IN_SPACES_FILES=$(find . -type f \ - \( -name "*.yml" -o -name "*.yaml" \ - -o -name "*.py" \ - -o -name "*.hs" -o -name "*.lhs" \ - -o -name "*.fs" -o -name "*.fsx" -o -name "*.fsi" \ - -o -name "*.coffee" -o -name "*.litcoffee" \ - -o -name "*.nim" -o -name "*.nims" -o -name "*.nimble" \ - -o -name "*.json" \ - -o -name "*.rst" \) \ - ! -path "./vendor/*" \ - ! -path "./node_modules/*" \ - ! -path "./.git/*" \ - -exec grep -l $'\t' {} \; 2>/dev/null | head -10) - - if [ -n "$TABS_IN_SPACES_FILES" ]; then - echo "⚠️ Tab characters found in files that require spaces:" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - echo "$TABS_IN_SPACES_FILES" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - echo "These languages require spaces (tabs will break): YAML, Python, Haskell, F#, CoffeeScript, Nim, JSON, RST" >> $GITHUB_STEP_SUMMARY - echo "All other files (including .md, .ps1, LICENSE, etc.) may use tabs per MokoStandards policy" >> $GITHUB_STEP_SUMMARY - else - echo "✅ No tabs found in files requiring spaces" >> $GITHUB_STEP_SUMMARY - echo "Note: Tabs are allowed in most files (policy default). Only checked files requiring spaces." >> $GITHUB_STEP_SUMMARY + if [ ! -f "README.md" ]; then + echo "❌ README.md not found" >> $GITHUB_STEP_SUMMARY + exit 1 fi - - name: Check File Encoding + # Required sections + REQUIRED_SECTIONS=("Installation" "Usage" "Contributing" "License") + MISSING=0 + PRESENT=0 + + echo "### Required Sections" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + for section in "${REQUIRED_SECTIONS[@]}"; do + if grep -qi "##.*$section" README.md; then + echo "✅ $section" >> $GITHUB_STEP_SUMMARY + PRESENT=$((PRESENT + 1)) + else + echo "❌ $section" >> $GITHUB_STEP_SUMMARY + MISSING=$((MISSING + 1)) + fi + done + + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Completeness**: $PRESENT/${#REQUIRED_SECTIONS[@]} required sections present" >> $GITHUB_STEP_SUMMARY + + if [ "$MISSING" -gt 0 ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Action Required**: Add missing sections to README.md" >> $GITHUB_STEP_SUMMARY + exit 1 + fi + + # ============================================================================ + # PHASE 3: Future Enhancements + # ============================================================================ + + git-hygiene: + name: Git Repository Hygiene + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Check .gitignore + run: | + set -x + echo "### .gitignore Validation" >> $GITHUB_STEP_SUMMARY + + if [ ! -f ".gitignore" ]; then + echo "⚠️ .gitignore file not found" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### ⚠️ Warning: .gitignore Not Found" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Status:** .gitignore file is recommended but not required" >> $GITHUB_STEP_SUMMARY + echo "**Recommendation:** Add .gitignore to exclude build artifacts, dependencies, and temporary files" >> $GITHUB_STEP_SUMMARY + echo "" + echo "⚠️ WARNING: .gitignore file not found - Continuing validation" + exit 0 + fi + + # Check for common exclusions + MISSING="" + grep -q "vendor/" .gitignore || MISSING="${MISSING}vendor/ " + grep -q "node_modules/" .gitignore || MISSING="${MISSING}node_modules/ " + + if [ -n "$MISSING" ]; then + echo "⚠️ .gitignore may be missing common exclusions: $MISSING" >> $GITHUB_STEP_SUMMARY + else + echo "✅ .gitignore appears complete" >> $GITHUB_STEP_SUMMARY + fi + + - name: Check for Large Files run: | set -x echo "" >> $GITHUB_STEP_SUMMARY - echo "### File Encoding Check" >> $GITHUB_STEP_SUMMARY + echo "### Large File Detection" >> $GITHUB_STEP_SUMMARY - # Check for UTF-8 encoding (ASCII is a subset of UTF-8 and is acceptable) - NON_UTF8=$(find . -type f \( -name "*.php" -o -name "*.js" -o -name "*.md" \) \ - ! -path "./vendor/*" \ - ! -path "./node_modules/*" \ - ! -path "./.git/*" \ - -exec file {} \; | grep -v "UTF-8" | grep -v "ASCII" | head -5) + # Find files larger than 1MB + LARGE_FILES=$(find . -type f -size +1M ! -path "./.git/*" ! -path "./vendor/*" ! -path "./node_modules/*" | head -5) - if [ -n "$NON_UTF8" ]; then - echo "⚠️ Non-UTF-8 files detected:" >> $GITHUB_STEP_SUMMARY + if [ -n "$LARGE_FILES" ]; then + echo "⚠️ Large files detected (>1MB):" >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - echo "$NON_UTF8" >> $GITHUB_STEP_SUMMARY + echo "$LARGE_FILES" >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "Consider using Git LFS for large binary files" >> $GITHUB_STEP_SUMMARY else - echo "✅ All source files appear to be UTF-8 encoded" >> $GITHUB_STEP_SUMMARY + echo "✅ No unusually large files detected" >> $GITHUB_STEP_SUMMARY fi - - name: Check Line Endings + script-integrity: + name: Script Integrity Validation + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.x' + + - name: Validate Script Integrity + id: script_check run: | set -x + echo "## 🔐 Script Integrity Validation" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "### Line Ending Check" >> $GITHUB_STEP_SUMMARY - # Check for CRLF line endings - CRLF_FILES=$(find . -type f \( -name "*.php" -o -name "*.js" -o -name "*.md" \) \ - ! -path "./vendor/*" \ - ! -path "./node_modules/*" \ - ! -path "./.git/*" \ - -exec file {} \; | grep "CRLF" | head -5) + if [ -f "api/.script-registry.json" ]; then + echo "### Critical Scripts" >> $GITHUB_STEP_SUMMARY + php api/maintenance/update_sha_hashes.php \ + --dry-run --verbose | tee /tmp/script-validation.log - if [ -n "$CRLF_FILES" ]; then - echo "⚠️ Files with CRLF line endings found:" >> $GITHUB_STEP_SUMMARY + EXIT_CODE=$? + + echo "" >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - echo "$CRLF_FILES" >> $GITHUB_STEP_SUMMARY + cat /tmp/script-validation.log >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - echo "MokoStandards requires LF line endings" >> $GITHUB_STEP_SUMMARY + + if [ "$EXIT_CODE" -eq 0 ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "✅ All critical scripts validated successfully!" >> $GITHUB_STEP_SUMMARY + exit 0 + else + echo "" >> $GITHUB_STEP_SUMMARY + echo "❌ Script integrity violations detected" >> $GITHUB_STEP_SUMMARY + echo "**Action Required:** Review validation report and update registry" >> $GITHUB_STEP_SUMMARY + exit 1 + fi else - echo "✅ Line endings are consistent (LF)" >> $GITHUB_STEP_SUMMARY + echo "ℹ️ Script registry not found - skipping integrity check" >> $GITHUB_STEP_SUMMARY + exit 0 fi + + # ════════════════════════════════════════════════════════════════════════ + # TIER 3 — QUALITY (code quality metrics) + # ════════════════════════════════════════════════════════════════════════ line-length-validation: name: Line Length Check runs-on: ubuntu-latest @@ -588,550 +1233,338 @@ jobs: echo "Line length standards help maintain code readability." >> $GITHUB_STEP_SUMMARY echo "Exceptions documented in: \`docs/policy/coding-style-guide.md\`" >> $GITHUB_STEP_SUMMARY - license-compliance: - name: License Header Validation + file-naming-standards: + name: File Naming Standards runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Check SPDX Headers - run: | - set -x - echo "### SPDX License Header Check" >> $GITHUB_STEP_SUMMARY - - # Count source files with and without SPDX headers - TOTAL_PHP=0 - WITH_SPDX_PHP=0 - - if find . -name "*.php" -type f ! -path "./vendor/*" | head -1 | grep -q .; then - TOTAL_PHP=$(find . -name "*.php" -type f ! -path "./vendor/*" | wc -l) - WITH_SPDX_PHP=$(find . -name "*.php" -type f ! -path "./vendor/*" -exec grep -l "SPDX-License-Identifier" {} \; | wc -l) - fi - - if [ "$TOTAL_PHP" -gt 0 ]; then - PERCENT=$((WITH_SPDX_PHP * 100 / TOTAL_PHP)) - echo "- PHP files: $WITH_SPDX_PHP/$TOTAL_PHP ($PERCENT%) with SPDX headers" >> $GITHUB_STEP_SUMMARY - - if [ "$PERCENT" -lt 80 ]; then - echo "⚠️ Less than 80% of PHP files have SPDX headers" >> $GITHUB_STEP_SUMMARY - else - echo "✅ Good SPDX header coverage" >> $GITHUB_STEP_SUMMARY - fi - fi - - - name: Validate License File + - name: Check File Naming run: | set -x + echo "## 📝 File Naming Standards" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "### License File Validation" >> $GITHUB_STEP_SUMMARY - if [ ! -f "LICENSE" ]; then - echo "❌ LICENSE file not found" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### ❌ Validation Failed: LICENSE File Missing" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Error:** LICENSE file is required for all MokoStandards-compliant repositories" >> $GITHUB_STEP_SUMMARY - echo "**Action Required:** Add LICENSE file with appropriate open-source license (GPL-3.0-or-later recommended)" >> $GITHUB_STEP_SUMMARY - echo "" - echo "❌ ERROR: LICENSE file not found - This is a critical requirement" - exit 1 - fi + VIOLATIONS=0 - # Check license type - if grep -qi "GNU GENERAL PUBLIC LICENSE" LICENSE; then - VERSION=$(grep -i "Version 3" LICENSE || echo "") - if [ -n "$VERSION" ]; then - echo "✅ GPL-3.0-or-later license detected" >> $GITHUB_STEP_SUMMARY - else - echo "⚠️ GPL license detected but version unclear" >> $GITHUB_STEP_SUMMARY - fi - elif grep -qi "MIT License" LICENSE; then - echo "✅ MIT license detected" >> $GITHUB_STEP_SUMMARY - elif grep -qi "Apache License" LICENSE; then - echo "✅ Apache license detected" >> $GITHUB_STEP_SUMMARY - else - echo "ℹ️ License type could not be automatically detected" >> $GITHUB_STEP_SUMMARY - fi + # Check PHP files (should be PascalCase for classes) + INVALID_PHP=$(find . -name "*.php" ! -path "./vendor/*" ! -path "./.git/*" ! -regex ".*/[A-Z][a-zA-Z0-9]*\.php" ! -name "index.php" ! -name "functions.php" | wc -l || echo 0) - git-hygiene: - name: Git Repository Hygiene - runs-on: ubuntu-latest + # Check config files (should be kebab-case) + INVALID_CONFIG=$(find . -name "*.yml" -o -name "*.yaml" -o -name "*.json" ! -path "./vendor/*" ! -path "./.git/*" ! -path "./node_modules/*" | grep -E "[A-Z_]" | wc -l || echo 0) - steps: - - name: Checkout Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - fetch-depth: 0 - - - name: Check .gitignore - run: | - set -x - echo "### .gitignore Validation" >> $GITHUB_STEP_SUMMARY - - if [ ! -f ".gitignore" ]; then - echo "⚠️ .gitignore file not found" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### ⚠️ Warning: .gitignore Not Found" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Status:** .gitignore file is recommended but not required" >> $GITHUB_STEP_SUMMARY - echo "**Recommendation:** Add .gitignore to exclude build artifacts, dependencies, and temporary files" >> $GITHUB_STEP_SUMMARY - echo "" - echo "⚠️ WARNING: .gitignore file not found - Continuing validation" - exit 0 - fi - - # Check for common exclusions - MISSING="" - grep -q "vendor/" .gitignore || MISSING="${MISSING}vendor/ " - grep -q "node_modules/" .gitignore || MISSING="${MISSING}node_modules/ " - - if [ -n "$MISSING" ]; then - echo "⚠️ .gitignore may be missing common exclusions: $MISSING" >> $GITHUB_STEP_SUMMARY - else - echo "✅ .gitignore appears complete" >> $GITHUB_STEP_SUMMARY - fi - - - name: Check for Large Files - run: | - set -x + echo "### Naming Violations" >> $GITHUB_STEP_SUMMARY + echo "- **PHP files not PascalCase**: $INVALID_PHP" >> $GITHUB_STEP_SUMMARY + echo "- **Config files not kebab-case**: $INVALID_CONFIG" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "### Large File Detection" >> $GITHUB_STEP_SUMMARY - # Find files larger than 1MB - LARGE_FILES=$(find . -type f -size +1M ! -path "./.git/*" ! -path "./vendor/*" ! -path "./node_modules/*" | head -5) + VIOLATIONS=$((INVALID_PHP + INVALID_CONFIG)) - if [ -n "$LARGE_FILES" ]; then - echo "⚠️ Large files detected (>1MB):" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - echo "$LARGE_FILES" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - echo "Consider using Git LFS for large binary files" >> $GITHUB_STEP_SUMMARY + if [ "$VIOLATIONS" -gt 0 ]; then + echo "⚠️ Found $VIOLATIONS naming convention violation(s)" >> $GITHUB_STEP_SUMMARY + echo "**Recommendation**: Follow naming conventions for consistency" >> $GITHUB_STEP_SUMMARY else - echo "✅ No unusually large files detected" >> $GITHUB_STEP_SUMMARY + echo "✅ File naming conventions followed" >> $GITHUB_STEP_SUMMARY fi - workflow-validation: - name: Workflow Configuration Check + insecure-patterns: + name: Insecure Code Pattern Detection runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Check Required Workflows + - name: Scan for Insecure Patterns run: | set -x - echo "### GitHub Actions Workflows" >> $GITHUB_STEP_SUMMARY + echo "## 🔒 Insecure Code Pattern Detection" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY - WORKFLOWS_DIR=".github/workflows" + VIOLATIONS=0 - if [ ! -d "$WORKFLOWS_DIR" ]; then - echo "❌ No workflows directory found" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### ❌ Validation Failed: Workflows Directory Missing" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Error:** .github/workflows directory is required for CI/CD automation" >> $GITHUB_STEP_SUMMARY - echo "**Action Required:** Create .github/workflows directory and add GitHub Actions workflows" >> $GITHUB_STEP_SUMMARY - echo "" - echo "❌ ERROR: .github/workflows directory not found" - exit 1 + # PHP: SQL injection patterns + if grep -r -n "\\$_\(GET\|POST\|REQUEST\).*mysql_query\|mysqli_query" . --include="*.php" ! -path "./vendor/*" 2>/dev/null > /tmp/sql_inject.txt; then + COUNT=$(wc -l < /tmp/sql_inject.txt) + echo "⚠️ Found $COUNT potential SQL injection pattern(s)" >> $GITHUB_STEP_SUMMARY + VIOLATIONS=$((VIOLATIONS + COUNT)) fi - # Check for recommended workflows - if [ -f "$WORKFLOWS_DIR/ci.yml" ] || [ -f "$WORKFLOWS_DIR/build.yml" ]; then - echo "✅ CI workflow present" >> $GITHUB_STEP_SUMMARY + # PHP: eval/exec usage + if grep -r -n "eval\|exec\|system\|passthru\|shell_exec" . --include="*.php" ! -path "./vendor/*" 2>/dev/null > /tmp/exec.txt; then + COUNT=$(wc -l < /tmp/exec.txt) + echo "⚠️ Found $COUNT dangerous function call(s)" >> $GITHUB_STEP_SUMMARY + VIOLATIONS=$((VIOLATIONS + COUNT)) + fi + + # Python: eval usage + if grep -r -n "eval(" . --include="*.py" 2>/dev/null > /tmp/py_eval.txt; then + COUNT=$(wc -l < /tmp/py_eval.txt) + echo "⚠️ Found $COUNT Python eval() usage(s)" >> $GITHUB_STEP_SUMMARY + VIOLATIONS=$((VIOLATIONS + COUNT)) + fi + + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "$VIOLATIONS" -gt 0 ]; then + echo "**Total Violations**: $VIOLATIONS" >> $GITHUB_STEP_SUMMARY + echo "**Recommendation**: Review and secure flagged patterns" >> $GITHUB_STEP_SUMMARY else - echo "⚠️ No CI workflow found (ci.yml or build.yml)" >> $GITHUB_STEP_SUMMARY + echo "✅ No insecure patterns detected" >> $GITHUB_STEP_SUMMARY fi - if [ -f "$WORKFLOWS_DIR/codeql-analysis.yml" ]; then - echo "✅ CodeQL security scanning present" >> $GITHUB_STEP_SUMMARY - else - echo "⚠️ CodeQL workflow not found" >> $GITHUB_STEP_SUMMARY - fi - - - name: Validate Workflow Syntax - run: | - set -x - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Workflow YAML Syntax" >> $GITHUB_STEP_SUMMARY - - INVALID=0 - for workflow in $(find .github/workflows -maxdepth 1 -type f \( -name "*.yml" -o -name "*.yaml" \) 2>/dev/null); do - if [ -f "$workflow" ]; then - if python3 -c "import yaml, sys; yaml.safe_load(open(sys.argv[1]))" "$workflow" 2>/dev/null; then - echo "✅ $(basename $workflow)" >> $GITHUB_STEP_SUMMARY - else - echo "❌ $(basename $workflow) - invalid YAML" >> $GITHUB_STEP_SUMMARY - INVALID=$((INVALID + 1)) - fi - fi - done - - if [ "$INVALID" -gt 0 ]; then - echo "" >> $GITHUB_STEP_SUMMARY - echo "### ❌ Validation Failed: Invalid Workflow YAML Syntax" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Error:** $INVALID workflow file(s) have invalid YAML syntax" >> $GITHUB_STEP_SUMMARY - echo "**Action Required:** Fix YAML syntax errors in the marked workflow files" >> $GITHUB_STEP_SUMMARY - echo "**Tool:** Run \`python3 -c \"import yaml; yaml.safe_load(open('.github/workflows/FILE.yml'))\"\` locally" >> $GITHUB_STEP_SUMMARY - echo "" - echo "❌ ERROR: $INVALID workflow file(s) with invalid YAML syntax" - exit 1 - fi - - echo "" >> $GITHUB_STEP_SUMMARY - echo "### ✅ All Workflow Files Have Valid YAML Syntax" >> $GITHUB_STEP_SUMMARY - echo "" - echo "✅ SUCCESS: All workflow files passed YAML validation" - - - name: Validate CodeQL Configuration - if: hashFiles('.github/workflows/codeql-analysis.yml') != '' - run: | - set -e - echo "" >> $GITHUB_STEP_SUMMARY - echo "### CodeQL Language Configuration" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # Inline validation (rewritten from Python to bash for PHP-only architecture) - CODEQL_FILE=".github/workflows/codeql-analysis.yml" - - if [ ! -f "$CODEQL_FILE" ]; then - echo "⚠️ CodeQL workflow file not found" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### ⚠️ CodeQL Workflow Not Found" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Status:** CodeQL workflow file not present - skipping language validation" >> $GITHUB_STEP_SUMMARY - echo "" - echo "⚠️ INFO: CodeQL workflow not found - Skipping validation" - exit 0 - fi - - echo "**CodeQL Configuration Analysis**" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # Extract configured languages from workflow - LANGUAGES=$(grep -A5 "language:" "$CODEQL_FILE" | grep -oP "(?<=')[^']+(?=')" | tr '\n' ' ' || echo "") - - # Check if this is a configuration-only scan (no languages specified) - if grep -q "category.*language:config" "$CODEQL_FILE"; then - echo "**Scan Type:** Configuration-only (no language matrix)" >> $GITHUB_STEP_SUMMARY - echo "**Status:** ✅ Valid configuration for PHP-only repository" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "This CodeQL workflow scans YAML, JSON, shell scripts for security issues." >> $GITHUB_STEP_SUMMARY - echo "PHP security is handled by SecurityValidator enterprise library." >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "✅ SUCCESS: CodeQL configuration-only scan properly configured" - exit 0 - fi - - if [ -z "$LANGUAGES" ]; then - echo "❌ No languages configured in CodeQL workflow" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### ❌ Validation Failed: CodeQL Languages Not Configured" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Error:** CodeQL workflow exists but has no languages configured" >> $GITHUB_STEP_SUMMARY - echo "**Action Required:** Configure appropriate languages in codeql-analysis.yml" >> $GITHUB_STEP_SUMMARY - echo "" - echo "❌ ERROR: No languages configured in CodeQL workflow" - exit 1 - fi - - echo "**Configured Languages:** $LANGUAGES" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # Validate language presence in repository - INVALID_LANGS="" - VALID_LANGS="" - - for LANG in $LANGUAGES; do - case "$LANG" in - python) - # Check for Python files (should be none in v04.00.04) - if find . -name "*.py" -type f ! -path "./.git/*" | grep -q .; then - VALID_LANGS="$VALID_LANGS python" - echo "✅ Python: Found Python files" >> $GITHUB_STEP_SUMMARY - else - INVALID_LANGS="$INVALID_LANGS python" - echo "❌ Python: No Python files found (PHP-only repository)" >> $GITHUB_STEP_SUMMARY - fi - ;; - javascript|typescript) - # Check for JS/TS files - if find . \( -name "*.js" -o -name "*.ts" -o -name "*.json" \) -type f ! -path "./.git/*" ! -path "./node_modules/*" | grep -q .; then - VALID_LANGS="$VALID_LANGS $LANG" - echo "✅ $LANG: Found JavaScript/TypeScript/JSON files" >> $GITHUB_STEP_SUMMARY - else - INVALID_LANGS="$INVALID_LANGS $LANG" - echo "⚠️ $LANG: No JavaScript/TypeScript files found" >> $GITHUB_STEP_SUMMARY - fi - ;; - java) - if find . -name "*.java" -type f ! -path "./.git/*" | grep -q .; then - VALID_LANGS="$VALID_LANGS java" - echo "✅ Java: Found Java files" >> $GITHUB_STEP_SUMMARY - else - INVALID_LANGS="$INVALID_LANGS java" - echo "⚠️ Java: No Java files found" >> $GITHUB_STEP_SUMMARY - fi - ;; - go) - if find . -name "*.go" -type f ! -path "./.git/*" | grep -q .; then - VALID_LANGS="$VALID_LANGS go" - echo "✅ Go: Found Go files" >> $GITHUB_STEP_SUMMARY - else - INVALID_LANGS="$INVALID_LANGS go" - echo "⚠️ Go: No Go files found" >> $GITHUB_STEP_SUMMARY - fi - ;; - cpp|c) - if find . \( -name "*.cpp" -o -name "*.c" -o -name "*.h" \) -type f ! -path "./.git/*" | grep -q .; then - VALID_LANGS="$VALID_LANGS $LANG" - echo "✅ $LANG: Found C/C++ files" >> $GITHUB_STEP_SUMMARY - else - INVALID_LANGS="$INVALID_LANGS $LANG" - echo "⚠️ $LANG: No C/C++ files found" >> $GITHUB_STEP_SUMMARY - fi - ;; - ruby) - if find . -name "*.rb" -type f ! -path "./.git/*" | grep -q .; then - VALID_LANGS="$VALID_LANGS ruby" - echo "✅ Ruby: Found Ruby files" >> $GITHUB_STEP_SUMMARY - else - INVALID_LANGS="$INVALID_LANGS ruby" - echo "⚠️ Ruby: No Ruby files found" >> $GITHUB_STEP_SUMMARY - fi - ;; - *) - echo "⚠️ $LANG: Unknown language, skipping validation" >> $GITHUB_STEP_SUMMARY - ;; - esac - done - - echo "" >> $GITHUB_STEP_SUMMARY - - # Report results - if [ -n "$INVALID_LANGS" ]; then - echo "**⚠️ Warning:** Some configured languages may not have corresponding files:" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - echo "Invalid languages: $INVALID_LANGS" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Note:** This is informational. CodeQL will skip languages without source files." >> $GITHUB_STEP_SUMMARY - echo "For PHP repository (v04.00.04), JavaScript language covers JSON/YAML/shell scripts." >> $GITHUB_STEP_SUMMARY - else - echo "✅ **All configured CodeQL languages have corresponding source files**" >> $GITHUB_STEP_SUMMARY - fi - - # Always succeed - this is informational only - echo "" >> $GITHUB_STEP_SUMMARY - echo "### ✅ CodeQL Configuration Validation Complete" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Status:** CodeQL language configuration reviewed successfully" >> $GITHUB_STEP_SUMMARY - echo "" - echo "✅ SUCCESS: CodeQL validation complete" - exit 0 - - version-consistency: - name: Version Consistency Check + code-complexity: + name: Code Complexity Analysis runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Set up PHP - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.31.0 + - name: Setup PHP + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.31.0 with: php-version: '8.1' - extensions: json - tools: composer - coverage: none - - name: Install API Package - run: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader - env: - COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}' - - - name: Run Version Consistency Check - id: version_check + - name: Analyze Complexity run: | set -x - echo "## 🔢 Version Consistency Validation" >> $GITHUB_STEP_SUMMARY + echo "## 📊 Code Complexity Analysis" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - if [ -f "api/validate/check_version_consistency.php" ]; then - php api/validate/check_version_consistency.php --verbose | tee /tmp/version-check.log - EXIT_CODE=$? + PHP_COUNT=$(find . -name "*.php" ! -path "./vendor/*" ! -path "./.git/*" | wc -l) - echo "" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - cat /tmp/version-check.log >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + if [ "$PHP_COUNT" -gt 0 ]; then + # Install phploc + wget https://phar.phpunit.de/phploc.phar 2>/dev/null + chmod +x phploc.phar - if [ "$EXIT_CODE" -eq 0 ]; then + echo "### PHP Code Metrics" >> $GITHUB_STEP_SUMMARY + if ./phploc.phar --exclude vendor --exclude .git . 2>&1 | tee /tmp/phploc.txt; then + COMPLEXITY=$(grep "Cyclomatic Complexity" /tmp/phploc.txt | grep "Average" | awk '{print $NF}' || echo "N/A") + echo "**Average Cyclomatic Complexity**: $COMPLEXITY" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - echo "✅ All version numbers are consistent!" >> $GITHUB_STEP_SUMMARY - exit 0 - else - echo "" >> $GITHUB_STEP_SUMMARY - echo "❌ Version mismatches detected - Please update all version references" >> $GITHUB_STEP_SUMMARY - exit 1 + + if [ "$COMPLEXITY" != "N/A" ] && [ $(echo "$COMPLEXITY > 10" | bc -l) -eq 1 ]; then + echo "⚠️ Average complexity exceeds recommended threshold (10)" >> $GITHUB_STEP_SUMMARY + echo "**Recommendation**: Refactor complex functions" >> $GITHUB_STEP_SUMMARY + else + echo "✅ Code complexity within acceptable limits" >> $GITHUB_STEP_SUMMARY + fi fi else - echo "ℹ️ Version consistency check script not found - skipping" >> $GITHUB_STEP_SUMMARY - exit 0 + echo "ℹ️ No PHP files found for complexity analysis" >> $GITHUB_STEP_SUMMARY fi - script-integrity: - name: Script Integrity Validation + code-duplication: + name: Code Duplication Detection runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Set up Python + - name: Setup PHP + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.31.0 + with: + php-version: '8.1' + + - name: Detect Duplicates + run: | + set -x + echo "## 🔁 Code Duplication Detection" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + # Check if PHP files exist + PHP_COUNT=$(find . -name "*.php" ! -path "./vendor/*" ! -path "./.git/*" | wc -l) + + if [ "$PHP_COUNT" -gt 0 ]; then + echo "### PHP Code Duplication" >> $GITHUB_STEP_SUMMARY + + # Install phpcpd + wget https://phar.phpunit.de/phpcpd.phar 2>/dev/null + chmod +x phpcpd.phar + + # Run duplication detection + if ./phpcpd.phar --exclude vendor --exclude .git . 2>&1 | tee /tmp/phpcpd.txt; then + DUPLICATION=$(grep "Found" /tmp/phpcpd.txt | grep -oE "[0-9]+\.[0-9]+%" | head -1 || echo "0.00%") + echo "📊 **Duplication Rate**: $DUPLICATION" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + DUPLICATION_NUM=$(echo "$DUPLICATION" | sed 's/%//') + if [ $(echo "$DUPLICATION_NUM > 5.0" | bc -l) -eq 1 ]; then + echo "⚠️ Code duplication exceeds 5% threshold" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + echo "View duplication details" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + cat /tmp/phpcpd.txt >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + else + echo "✅ Code duplication within acceptable limits (<5%)" >> $GITHUB_STEP_SUMMARY + fi + else + echo "✅ No significant code duplication detected" >> $GITHUB_STEP_SUMMARY + fi + else + echo "ℹ️ No PHP files found for duplication analysis" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Note**: This is an informational check to encourage DRY principles." >> $GITHUB_STEP_SUMMARY + + dead-code-detection: + name: Dead Code Detection + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Setup Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.x' - - name: Validate Script Integrity - id: script_check + - name: Detect Dead Code run: | set -x - echo "## 🔐 Script Integrity Validation" >> $GITHUB_STEP_SUMMARY + echo "## 🗑️ Dead Code Detection" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - if [ -f "api/.script-registry.json" ]; then - echo "### Critical Scripts" >> $GITHUB_STEP_SUMMARY - php api/maintenance/update_sha_hashes.php \ - --dry-run --verbose | tee /tmp/script-validation.log + PY_COUNT=$(find . -name "*.py" ! -path "./vendor/*" ! -path "./.git/*" ! -path "./venv/*" | wc -l) - EXIT_CODE=$? + if [ "$PY_COUNT" -gt 0 ]; then + pip install vulture 2>/dev/null + echo "### Python Dead Code" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - cat /tmp/script-validation.log >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - - if [ "$EXIT_CODE" -eq 0 ]; then - echo "" >> $GITHUB_STEP_SUMMARY - echo "✅ All critical scripts validated successfully!" >> $GITHUB_STEP_SUMMARY - exit 0 - else - echo "" >> $GITHUB_STEP_SUMMARY - echo "❌ Script integrity violations detected" >> $GITHUB_STEP_SUMMARY - echo "**Action Required:** Review validation report and update registry" >> $GITHUB_STEP_SUMMARY - exit 1 + if vulture . --exclude vendor,venv,.git 2>&1 | tee /tmp/vulture.txt; then + DEAD_COUNT=$(wc -l < /tmp/vulture.txt || echo 0) + if [ "$DEAD_COUNT" -gt 0 ]; then + echo "⚠️ Found $DEAD_COUNT potential dead code item(s)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + echo "View dead code" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + head -50 /tmp/vulture.txt >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + else + echo "✅ No dead code detected" >> $GITHUB_STEP_SUMMARY + fi fi else - echo "ℹ️ Script registry not found - skipping integrity check" >> $GITHUB_STEP_SUMMARY - exit 0 + echo "ℹ️ No Python files found for dead code analysis" >> $GITHUB_STEP_SUMMARY fi - enterprise-readiness: - name: Enterprise Readiness Check + + # ════════════════════════════════════════════════════════════════════════ + # TIER 4 — SUPPLEMENTARY (informational) + # ════════════════════════════════════════════════════════════════════════ + file-size-limits: + name: File Size Limits runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Set up PHP - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.31.0 - with: - php-version: '8.1' - extensions: json, mbstring - tools: composer - coverage: none - - - name: Install API Package - run: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader - env: - COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}' - - - name: Check Enterprise Readiness - id: enterprise_check + - name: Check File Sizes run: | + set -x + echo "## 📦 File Size Validation" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - if [ -f "api/validate/check_enterprise_readiness.php" ]; then - php api/validate/check_enterprise_readiness.php --verbose | tee /tmp/enterprise-check.log - EXIT_CODE=$? + # Exempt file types (allowed to be large) + EXEMPT="! -name *.mmdb ! -name *.woff2 ! -name *.woff ! -name *.ttf ! -name *.otf" + # Find large files (>15MB warning, >20MB critical) + LARGE_FILES=$(find . -type f -size +15M $EXEMPT ! -path "./.git/*" ! -path "./vendor/*" ! -path "./node_modules/*" 2>/dev/null | wc -l) + HUGE_FILES=$(find . -type f -size +20M $EXEMPT ! -path "./.git/*" ! -path "./vendor/*" ! -path "./node_modules/*" 2>/dev/null | wc -l) + + echo "### Size Thresholds" >> $GITHUB_STEP_SUMMARY + echo "- **Warning**: Files >15MB" >> $GITHUB_STEP_SUMMARY + echo "- **Critical**: Files >20MB" >> $GITHUB_STEP_SUMMARY + echo "- **Exempt**: .mmdb, .woff2, .woff, .ttf, .otf" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "$HUGE_FILES" -gt 0 ]; then + echo "❌ **Critical**: Found $HUGE_FILES file(s) exceeding 20MB" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + echo "View files >20MB" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - cat /tmp/enterprise-check.log >> $GITHUB_STEP_SUMMARY + find . -type f -size +20M $EXEMPT ! -path "./.git/*" ! -path "./vendor/*" ! -path "./node_modules/*" -exec ls -lh {} + 2>/dev/null | awk '{print $5, $9}' >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - - if [ "$EXIT_CODE" -eq 0 ]; then - echo "" >> $GITHUB_STEP_SUMMARY - echo "✅ Repository meets enterprise readiness criteria!" >> $GITHUB_STEP_SUMMARY - exit 0 - else - echo "" >> $GITHUB_STEP_SUMMARY - echo "⚠️ Enterprise readiness issues detected" >> $GITHUB_STEP_SUMMARY - echo "**Note:** This is informational - review recommendations to improve" >> $GITHUB_STEP_SUMMARY - exit 0 # Non-blocking - fi + echo "
" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Action Required**: Remove or optimize files >20MB" >> $GITHUB_STEP_SUMMARY + exit 1 + elif [ "$LARGE_FILES" -gt 0 ]; then + echo "⚠️ **Warning**: Found $LARGE_FILES file(s) between 15MB and 20MB" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + echo "View files >15MB" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + find . -type f -size +15M $EXEMPT ! -path "./.git/*" ! -path "./vendor/*" ! -path "./node_modules/*" -exec ls -lh {} + 2>/dev/null | awk '{print $5, $9}' >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Recommendation**: Consider optimizing large files" >> $GITHUB_STEP_SUMMARY else - echo "ℹ️ Enterprise readiness check script not found - skipping" >> $GITHUB_STEP_SUMMARY - exit 0 + echo "✅ All files within acceptable size limits" >> $GITHUB_STEP_SUMMARY fi - repository-health: - name: Repository Health Check + binary-file-detection: + name: Binary File Detection runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Set up PHP - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.31.0 - with: - php-version: '8.1' - extensions: json, mbstring - tools: composer - coverage: none - - - name: Install API Package - run: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader - env: - COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}' - - - name: Check Repository Health - id: health_check + - name: Detect Binary Files run: | + set -x + echo "## 🔍 Binary File Detection" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - if [ -f "api/validate/check_repo_health.php" ]; then - php api/validate/check_repo_health.php --verbose | tee /tmp/health-check.log - EXIT_CODE=$? + # Find binary files excluding allowed types + BINARIES=$(find . -type f ! -path "./.git/*" ! -path "./vendor/*" ! -path "./node_modules/*" \ + ! -name "*.png" ! -name "*.jpg" ! -name "*.jpeg" ! -name "*.gif" ! -name "*.svg" ! -name "*.ico" \ + ! -name "*.woff" ! -name "*.woff2" ! -name "*.ttf" ! -name "*.eot" \ + -exec file {} \; | grep -v "text" | grep -v "empty" | wc -l || echo 0) + if [ "$BINARIES" -gt 0 ]; then + echo "⚠️ Found $BINARIES non-image binary file(s)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + echo "View binary files" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - cat /tmp/health-check.log >> $GITHUB_STEP_SUMMARY + find . -type f ! -path "./.git/*" ! -path "./vendor/*" ! -path "./node_modules/*" \ + ! -name "*.png" ! -name "*.jpg" ! -name "*.jpeg" ! -name "*.gif" ! -name "*.svg" ! -name "*.ico" \ + ! -name "*.woff" ! -name "*.woff2" ! -name "*.ttf" ! -name "*.eot" \ + -exec file {} \; | grep -v "text" | grep -v "empty" | cut -d: -f1 >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - - if [ "$EXIT_CODE" -eq 0 ]; then - echo "" >> $GITHUB_STEP_SUMMARY - echo "✅ Repository health check passed!" >> $GITHUB_STEP_SUMMARY - exit 0 - else - echo "" >> $GITHUB_STEP_SUMMARY - echo "⚠️ Repository health issues detected" >> $GITHUB_STEP_SUMMARY - echo "**Note:** This is informational - review recommendations to improve" >> $GITHUB_STEP_SUMMARY - exit 0 # Non-blocking - fi + echo "
" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Recommendation**: Source control should primarily contain text files" >> $GITHUB_STEP_SUMMARY else - echo "ℹ️ Repository health check script not found - skipping" >> $GITHUB_STEP_SUMMARY - exit 0 + echo "✅ No unexpected binary files detected" >> $GITHUB_STEP_SUMMARY fi + # ============================================================================ + # PHASE 4: Nice to Have Checks + # ============================================================================ + todo-fixme-tracking: name: TODO/FIXME Tracking runs-on: ubuntu-latest @@ -1181,154 +1614,131 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "**Note**: This is an informational check. Technical debt items don't block compliance." >> $GITHUB_STEP_SUMMARY - file-size-limits: - name: File Size Limits + dependency-vulnerabilities: + name: Dependency Vulnerability Scanning runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Check File Sizes + - name: Setup PHP + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.31.0 + with: + php-version: '8.1' + + - name: Setup Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: '3.x' + + - name: Scan Dependencies run: | set -x - echo "## 📦 File Size Validation" >> $GITHUB_STEP_SUMMARY + echo "## 🛡️ Dependency Vulnerability Scanning" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - # Find large files (>1MB) - LARGE_FILES=$(find . -type f -size +1M ! -path "./.git/*" ! -path "./vendor/*" ! -path "./node_modules/*" 2>/dev/null | wc -l) - HUGE_FILES=$(find . -type f -size +10M ! -path "./.git/*" ! -path "./vendor/*" ! -path "./node_modules/*" 2>/dev/null | wc -l) + VULNERABILITIES=0 - echo "### Size Thresholds" >> $GITHUB_STEP_SUMMARY - echo "- **Warning**: Files >1MB" >> $GITHUB_STEP_SUMMARY - echo "- **Critical**: Files >10MB" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ "$HUGE_FILES" -gt 0 ]; then - echo "❌ **Critical**: Found $HUGE_FILES file(s) exceeding 10MB" >> $GITHUB_STEP_SUMMARY + # PHP Dependencies + if [ -f "composer.json" ]; then + echo "### PHP Dependencies (composer)" >> $GITHUB_STEP_SUMMARY + if composer audit --no-dev 2>&1 | tee /tmp/php_audit.txt; then + echo "✅ No PHP vulnerabilities detected" >> $GITHUB_STEP_SUMMARY + else + VULN_COUNT=$(grep -c "vulnerability" /tmp/php_audit.txt || echo 0) + echo "⚠️ Found $VULN_COUNT PHP vulnerability/vulnerabilities" >> $GITHUB_STEP_SUMMARY + VULNERABILITIES=$((VULNERABILITIES + VULN_COUNT)) + fi echo "" >> $GITHUB_STEP_SUMMARY - echo "
" >> $GITHUB_STEP_SUMMARY - echo "View files >10MB" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - find . -type f -size +10M ! -path "./.git/*" ! -path "./vendor/*" ! -path "./node_modules/*" -exec ls -lh {} + 2>/dev/null | awk '{print $5, $9}' >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - echo "
" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Action Required**: Remove or optimize files >10MB" >> $GITHUB_STEP_SUMMARY - exit 1 - elif [ "$LARGE_FILES" -gt 0 ]; then - echo "⚠️ **Warning**: Found $LARGE_FILES file(s) between 1MB and 10MB" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "
" >> $GITHUB_STEP_SUMMARY - echo "View files >1MB" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - find . -type f -size +1M ! -path "./.git/*" ! -path "./vendor/*" ! -path "./node_modules/*" -exec ls -lh {} + 2>/dev/null | awk '{print $5, $9}' >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - echo "
" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Recommendation**: Consider optimizing or documenting large files" >> $GITHUB_STEP_SUMMARY - else - echo "✅ All files within acceptable size limits (<1MB)" >> $GITHUB_STEP_SUMMARY fi - secret-scanning: - name: Secret Scanning + # Python Dependencies + if [ -f "requirements.txt" ]; then + echo "### Python Dependencies" >> $GITHUB_STEP_SUMMARY + pip install pip-audit 2>&1 > /dev/null + if pip-audit -r requirements.txt 2>&1 | tee /tmp/py_audit.txt; then + echo "✅ No Python vulnerabilities detected" >> $GITHUB_STEP_SUMMARY + else + VULN_COUNT=$(grep -c "vulnerability" /tmp/py_audit.txt || echo 0) + echo "⚠️ Found $VULN_COUNT Python vulnerability/vulnerabilities" >> $GITHUB_STEP_SUMMARY + VULNERABILITIES=$((VULNERABILITIES + VULN_COUNT)) + fi + echo "" >> $GITHUB_STEP_SUMMARY + fi + + # NPM Dependencies + if [ -f "package.json" ]; then + echo "### NPM Dependencies" >> $GITHUB_STEP_SUMMARY + if npm audit --production 2>&1 | tee /tmp/npm_audit.txt; then + echo "✅ No NPM vulnerabilities detected" >> $GITHUB_STEP_SUMMARY + else + VULN_COUNT=$(grep -c "vulnerability" /tmp/npm_audit.txt || echo 0) + echo "⚠️ Found $VULN_COUNT NPM vulnerability/vulnerabilities" >> $GITHUB_STEP_SUMMARY + VULNERABILITIES=$((VULNERABILITIES + VULN_COUNT)) + fi + echo "" >> $GITHUB_STEP_SUMMARY + fi + + if [ "$VULNERABILITIES" -gt 0 ]; then + echo "**Total Vulnerabilities**: $VULNERABILITIES" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Action Required**: Update vulnerable dependencies" >> $GITHUB_STEP_SUMMARY + exit 1 + else + echo "✅ No dependency vulnerabilities detected" >> $GITHUB_STEP_SUMMARY + fi + + unused-dependencies: + name: Unused Dependencies Check runs-on: ubuntu-latest steps: - name: Checkout Repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - name: Scan for Secrets + - name: Setup PHP + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.31.0 + with: + php-version: '8.1' + + - name: Check Unused Dependencies run: | set -x - echo "## 🔒 Secret Scanning" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Scanning for hardcoded secrets and credentials." >> $GITHUB_STEP_SUMMARY + echo "## 📦 Unused Dependencies Check" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - # Define secret patterns - VIOLATIONS=0 + if [ -f "composer.json" ]; then + echo "### PHP Dependencies" >> $GITHUB_STEP_SUMMARY - # Check for common secret patterns - echo "### Secret Patterns" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY + # Install composer-unused + composer global require icanhazstring/composer-unused 2>/dev/null || true - # Pattern 1: password/secret assignments - # Exclusions: - # test|example|sample - test/example files - # getenv - environment-variable reads - # /\.\*/|^\s*// - regex patterns and commented lines - # CREDENTIAL_PATTERNS|SecurityValidator|SECRET_PATTERN - scanner internals - # ===|!== - strict comparison operators (not assignments) - # ApiClient - constructor calls where token is a variable arg - if grep -r -n -E "(password|passwd|pwd|secret|api[_-]?key|token).*=.*['\"]" . \ - --include="*.php" --include="*.py" --include="*.js" --include="*.ts" \ - --exclude-dir=".git" --exclude-dir="vendor" --exclude-dir="node_modules" 2>/dev/null | \ - grep -v -E '(test|example|sample|getenv|/\.\*/|^\s*//|CREDENTIAL_PATTERNS|SecurityValidator|SECRET_PATTERN|===|!==|ApiClient)' | \ - grep -v "= ''" | grep -v '= ""' > /tmp/secrets1.txt 2>/dev/null; then - COUNT=$(wc -l < /tmp/secrets1.txt) - if [ "$COUNT" -gt 0 ]; then - echo "⚠️ Found $COUNT potential secret assignment(s)" >> $GITHUB_STEP_SUMMARY - VIOLATIONS=$((VIOLATIONS + COUNT)) + if composer global exec composer-unused 2>&1 | tee /tmp/unused.txt; then + UNUSED_COUNT=$(grep "unused" /tmp/unused.txt | wc -l || echo 0) + if [ "$UNUSED_COUNT" -gt 0 ]; then + echo "⚠️ Found $UNUSED_COUNT unused dependency/dependencies" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + echo "View unused dependencies" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + cat /tmp/unused.txt >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + echo "
" >> $GITHUB_STEP_SUMMARY + else + echo "✅ No unused dependencies detected" >> $GITHUB_STEP_SUMMARY + fi + else + echo "✅ All dependencies appear to be in use" >> $GITHUB_STEP_SUMMARY fi - fi - - # Pattern 2: Private keys - if grep -r -n "BEGIN.*PRIVATE KEY" . \ - --include="*.pem" --include="*.key" --include="*.txt" \ - --exclude-dir=".git" --exclude-dir="vendor" --exclude-dir="node_modules" 2>/dev/null > /tmp/secrets2.txt; then - COUNT=$(wc -l < /tmp/secrets2.txt) - if [ "$COUNT" -gt 0 ]; then - echo "❌ Found $COUNT private key file(s)" >> $GITHUB_STEP_SUMMARY - VIOLATIONS=$((VIOLATIONS + COUNT)) - fi - fi - - # Pattern 3: AWS keys - if grep -r -n -E "AKIA[0-9A-Z]{16}" . \ - --include="*.php" --include="*.py" --include="*.js" --include="*.txt" --include="*.env" \ - --exclude-dir=".git" --exclude-dir="vendor" --exclude-dir="node_modules" 2>/dev/null > /tmp/secrets3.txt; then - COUNT=$(wc -l < /tmp/secrets3.txt) - if [ "$COUNT" -gt 0 ]; then - echo "❌ Found $COUNT potential AWS access key(s)" >> $GITHUB_STEP_SUMMARY - VIOLATIONS=$((VIOLATIONS + COUNT)) - fi - fi - - # Pattern 4: GitHub tokens - if grep -r -n -E "gh[ps]_[a-zA-Z0-9]{36}" . \ - --include="*.php" --include="*.py" --include="*.js" --include="*.txt" --include="*.env" \ - --exclude-dir=".git" --exclude-dir="vendor" --exclude-dir="node_modules" 2>/dev/null > /tmp/secrets4.txt; then - COUNT=$(wc -l < /tmp/secrets4.txt) - if [ "$COUNT" -gt 0 ]; then - echo "❌ Found $COUNT potential GitHub token(s)" >> $GITHUB_STEP_SUMMARY - VIOLATIONS=$((VIOLATIONS + COUNT)) - fi - fi - - echo "" >> $GITHUB_STEP_SUMMARY - - if [ "$VIOLATIONS" -gt 0 ]; then - echo "**Total Violations**: $VIOLATIONS" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "
" >> $GITHUB_STEP_SUMMARY - echo "View detected secrets (file paths only)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - cat /tmp/secrets*.txt 2>/dev/null | cut -d: -f1 | sort -u >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - echo "
" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Action Required**: Remove hardcoded secrets immediately!" >> $GITHUB_STEP_SUMMARY - echo "Use environment variables or secrets management instead." >> $GITHUB_STEP_SUMMARY - exit 1 else - echo "✅ No hardcoded secrets detected" >> $GITHUB_STEP_SUMMARY + echo "ℹ️ No composer.json found" >> $GITHUB_STEP_SUMMARY fi + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Recommendation**: Remove unused dependencies to reduce attack surface" >> $GITHUB_STEP_SUMMARY + broken-link-detection: name: Broken Link Detection runs-on: ubuntu-latest @@ -1418,283 +1828,6 @@ jobs: # PHASE 2: Medium Priority Checks # ============================================================================ - dependency-vulnerabilities: - name: Dependency Vulnerability Scanning - runs-on: ubuntu-latest - - steps: - - name: Checkout Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Setup PHP - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.31.0 - with: - php-version: '8.1' - - - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: '3.x' - - - name: Scan Dependencies - run: | - set -x - echo "## 🛡️ Dependency Vulnerability Scanning" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - VULNERABILITIES=0 - - # PHP Dependencies - if [ -f "composer.json" ]; then - echo "### PHP Dependencies (composer)" >> $GITHUB_STEP_SUMMARY - if composer audit --no-dev 2>&1 | tee /tmp/php_audit.txt; then - echo "✅ No PHP vulnerabilities detected" >> $GITHUB_STEP_SUMMARY - else - VULN_COUNT=$(grep -c "vulnerability" /tmp/php_audit.txt || echo 0) - echo "⚠️ Found $VULN_COUNT PHP vulnerability/vulnerabilities" >> $GITHUB_STEP_SUMMARY - VULNERABILITIES=$((VULNERABILITIES + VULN_COUNT)) - fi - echo "" >> $GITHUB_STEP_SUMMARY - fi - - # Python Dependencies - if [ -f "requirements.txt" ]; then - echo "### Python Dependencies" >> $GITHUB_STEP_SUMMARY - pip install pip-audit 2>&1 > /dev/null - if pip-audit -r requirements.txt 2>&1 | tee /tmp/py_audit.txt; then - echo "✅ No Python vulnerabilities detected" >> $GITHUB_STEP_SUMMARY - else - VULN_COUNT=$(grep -c "vulnerability" /tmp/py_audit.txt || echo 0) - echo "⚠️ Found $VULN_COUNT Python vulnerability/vulnerabilities" >> $GITHUB_STEP_SUMMARY - VULNERABILITIES=$((VULNERABILITIES + VULN_COUNT)) - fi - echo "" >> $GITHUB_STEP_SUMMARY - fi - - # NPM Dependencies - if [ -f "package.json" ]; then - echo "### NPM Dependencies" >> $GITHUB_STEP_SUMMARY - if npm audit --production 2>&1 | tee /tmp/npm_audit.txt; then - echo "✅ No NPM vulnerabilities detected" >> $GITHUB_STEP_SUMMARY - else - VULN_COUNT=$(grep -c "vulnerability" /tmp/npm_audit.txt || echo 0) - echo "⚠️ Found $VULN_COUNT NPM vulnerability/vulnerabilities" >> $GITHUB_STEP_SUMMARY - VULNERABILITIES=$((VULNERABILITIES + VULN_COUNT)) - fi - echo "" >> $GITHUB_STEP_SUMMARY - fi - - if [ "$VULNERABILITIES" -gt 0 ]; then - echo "**Total Vulnerabilities**: $VULNERABILITIES" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Action Required**: Update vulnerable dependencies" >> $GITHUB_STEP_SUMMARY - exit 1 - else - echo "✅ No dependency vulnerabilities detected" >> $GITHUB_STEP_SUMMARY - fi - - code-duplication: - name: Code Duplication Detection - runs-on: ubuntu-latest - - steps: - - name: Checkout Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Setup PHP - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.31.0 - with: - php-version: '8.1' - - - name: Detect Duplicates - run: | - set -x - echo "## 🔁 Code Duplication Detection" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # Check if PHP files exist - PHP_COUNT=$(find . -name "*.php" ! -path "./vendor/*" ! -path "./.git/*" | wc -l) - - if [ "$PHP_COUNT" -gt 0 ]; then - echo "### PHP Code Duplication" >> $GITHUB_STEP_SUMMARY - - # Install phpcpd - wget https://phar.phpunit.de/phpcpd.phar 2>/dev/null - chmod +x phpcpd.phar - - # Run duplication detection - if ./phpcpd.phar --exclude vendor --exclude .git . 2>&1 | tee /tmp/phpcpd.txt; then - DUPLICATION=$(grep "Found" /tmp/phpcpd.txt | grep -oE "[0-9]+\.[0-9]+%" | head -1 || echo "0.00%") - echo "📊 **Duplication Rate**: $DUPLICATION" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - DUPLICATION_NUM=$(echo "$DUPLICATION" | sed 's/%//') - if [ $(echo "$DUPLICATION_NUM > 5.0" | bc -l) -eq 1 ]; then - echo "⚠️ Code duplication exceeds 5% threshold" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "
" >> $GITHUB_STEP_SUMMARY - echo "View duplication details" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - cat /tmp/phpcpd.txt >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - echo "
" >> $GITHUB_STEP_SUMMARY - else - echo "✅ Code duplication within acceptable limits (<5%)" >> $GITHUB_STEP_SUMMARY - fi - else - echo "✅ No significant code duplication detected" >> $GITHUB_STEP_SUMMARY - fi - else - echo "ℹ️ No PHP files found for duplication analysis" >> $GITHUB_STEP_SUMMARY - fi - - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Note**: This is an informational check to encourage DRY principles." >> $GITHUB_STEP_SUMMARY - - unused-dependencies: - name: Unused Dependencies Check - runs-on: ubuntu-latest - - steps: - - name: Checkout Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Setup PHP - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.31.0 - with: - php-version: '8.1' - - - name: Check Unused Dependencies - run: | - set -x - echo "## 📦 Unused Dependencies Check" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ -f "composer.json" ]; then - echo "### PHP Dependencies" >> $GITHUB_STEP_SUMMARY - - # Install composer-unused - composer global require icanhazstring/composer-unused 2>/dev/null || true - - if composer global exec composer-unused 2>&1 | tee /tmp/unused.txt; then - UNUSED_COUNT=$(grep "unused" /tmp/unused.txt | wc -l || echo 0) - if [ "$UNUSED_COUNT" -gt 0 ]; then - echo "⚠️ Found $UNUSED_COUNT unused dependency/dependencies" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "
" >> $GITHUB_STEP_SUMMARY - echo "View unused dependencies" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - cat /tmp/unused.txt >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - echo "
" >> $GITHUB_STEP_SUMMARY - else - echo "✅ No unused dependencies detected" >> $GITHUB_STEP_SUMMARY - fi - else - echo "✅ All dependencies appear to be in use" >> $GITHUB_STEP_SUMMARY - fi - else - echo "ℹ️ No composer.json found" >> $GITHUB_STEP_SUMMARY - fi - - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Recommendation**: Remove unused dependencies to reduce attack surface" >> $GITHUB_STEP_SUMMARY - - readme-completeness: - name: README Completeness Check - runs-on: ubuntu-latest - - steps: - - name: Checkout Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Check README Sections - run: | - set -x - echo "## 📄 README Completeness Check" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ ! -f "README.md" ]; then - echo "❌ README.md not found" >> $GITHUB_STEP_SUMMARY - exit 1 - fi - - # Required sections - REQUIRED_SECTIONS=("Installation" "Usage" "Contributing" "License") - MISSING=0 - PRESENT=0 - - echo "### Required Sections" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - for section in "${REQUIRED_SECTIONS[@]}"; do - if grep -qi "##.*$section" README.md; then - echo "✅ $section" >> $GITHUB_STEP_SUMMARY - PRESENT=$((PRESENT + 1)) - else - echo "❌ $section" >> $GITHUB_STEP_SUMMARY - MISSING=$((MISSING + 1)) - fi - done - - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Completeness**: $PRESENT/${#REQUIRED_SECTIONS[@]} required sections present" >> $GITHUB_STEP_SUMMARY - - if [ "$MISSING" -gt 0 ]; then - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Action Required**: Add missing sections to README.md" >> $GITHUB_STEP_SUMMARY - exit 1 - fi - - # ============================================================================ - # PHASE 3: Future Enhancements - # ============================================================================ - - code-complexity: - name: Code Complexity Analysis - runs-on: ubuntu-latest - - steps: - - name: Checkout Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Setup PHP - uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.31.0 - with: - php-version: '8.1' - - - name: Analyze Complexity - run: | - set -x - echo "## 📊 Code Complexity Analysis" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - PHP_COUNT=$(find . -name "*.php" ! -path "./vendor/*" ! -path "./.git/*" | wc -l) - - if [ "$PHP_COUNT" -gt 0 ]; then - # Install phploc - wget https://phar.phpunit.de/phploc.phar 2>/dev/null - chmod +x phploc.phar - - echo "### PHP Code Metrics" >> $GITHUB_STEP_SUMMARY - if ./phploc.phar --exclude vendor --exclude .git . 2>&1 | tee /tmp/phploc.txt; then - COMPLEXITY=$(grep "Cyclomatic Complexity" /tmp/phploc.txt | grep "Average" | awk '{print $NF}' || echo "N/A") - echo "**Average Cyclomatic Complexity**: $COMPLEXITY" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - if [ "$COMPLEXITY" != "N/A" ] && [ $(echo "$COMPLEXITY > 10" | bc -l) -eq 1 ]; then - echo "⚠️ Average complexity exceeds recommended threshold (10)" >> $GITHUB_STEP_SUMMARY - echo "**Recommendation**: Refactor complex functions" >> $GITHUB_STEP_SUMMARY - else - echo "✅ Code complexity within acceptable limits" >> $GITHUB_STEP_SUMMARY - fi - fi - else - echo "ℹ️ No PHP files found for complexity analysis" >> $GITHUB_STEP_SUMMARY - fi - api-documentation: name: API Documentation Coverage runs-on: ubuntu-latest @@ -1728,176 +1861,6 @@ jobs: echo "ℹ️ No public methods found for documentation check" >> $GITHUB_STEP_SUMMARY fi - insecure-patterns: - name: Insecure Code Pattern Detection - runs-on: ubuntu-latest - - steps: - - name: Checkout Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Scan for Insecure Patterns - run: | - set -x - echo "## 🔒 Insecure Code Pattern Detection" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - VIOLATIONS=0 - - # PHP: SQL injection patterns - if grep -r -n "\\$_\(GET\|POST\|REQUEST\).*mysql_query\|mysqli_query" . --include="*.php" ! -path "./vendor/*" 2>/dev/null > /tmp/sql_inject.txt; then - COUNT=$(wc -l < /tmp/sql_inject.txt) - echo "⚠️ Found $COUNT potential SQL injection pattern(s)" >> $GITHUB_STEP_SUMMARY - VIOLATIONS=$((VIOLATIONS + COUNT)) - fi - - # PHP: eval/exec usage - if grep -r -n "eval\|exec\|system\|passthru\|shell_exec" . --include="*.php" ! -path "./vendor/*" 2>/dev/null > /tmp/exec.txt; then - COUNT=$(wc -l < /tmp/exec.txt) - echo "⚠️ Found $COUNT dangerous function call(s)" >> $GITHUB_STEP_SUMMARY - VIOLATIONS=$((VIOLATIONS + COUNT)) - fi - - # Python: eval usage - if grep -r -n "eval(" . --include="*.py" 2>/dev/null > /tmp/py_eval.txt; then - COUNT=$(wc -l < /tmp/py_eval.txt) - echo "⚠️ Found $COUNT Python eval() usage(s)" >> $GITHUB_STEP_SUMMARY - VIOLATIONS=$((VIOLATIONS + COUNT)) - fi - - echo "" >> $GITHUB_STEP_SUMMARY - - if [ "$VIOLATIONS" -gt 0 ]; then - echo "**Total Violations**: $VIOLATIONS" >> $GITHUB_STEP_SUMMARY - echo "**Recommendation**: Review and secure flagged patterns" >> $GITHUB_STEP_SUMMARY - else - echo "✅ No insecure patterns detected" >> $GITHUB_STEP_SUMMARY - fi - - binary-file-detection: - name: Binary File Detection - runs-on: ubuntu-latest - - steps: - - name: Checkout Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Detect Binary Files - run: | - set -x - echo "## 🔍 Binary File Detection" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - # Find binary files excluding allowed types - BINARIES=$(find . -type f ! -path "./.git/*" ! -path "./vendor/*" ! -path "./node_modules/*" \ - ! -name "*.png" ! -name "*.jpg" ! -name "*.jpeg" ! -name "*.gif" ! -name "*.svg" ! -name "*.ico" \ - ! -name "*.woff" ! -name "*.woff2" ! -name "*.ttf" ! -name "*.eot" \ - -exec file {} \; | grep -v "text" | grep -v "empty" | wc -l || echo 0) - - if [ "$BINARIES" -gt 0 ]; then - echo "⚠️ Found $BINARIES non-image binary file(s)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "
" >> $GITHUB_STEP_SUMMARY - echo "View binary files" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - find . -type f ! -path "./.git/*" ! -path "./vendor/*" ! -path "./node_modules/*" \ - ! -name "*.png" ! -name "*.jpg" ! -name "*.jpeg" ! -name "*.gif" ! -name "*.svg" ! -name "*.ico" \ - ! -name "*.woff" ! -name "*.woff2" ! -name "*.ttf" ! -name "*.eot" \ - -exec file {} \; | grep -v "text" | grep -v "empty" | cut -d: -f1 >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - echo "
" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "**Recommendation**: Source control should primarily contain text files" >> $GITHUB_STEP_SUMMARY - else - echo "✅ No unexpected binary files detected" >> $GITHUB_STEP_SUMMARY - fi - - # ============================================================================ - # PHASE 4: Nice to Have Checks - # ============================================================================ - - dead-code-detection: - name: Dead Code Detection - runs-on: ubuntu-latest - - steps: - - name: Checkout Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: '3.x' - - - name: Detect Dead Code - run: | - set -x - echo "## 🗑️ Dead Code Detection" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - PY_COUNT=$(find . -name "*.py" ! -path "./vendor/*" ! -path "./.git/*" ! -path "./venv/*" | wc -l) - - if [ "$PY_COUNT" -gt 0 ]; then - pip install vulture 2>/dev/null - echo "### Python Dead Code" >> $GITHUB_STEP_SUMMARY - - if vulture . --exclude vendor,venv,.git 2>&1 | tee /tmp/vulture.txt; then - DEAD_COUNT=$(wc -l < /tmp/vulture.txt || echo 0) - if [ "$DEAD_COUNT" -gt 0 ]; then - echo "⚠️ Found $DEAD_COUNT potential dead code item(s)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "
" >> $GITHUB_STEP_SUMMARY - echo "View dead code" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - head -50 /tmp/vulture.txt >> $GITHUB_STEP_SUMMARY - echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - echo "
" >> $GITHUB_STEP_SUMMARY - else - echo "✅ No dead code detected" >> $GITHUB_STEP_SUMMARY - fi - fi - else - echo "ℹ️ No Python files found for dead code analysis" >> $GITHUB_STEP_SUMMARY - fi - - file-naming-standards: - name: File Naming Standards - runs-on: ubuntu-latest - - steps: - - name: Checkout Repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Check File Naming - run: | - set -x - echo "## 📝 File Naming Standards" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - VIOLATIONS=0 - - # Check PHP files (should be PascalCase for classes) - INVALID_PHP=$(find . -name "*.php" ! -path "./vendor/*" ! -path "./.git/*" ! -regex ".*/[A-Z][a-zA-Z0-9]*\.php" ! -name "index.php" ! -name "functions.php" | wc -l || echo 0) - - # Check config files (should be kebab-case) - INVALID_CONFIG=$(find . -name "*.yml" -o -name "*.yaml" -o -name "*.json" ! -path "./vendor/*" ! -path "./.git/*" ! -path "./node_modules/*" | grep -E "[A-Z_]" | wc -l || echo 0) - - echo "### Naming Violations" >> $GITHUB_STEP_SUMMARY - echo "- **PHP files not PascalCase**: $INVALID_PHP" >> $GITHUB_STEP_SUMMARY - echo "- **Config files not kebab-case**: $INVALID_CONFIG" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - - VIOLATIONS=$((INVALID_PHP + INVALID_CONFIG)) - - if [ "$VIOLATIONS" -gt 0 ]; then - echo "⚠️ Found $VIOLATIONS naming convention violation(s)" >> $GITHUB_STEP_SUMMARY - echo "**Recommendation**: Follow naming conventions for consistency" >> $GITHUB_STEP_SUMMARY - else - echo "✅ File naming conventions followed" >> $GITHUB_STEP_SUMMARY - fi - accessibility-check: name: Accessibility Check runs-on: ubuntu-latest @@ -1980,6 +1943,106 @@ jobs: echo "ℹ️ Not a JavaScript project" >> $GITHUB_STEP_SUMMARY fi + enterprise-readiness: + name: Enterprise Readiness Check + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up PHP + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.31.0 + with: + php-version: '8.1' + extensions: json, mbstring + tools: composer + coverage: none + + - name: Install API Package + run: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}' + + - name: Check Enterprise Readiness + id: enterprise_check + run: | + echo "" >> $GITHUB_STEP_SUMMARY + + if [ -f "api/validate/check_enterprise_readiness.php" ]; then + php api/validate/check_enterprise_readiness.php --verbose | tee /tmp/enterprise-check.log + EXIT_CODE=$? + + echo "" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + cat /tmp/enterprise-check.log >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + + if [ "$EXIT_CODE" -eq 0 ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "✅ Repository meets enterprise readiness criteria!" >> $GITHUB_STEP_SUMMARY + exit 0 + else + echo "" >> $GITHUB_STEP_SUMMARY + echo "⚠️ Enterprise readiness issues detected" >> $GITHUB_STEP_SUMMARY + echo "**Note:** This is informational - review recommendations to improve" >> $GITHUB_STEP_SUMMARY + exit 0 # Non-blocking + fi + else + echo "ℹ️ Enterprise readiness check script not found - skipping" >> $GITHUB_STEP_SUMMARY + exit 0 + fi + + repository-health: + name: Repository Health Check + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up PHP + uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # v2.31.0 + with: + php-version: '8.1' + extensions: json, mbstring + tools: composer + coverage: none + + - name: Install API Package + run: composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader + env: + COMPOSER_AUTH: '{"github-oauth":{"github.com":"${{ secrets.GH_TOKEN }}"}}' + + - name: Check Repository Health + id: health_check + run: | + echo "" >> $GITHUB_STEP_SUMMARY + + if [ -f "api/validate/check_repo_health.php" ]; then + php api/validate/check_repo_health.php --verbose | tee /tmp/health-check.log + EXIT_CODE=$? + + echo "" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + cat /tmp/health-check.log >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + + if [ "$EXIT_CODE" -eq 0 ]; then + echo "" >> $GITHUB_STEP_SUMMARY + echo "✅ Repository health check passed!" >> $GITHUB_STEP_SUMMARY + exit 0 + else + echo "" >> $GITHUB_STEP_SUMMARY + echo "⚠️ Repository health issues detected" >> $GITHUB_STEP_SUMMARY + echo "**Note:** This is informational - review recommendations to improve" >> $GITHUB_STEP_SUMMARY + exit 0 # Non-blocking + fi + else + echo "ℹ️ Repository health check script not found - skipping" >> $GITHUB_STEP_SUMMARY + exit 0 + fi + terraform-validation: name: Terraform Configuration Validation runs-on: ubuntu-latest @@ -2420,110 +2483,73 @@ jobs: - name: Create tracking issue for standards violations if: failure() && github.event_name == 'push' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const compliancePercent = '${{ needs.compliance-check.outputs.compliance_percentage }}' || '0'; - const failedChecks = []; + env: + GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + run: | + REPO="${{ github.repository }}" + RUN_URL="${{ github.server_url }}/${REPO}/actions/runs/${{ github.run_id }}" + DATE=$(date -u '+%Y-%m-%d') + SHA="${{ github.sha }}" + ACTOR="${{ github.actor }}" + BRANCH="${{ github.ref_name }}" - // Collect failed checks from job outputs - const jobs = [ - { name: 'File Headers', failed: '${{ needs.compliance-check.outputs.headers_ok }}' !== 'true' }, - { name: 'Required Files', failed: '${{ needs.compliance-check.outputs.required_files_ok }}' !== 'true' }, - { name: 'Documentation', failed: '${{ needs.compliance-check.outputs.documentation_ok }}' !== 'true' }, - { name: 'Code Quality', failed: '${{ needs.compliance-check.outputs.code_quality_ok }}' !== 'true' } - ]; + # Collect failed checks + FAILED="" + [ "${{ needs.repository-structure.result }}" != "success" ] && FAILED="${FAILED}\n- Repository Structure" + [ "${{ needs.documentation-quality.result }}" != "success" ] && FAILED="${FAILED}\n- Documentation Quality" + [ "${{ needs.coding-standards.result }}" != "success" ] && FAILED="${FAILED}\n- Coding Standards" + [ "${{ needs.license-compliance.result }}" != "success" ] && FAILED="${FAILED}\n- License Compliance" + [ "${{ needs.git-hygiene.result }}" != "success" ] && FAILED="${FAILED}\n- Git Hygiene" + [ "${{ needs.workflow-validation.result }}" != "success" ] && FAILED="${FAILED}\n- Workflow Validation" + [ "${{ needs.version-consistency.result }}" != "success" ] && FAILED="${FAILED}\n- Version Consistency" + [ "${{ needs.script-integrity.result }}" != "success" ] && FAILED="${FAILED}\n- Script Integrity" + [ "${{ needs.secret-scanning.result }}" != "success" ] && FAILED="${FAILED}\n- Secret Scanning" + [ "${{ needs.line-length-validation.result }}" != "success" ] && FAILED="${FAILED}\n- Line Length" + [ "${{ needs.file-size-limits.result }}" != "success" ] && FAILED="${FAILED}\n- File Size Limits" + [ "${{ needs.readme-completeness.result }}" != "success" ] && FAILED="${FAILED}\n- README Completeness" - jobs.forEach(job => { - if (job.failed) failedChecks.push(job.name); - }); + if [ -z "$FAILED" ]; then + echo "No failed checks to report" + exit 0 + fi - const body = `## 📋 Standards Compliance Violations Detected + TITLE="[Standards] Compliance violations — ${DATE}" + BODY="## Standards Compliance Violations - **Branch**: \`${context.ref}\` - **Commit**: ${context.sha.substring(0, 7)} - **Triggered by**: @${context.actor} - **Date**: ${new Date().toISOString()} + | Field | Value | + |-------|-------| + | **Branch** | \`${BRANCH}\` | + | **Commit** | \`${SHA:0:7}\` | + | **Actor** | @${ACTOR} | + | **Run** | [View workflow](${RUN_URL}) | - ### Compliance Score: ${compliancePercent}% + ### Failed Checks + $(printf '%b' "$FAILED") - ### ❌ Failed Checks - ${failedChecks.length > 0 ? failedChecks.map(check => `- ${check}`).join('\n') : '_Details available in workflow run_'} + ### Required Actions + 1. Review the [workflow run](${RUN_URL}) for details + 2. Fix each failed check + 3. Push to trigger a new scan - ### 📊 What This Means - Your repository does not meet the required MokoStandards compliance threshold. This can affect: - - Code quality and maintainability - - Team collaboration efficiency - - Automated tooling integration - - Repository discoverability + --- + *Auto-created by standards-compliance workflow*" - ### ✅ Required Actions - 1. Review the [workflow run](${context.payload.repository.html_url}/actions/runs/${context.runId}) for detailed findings - 2. Address each failed check according to MokoStandards documentation - 3. Push changes to trigger a new compliance scan - 4. Ensure compliance reaches 100% before merging + BODY=$(echo "$BODY" | sed 's/^ //') + LABEL="standards-violation" - ### 📚 Resources - - [MokoStandards Documentation](https://github.com/mokoconsulting-tech/MokoStandards) - - [Repository Structure Guide](https://github.com/mokoconsulting-tech/MokoStandards/tree/main/docs/policy/core-structure.md) - - [Documentation Standards](https://github.com/mokoconsulting-tech/MokoStandards/tree/main/docs/policy/document-formatting.md) - - [Coding Standards](https://github.com/mokoconsulting-tech/MokoStandards/tree/main/docs/policy/coding-style-guide.md) + gh label create "$LABEL" --repo "$REPO" --color "D73A4A" --description "Standards compliance failure" --force 2>/dev/null || true - ### 🔄 Resolution - This issue will be automatically closed when compliance reaches 100%. + EXISTING=$(gh api "repos/${REPO}/issues?labels=${LABEL}&state=all&per_page=1&sort=created&direction=desc" \ + --jq '.[0].number' 2>/dev/null) - --- - *This issue was automatically created by the Standards Compliance workflow.* - `; - - // Validate assignees before creating issue - async function validateAssignees(assignees) { - const validAssignees = []; - for (const assignee of assignees) { - try { - await github.rest.users.getByUsername({ username: assignee }); - validAssignees.push(assignee); - console.log(`✓ Validated assignee: ${assignee}`); - } catch (error) { - console.log(`✗ Invalid assignee (skipping): ${assignee} - ${error.message}`); - } - } - return validAssignees; - } - - const requestedAssignees = ['jmiller-moko']; - const validAssignees = await validateAssignees(requestedAssignees); - - // Check for existing open issue - const issues = await github.rest.issues.listForRepo({ - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - labels: 'standards-violation', - per_page: 1 - }); - - if (issues.data.length > 0) { - // Update existing issue - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: issues.data[0].number, - body: `### 🔄 Updated Scan Results\n\n${body}` - }); - console.log(`Updated existing issue #${issues.data[0].number}`); - } else { - // Create new issue with validated assignees - const issue = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: `[Standards] Compliance Violations - ${new Date().toISOString().split('T')[0]}`, - body: body, - labels: ['standards-violation', 'compliance', 'automation'], - assignees: validAssignees - }); - console.log(`Created new issue #${issue.data.number}`); - } + 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 "Updated issue #${EXISTING}" + else + gh issue create --repo "$REPO" --title "$TITLE" --body "$BODY" \ + --label "$LABEL" --assignee "jmiller-moko" + fi # CUSTOMIZATION: # @@ -2532,3 +2558,4 @@ jobs: # 3. Integrate with custom linting tools # 4. Add notification steps for compliance failures # 5. Customize required files/directories for your project type + diff --git a/.github/workflows/sync-version-on-merge.yml b/.github/workflows/sync-version-on-merge.yml index 54d8118..79cca55 100644 --- a/.github/workflows/sync-version-on-merge.yml +++ b/.github/workflows/sync-version-on-merge.yml @@ -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] " 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 }}" diff --git a/.gitignore b/.gitignore index 18cf5e1..d094d98 100644 --- a/.gitignore +++ b/.gitignore @@ -903,3 +903,8 @@ modulebuilder.txt # Keep-empty folders helper # ============================================================ !.gitkeep + +# ── MokoStandards sync (auto-appended) ──────────────────────────────── +.claude/ + +!src/media/vendor/ diff --git a/.mokostandards b/.mokostandards new file mode 100644 index 0000000..ed4a1bd --- /dev/null +++ b/.mokostandards @@ -0,0 +1,20 @@ +# Copyright (C) 2026 Moko Consulting +# 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" diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 77afc51..9e68438 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,79 +1,87 @@ -# Contributor Covenant Code of Conduct + +# 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cc0be62..46e4c20 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,273 +1,128 @@ -# Contributing to MokoStandards-Template-Joomla-Module + -### 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 -(): - - - -