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