diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml deleted file mode 100644 index 45c0a5f..0000000 --- a/.github/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.05.13 -# 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 repository root. 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-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 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 ".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 < ".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.05 --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' '' - } > 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 - - 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-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 deleted file mode 100644 index af275da..0000000 --- a/.github/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.05.13 -# 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 repository root. 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-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 → 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 ".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 < ".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.05 --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" >> .ftpignore - echo "*.min.css" >> .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' '' - } > update.xml - sed -i '/^[[:space:]]*$/d' update.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/.github/workflows/deploy-rs.yml b/.github/workflows/deploy-rs.yml deleted file mode 100644 index 5f9f8da..0000000 --- a/.github/workflows/deploy-rs.yml +++ /dev/null @@ -1,661 +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-rs.yml.template -# VERSION: 04.05.13 -# 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 .ftpignore file in the repository root. 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: 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 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 ".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 < ".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.RS_FTP_HOST }} - PORT_VAR: ${{ vars.RS_FTP_PORT }} - run: | - HOST="$HOST_RAW" - PORT="$PORT_VAR" - - if [ -z "$HOST" ]; then - echo "⏭️ RS_FTP_HOST not configured — skipping RS deployment." - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # 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' && steps.conn.outputs.skip != 'true' - 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 not configured — skipping RS deployment." - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - 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.05 --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 - - 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: 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/update-server.yml b/.github/workflows/update-server.yml new file mode 100644 index 0000000..91d9365 --- /dev/null +++ b/.github/workflows/update-server.yml @@ -0,0 +1,236 @@ +# Copyright (C) 2026 Moko Consulting +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# FILE INFORMATION +# DEFGROUP: GitHub.Workflow +# INGROUP: MokoStandards.Joomla +# REPO: https://github.com/mokoconsulting-tech/MokoStandards +# PATH: /templates/workflows/joomla/update-server.yml.template +# VERSION: 04.05.13 +# BRIEF: Update Joomla update server XML feed with stable/rc/dev entries +# +# Writes update.xml with multiple entries: +# - stable on push to main (from auto-release) +# - rc on push to rc/** +# - development on push to dev/** +# +# Joomla filters by user's "Minimum Stability" setting. + +name: Update Joomla Update Server XML Feed + +on: + push: + branches: + - 'dev/**' + - 'rc/**' + paths: + - 'src/**' + - 'htdocs/**' + workflow_dispatch: + inputs: + stability: + description: 'Stability tag (development, rc, stable)' + required: true + default: 'development' + type: choice + options: + - development + - rc + - stable + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +permissions: + contents: write + +jobs: + update-xml: + name: Update update.xml + runs-on: ubuntu-latest + + 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.05 --quiet \ + "https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \ + /tmp/mokostandards 2>/dev/null || true + if [ -d "/tmp/mokostandards" ] && [ -f "/tmp/mokostandards/composer.json" ]; then + cd /tmp/mokostandards && composer install --no-dev --no-interaction --quiet 2>/dev/null || true + fi + + - name: Generate update.xml entry + run: | + BRANCH="${{ github.ref_name }}" + REPO="${{ github.repository }}" + VERSION=$(php /tmp/mokostandards/api/cli/version_read.php --path . 2>/dev/null || echo "0.0.0") + + # Determine stability from branch or input + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + STABILITY="${{ inputs.stability }}" + elif [[ "$BRANCH" == rc/* ]]; then + STABILITY="rc" + elif [[ "$BRANCH" == dev/* ]]; then + STABILITY="development" + else + STABILITY="stable" + fi + + # Parse manifest + MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '/dev/null | head -1) + if [ -z "$MANIFEST" ]; then + echo "No Joomla manifest found — skipping" + exit 0 + fi + + 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 || echo "") + PHP_MINIMUM=$(grep -oP '\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "") + + [ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(basename "$MANIFEST" .xml) + [ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '' "/") + + CLIENT_TAG="" + [ -n "$EXT_CLIENT" ] && CLIENT_TAG="${EXT_CLIENT}" + [ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="site" + + FOLDER_TAG="" + [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="${EXT_FOLDER}" + + PHP_TAG="" + [ -n "$PHP_MINIMUM" ] && PHP_TAG="${PHP_MINIMUM}" + + # Version suffix for non-stable + DISPLAY_VERSION="$VERSION" + [ "$STABILITY" = "rc" ] && DISPLAY_VERSION="${VERSION}-rc" + [ "$STABILITY" = "development" ] && DISPLAY_VERSION="${VERSION}-dev" + + MAJOR=$(echo "$VERSION" | awk -F. '{print $1}') + RELEASE_TAG="v${MAJOR}" + DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${RELEASE_TAG}/${EXT_ELEMENT}-${VERSION}.zip" + INFO_URL="https://github.com/${REPO}" + + # ── Build the new entry ─────────────────────────────────────── + NEW_ENTRY=$(cat < + ${EXT_NAME} + ${EXT_NAME} (${STABILITY}) + ${EXT_ELEMENT} + ${EXT_TYPE} + ${DISPLAY_VERSION} + $([ -n "$CLIENT_TAG" ] && echo " ${CLIENT_TAG}") + $([ -n "$FOLDER_TAG" ] && echo " ${FOLDER_TAG}") + + ${STABILITY} + + ${INFO_URL} + + ${DOWNLOAD_URL} + + ${TARGET_PLATFORM} + $([ -n "$PHP_TAG" ] && echo " ${PHP_TAG}") + Moko Consulting + https://mokoconsulting.tech + +XMLEOF +) + + # ── Merge into update.xml ───────────────────────────────────── + if [ ! -f "update.xml" ]; then + # Create fresh + printf '%s\n' '' > update.xml + printf '%s\n' '' >> update.xml + echo "$NEW_ENTRY" >> update.xml + printf '%s\n' '' >> update.xml + else + # Remove existing entry for this stability, add new one + # Use python for reliable XML manipulation + python3 -c " +import re, sys + +with open('update.xml', 'r') as f: + content = f.read() + +# Remove existing entry with this stability tag +pattern = r' .*?${STABILITY}.*?\n?' +content = re.sub(pattern, '', content, flags=re.DOTALL) + +# Insert new entry before +new_entry = '''${NEW_ENTRY}''' +content = content.replace('', new_entry + '\n') + +# Clean up empty lines +content = re.sub(r'\n{3,}', '\n\n', content) + +with open('update.xml', 'w') as f: + f.write(content) +" 2>/dev/null || { + # Fallback: just rewrite the whole file if python fails + # Keep existing stable entry if present + STABLE_ENTRY="" + if [ "$STABILITY" != "stable" ] && grep -q 'stable' update.xml; then + STABLE_ENTRY=$(sed -n '//,/<\/update>/{ /stable<\/tag>/,/<\/update>/p; //,/stable<\/tag>/p }' update.xml | sort -u) + fi + RC_ENTRY="" + if [ "$STABILITY" != "rc" ] && grep -q 'rc' update.xml; then + RC_ENTRY=$(python3 -c " +import re +with open('update.xml') as f: c = f.read() +m = re.search(r'(.*?rc.*?)', c, re.DOTALL) +if m: print(m.group(1)) +" 2>/dev/null || true) + fi + DEV_ENTRY="" + if [ "$STABILITY" != "development" ] && grep -q 'development' update.xml; then + DEV_ENTRY=$(python3 -c " +import re +with open('update.xml') as f: c = f.read() +m = re.search(r'(.*?development.*?)', c, re.DOTALL) +if m: print(m.group(1)) +" 2>/dev/null || true) + fi + + { + printf '%s\n' '' + printf '%s\n' '' + [ -n "$STABLE_ENTRY" ] && echo "$STABLE_ENTRY" + [ -n "$RC_ENTRY" ] && echo "$RC_ENTRY" + [ -n "$DEV_ENTRY" ] && echo "$DEV_ENTRY" + echo "$NEW_ENTRY" + printf '%s\n' '' + } > update.xml + } + fi + + # Commit + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + git add update.xml + git diff --cached --quiet || { + git commit -m "chore: update update.xml (${STABILITY}: ${DISPLAY_VERSION}) [skip ci]" \ + --author="github-actions[bot] " + git push + } + + echo "## Joomla Update Server" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Stability | \`${STABILITY}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Version | \`${DISPLAY_VERSION}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Element | \`${EXT_ELEMENT}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Download | [ZIP](${DOWNLOAD_URL}) |" >> $GITHUB_STEP_SUMMARY