ci: Joomla → update.xml releases, Dolibarr/generic → FTP deploy
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
734
.github/workflows/deploy-demo.yml
vendored
734
.github/workflows/deploy-demo.yml
vendored
@@ -1,734 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# This file is part of a Moko Consulting project.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: GitHub.Workflow
|
|
||||||
# INGROUP: MokoStandards.Deploy
|
|
||||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
|
||||||
# PATH: /templates/workflows/shared/deploy-demo.yml.template
|
|
||||||
# VERSION: 04.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'
|
|
||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
require '/tmp/mokostandards/vendor/autoload.php';
|
|
||||||
|
|
||||||
use phpseclib3\Net\SFTP;
|
|
||||||
use phpseclib3\Crypt\PublicKeyLoader;
|
|
||||||
|
|
||||||
$host = (string) getenv('SFTP_HOST');
|
|
||||||
$port = (int) getenv('SFTP_PORT');
|
|
||||||
$username = (string) getenv('SFTP_USER');
|
|
||||||
$authMethod = (string) getenv('AUTH_METHOD');
|
|
||||||
$usePassphrase = getenv('USE_PASSPHRASE') === 'true';
|
|
||||||
$hasPassword = getenv('HAS_PASSWORD') === 'true';
|
|
||||||
$remotePath = rtrim((string) getenv('REMOTE_PATH'), '/');
|
|
||||||
|
|
||||||
echo "⚠️ Clearing remote folder: {$remotePath}\n";
|
|
||||||
|
|
||||||
$sftp = new SFTP($host, $port);
|
|
||||||
|
|
||||||
// ── Authentication ──────────────────────────────────────────────
|
|
||||||
if ($authMethod === 'key') {
|
|
||||||
$keyData = (string) getenv('SFTP_KEY');
|
|
||||||
$passphrase = $usePassphrase ? (string) getenv('SFTP_PASSWORD') : false;
|
|
||||||
$password = $hasPassword ? (string) getenv('SFTP_PASSWORD') : '';
|
|
||||||
$key = PublicKeyLoader::load($keyData, $passphrase);
|
|
||||||
if (!$sftp->login($username, $key)) {
|
|
||||||
if ($password !== '') {
|
|
||||||
echo "⚠️ Key auth failed — falling back to password\n";
|
|
||||||
if (!$sftp->login($username, $password)) {
|
|
||||||
fwrite(STDERR, "❌ Both key and password authentication failed\n");
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
echo "✅ Connected via password authentication (key fallback)\n";
|
|
||||||
} else {
|
|
||||||
fwrite(STDERR, "❌ Key authentication failed and no password fallback is available\n");
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
echo "✅ Connected via SSH key authentication\n";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!$sftp->login($username, (string) getenv('SFTP_PASSWORD'))) {
|
|
||||||
fwrite(STDERR, "❌ Password authentication failed\n");
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
echo "✅ Connected via password authentication\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Recursive delete ────────────────────────────────────────────
|
|
||||||
function rmrf(SFTP $sftp, string $path): void
|
|
||||||
{
|
|
||||||
$entries = $sftp->nlist($path);
|
|
||||||
if ($entries === false) {
|
|
||||||
return; // path does not exist — nothing to clear
|
|
||||||
}
|
|
||||||
foreach ($entries as $name) {
|
|
||||||
if ($name === '.' || $name === '..') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$entry = "{$path}/{$name}";
|
|
||||||
if ($sftp->is_dir($entry)) {
|
|
||||||
rmrf($sftp, $entry);
|
|
||||||
$sftp->rmdir($entry);
|
|
||||||
echo " 🗑️ Removed dir: {$entry}\n";
|
|
||||||
} else {
|
|
||||||
$sftp->delete($entry);
|
|
||||||
echo " 🗑️ Removed file: {$entry}\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Create remote directory tree ────────────────────────────────
|
|
||||||
function sftpMakedirs(SFTP $sftp, string $path): void
|
|
||||||
{
|
|
||||||
$parts = array_values(array_filter(explode('/', $path), fn(string $p) => $p !== ''));
|
|
||||||
$current = str_starts_with($path, '/') ? '' : '';
|
|
||||||
foreach ($parts as $part) {
|
|
||||||
$current .= '/' . $part;
|
|
||||||
$sftp->mkdir($current); // silently returns false if already exists
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rmrf($sftp, $remotePath);
|
|
||||||
sftpMakedirs($sftp, $remotePath);
|
|
||||||
echo "✅ Remote folder ready: {$remotePath}\n";
|
|
||||||
PHPEOF
|
|
||||||
php /tmp/moko_clear.php
|
|
||||||
|
|
||||||
- name: Deploy via SFTP
|
|
||||||
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
|
|
||||||
env:
|
|
||||||
SFTP_HOST: ${{ steps.conn.outputs.host }}
|
|
||||||
SFTP_PORT: ${{ steps.conn.outputs.port }}
|
|
||||||
SFTP_USER: ${{ vars.DEMO_FTP_USERNAME }}
|
|
||||||
SFTP_KEY: ${{ secrets.DEMO_FTP_KEY }}
|
|
||||||
SFTP_PASSWORD: ${{ secrets.DEMO_FTP_PASSWORD }}
|
|
||||||
AUTH_METHOD: ${{ steps.auth.outputs.method }}
|
|
||||||
USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }}
|
|
||||||
REMOTE_PATH: ${{ steps.remote.outputs.path }}
|
|
||||||
SOURCE_DIR: ${{ steps.source.outputs.dir }}
|
|
||||||
run: |
|
|
||||||
# ── Write SSH key to temp file (key auth only) ────────────────────────
|
|
||||||
if [ "$AUTH_METHOD" = "key" ]; then
|
|
||||||
printf '%s' "$SFTP_KEY" > /tmp/deploy_key
|
|
||||||
chmod 600 /tmp/deploy_key
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Generate sftp-config.json safely via jq ───────────────────────────
|
|
||||||
if [ "$AUTH_METHOD" = "key" ]; then
|
|
||||||
jq -n \
|
|
||||||
--arg host "$SFTP_HOST" \
|
|
||||||
--argjson port "${SFTP_PORT:-22}" \
|
|
||||||
--arg user "$SFTP_USER" \
|
|
||||||
--arg path "$REMOTE_PATH" \
|
|
||||||
--arg key "/tmp/deploy_key" \
|
|
||||||
'{host:$host, port:$port, user:$user, remote_path:$path, ssh_key_file:$key}' \
|
|
||||||
> /tmp/sftp-config.json
|
|
||||||
else
|
|
||||||
jq -n \
|
|
||||||
--arg host "$SFTP_HOST" \
|
|
||||||
--argjson port "${SFTP_PORT:-22}" \
|
|
||||||
--arg user "$SFTP_USER" \
|
|
||||||
--arg path "$REMOTE_PATH" \
|
|
||||||
--arg pass "$SFTP_PASSWORD" \
|
|
||||||
'{host:$host, port:$port, user:$user, remote_path:$path, password:$pass}' \
|
|
||||||
> /tmp/sftp-config.json
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Write update files (demo = stable) ─────────────────────────────
|
|
||||||
PLATFORM=$(php /tmp/mokostandards/api/cli/platform_detect.php --path . 2>/dev/null || true)
|
|
||||||
VERSION=$(php /tmp/mokostandards/api/cli/version_read.php --path . 2>/dev/null || echo "unknown")
|
|
||||||
REPO="${{ github.repository }}"
|
|
||||||
|
|
||||||
if [ "$PLATFORM" = "crm-module" ]; then
|
|
||||||
printf '%s' "$VERSION" > update.txt
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$PLATFORM" = "waas-component" ]; then
|
|
||||||
MANIFEST=$(find . -maxdepth 2 -name "*.xml" -exec grep -l '<extension' {} \; 2>/dev/null | head -1 || true)
|
|
||||||
if [ -n "$MANIFEST" ]; then
|
|
||||||
EXT_NAME=$(grep -oP '<name>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "${{ github.event.repository.name }}")
|
|
||||||
EXT_TYPE=$(grep -oP '<extension[^>]+type="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "component")
|
|
||||||
EXT_ELEMENT=$(grep -oP '<element>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || basename "$MANIFEST" .xml)
|
|
||||||
EXT_CLIENT=$(grep -oP '<extension[^>]+client="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
|
|
||||||
EXT_FOLDER=$(grep -oP '<extension[^>]+group="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
|
|
||||||
TARGET_PLATFORM=$(grep -oP '<targetplatform[^/]*/' "$MANIFEST" 2>/dev/null | head -1 || true)
|
|
||||||
[ -n "$TARGET_PLATFORM" ] && TARGET_PLATFORM="${TARGET_PLATFORM}>"
|
|
||||||
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="5.*" %s>' "/")
|
|
||||||
|
|
||||||
CLIENT_TAG=""
|
|
||||||
if [ -n "$EXT_CLIENT" ]; then CLIENT_TAG="<client>${EXT_CLIENT}</client>"; elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then CLIENT_TAG="<client>site</client>"; fi
|
|
||||||
FOLDER_TAG=""
|
|
||||||
if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"; fi
|
|
||||||
|
|
||||||
DOWNLOAD_URL="https://github.com/${REPO}/releases/download/v${VERSION}/${EXT_ELEMENT}-${VERSION}.zip"
|
|
||||||
{
|
|
||||||
printf '%s\n' '<?xml version="1.0" encoding="utf-8"?>'
|
|
||||||
printf '%s\n' '<updates>'
|
|
||||||
printf '%s\n' ' <update>'
|
|
||||||
printf '%s\n' " <name>${EXT_NAME}</name>"
|
|
||||||
printf '%s\n' " <description>${EXT_NAME} update</description>"
|
|
||||||
printf '%s\n' " <element>${EXT_ELEMENT}</element>"
|
|
||||||
printf '%s\n' " <type>${EXT_TYPE}</type>"
|
|
||||||
printf '%s\n' " <version>${VERSION}</version>"
|
|
||||||
[ -n "$CLIENT_TAG" ] && printf '%s\n' " ${CLIENT_TAG}"
|
|
||||||
[ -n "$FOLDER_TAG" ] && printf '%s\n' " ${FOLDER_TAG}"
|
|
||||||
printf '%s\n' ' <tags>'
|
|
||||||
printf '%s\n' ' <tag>stable</tag>'
|
|
||||||
printf '%s\n' ' </tags>'
|
|
||||||
printf '%s\n' " <infourl title=\"${EXT_NAME}\">https://github.com/${REPO}</infourl>"
|
|
||||||
printf '%s\n' ' <downloads>'
|
|
||||||
printf '%s\n' " <downloadurl type=\"full\" format=\"zip\">${DOWNLOAD_URL}</downloadurl>"
|
|
||||||
printf '%s\n' ' </downloads>'
|
|
||||||
printf '%s\n' " ${TARGET_PLATFORM}"
|
|
||||||
printf '%s\n' ' <maintainer>Moko Consulting</maintainer>'
|
|
||||||
printf '%s\n' ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>'
|
|
||||||
printf '%s\n' ' </update>'
|
|
||||||
printf '%s\n' '</updates>'
|
|
||||||
} > 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
|
|
||||||
700
.github/workflows/deploy-dev.yml
vendored
700
.github/workflows/deploy-dev.yml
vendored
@@ -1,700 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# This file is part of a Moko Consulting project.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: GitHub.Workflow
|
|
||||||
# INGROUP: MokoStandards.Deploy
|
|
||||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
|
||||||
# PATH: /templates/workflows/shared/deploy-dev.yml.template
|
|
||||||
# VERSION: 04.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'
|
|
||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
require '/tmp/mokostandards/vendor/autoload.php';
|
|
||||||
|
|
||||||
use phpseclib3\Net\SFTP;
|
|
||||||
use phpseclib3\Crypt\PublicKeyLoader;
|
|
||||||
|
|
||||||
$host = (string) getenv('SFTP_HOST');
|
|
||||||
$port = (int) getenv('SFTP_PORT');
|
|
||||||
$username = (string) getenv('SFTP_USER');
|
|
||||||
$authMethod = (string) getenv('AUTH_METHOD');
|
|
||||||
$usePassphrase = getenv('USE_PASSPHRASE') === 'true';
|
|
||||||
$hasPassword = getenv('HAS_PASSWORD') === 'true';
|
|
||||||
$remotePath = rtrim((string) getenv('REMOTE_PATH'), '/');
|
|
||||||
|
|
||||||
echo "⚠️ Clearing remote folder: {$remotePath}\n";
|
|
||||||
|
|
||||||
$sftp = new SFTP($host, $port);
|
|
||||||
|
|
||||||
// ── Authentication ──────────────────────────────────────────────
|
|
||||||
if ($authMethod === 'key') {
|
|
||||||
$keyData = (string) getenv('SFTP_KEY');
|
|
||||||
$passphrase = $usePassphrase ? (string) getenv('SFTP_PASSWORD') : false;
|
|
||||||
$password = $hasPassword ? (string) getenv('SFTP_PASSWORD') : '';
|
|
||||||
$key = PublicKeyLoader::load($keyData, $passphrase);
|
|
||||||
if (!$sftp->login($username, $key)) {
|
|
||||||
if ($password !== '') {
|
|
||||||
echo "⚠️ Key auth failed — falling back to password\n";
|
|
||||||
if (!$sftp->login($username, $password)) {
|
|
||||||
fwrite(STDERR, "❌ Both key and password authentication failed\n");
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
echo "✅ Connected via password authentication (key fallback)\n";
|
|
||||||
} else {
|
|
||||||
fwrite(STDERR, "❌ Key authentication failed and no password fallback is available\n");
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
echo "✅ Connected via SSH key authentication\n";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!$sftp->login($username, (string) getenv('SFTP_PASSWORD'))) {
|
|
||||||
fwrite(STDERR, "❌ Password authentication failed\n");
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
echo "✅ Connected via password authentication\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Recursive delete ────────────────────────────────────────────
|
|
||||||
function rmrf(SFTP $sftp, string $path): void
|
|
||||||
{
|
|
||||||
$entries = $sftp->nlist($path);
|
|
||||||
if ($entries === false) {
|
|
||||||
return; // path does not exist — nothing to clear
|
|
||||||
}
|
|
||||||
foreach ($entries as $name) {
|
|
||||||
if ($name === '.' || $name === '..') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$entry = "{$path}/{$name}";
|
|
||||||
if ($sftp->is_dir($entry)) {
|
|
||||||
rmrf($sftp, $entry);
|
|
||||||
$sftp->rmdir($entry);
|
|
||||||
echo " 🗑️ Removed dir: {$entry}\n";
|
|
||||||
} else {
|
|
||||||
$sftp->delete($entry);
|
|
||||||
echo " 🗑️ Removed file: {$entry}\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Create remote directory tree ────────────────────────────────
|
|
||||||
function sftpMakedirs(SFTP $sftp, string $path): void
|
|
||||||
{
|
|
||||||
$parts = array_values(array_filter(explode('/', $path), fn(string $p) => $p !== ''));
|
|
||||||
$current = str_starts_with($path, '/') ? '' : '';
|
|
||||||
foreach ($parts as $part) {
|
|
||||||
$current .= '/' . $part;
|
|
||||||
$sftp->mkdir($current); // silently returns false if already exists
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rmrf($sftp, $remotePath);
|
|
||||||
sftpMakedirs($sftp, $remotePath);
|
|
||||||
echo "✅ Remote folder ready: {$remotePath}\n";
|
|
||||||
PHPEOF
|
|
||||||
php /tmp/moko_clear.php
|
|
||||||
|
|
||||||
- name: Deploy via SFTP
|
|
||||||
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
|
|
||||||
env:
|
|
||||||
SFTP_HOST: ${{ steps.conn.outputs.host }}
|
|
||||||
SFTP_PORT: ${{ steps.conn.outputs.port }}
|
|
||||||
SFTP_USER: ${{ vars.DEV_FTP_USERNAME }}
|
|
||||||
SFTP_KEY: ${{ secrets.DEV_FTP_KEY }}
|
|
||||||
SFTP_PASSWORD: ${{ secrets.DEV_FTP_PASSWORD }}
|
|
||||||
AUTH_METHOD: ${{ steps.auth.outputs.method }}
|
|
||||||
USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }}
|
|
||||||
REMOTE_PATH: ${{ steps.remote.outputs.path }}
|
|
||||||
SOURCE_DIR: ${{ steps.source.outputs.dir }}
|
|
||||||
run: |
|
|
||||||
# ── Write SSH key to temp file (key auth only) ────────────────────────
|
|
||||||
if [ "$AUTH_METHOD" = "key" ]; then
|
|
||||||
printf '%s' "$SFTP_KEY" > /tmp/deploy_key
|
|
||||||
chmod 600 /tmp/deploy_key
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Generate sftp-config.json safely via jq ───────────────────────────
|
|
||||||
if [ "$AUTH_METHOD" = "key" ]; then
|
|
||||||
jq -n \
|
|
||||||
--arg host "$SFTP_HOST" \
|
|
||||||
--argjson port "${SFTP_PORT:-22}" \
|
|
||||||
--arg user "$SFTP_USER" \
|
|
||||||
--arg path "$REMOTE_PATH" \
|
|
||||||
--arg key "/tmp/deploy_key" \
|
|
||||||
'{host:$host, port:$port, user:$user, remote_path:$path, ssh_key_file:$key}' \
|
|
||||||
> /tmp/sftp-config.json
|
|
||||||
else
|
|
||||||
jq -n \
|
|
||||||
--arg host "$SFTP_HOST" \
|
|
||||||
--argjson port "${SFTP_PORT:-22}" \
|
|
||||||
--arg user "$SFTP_USER" \
|
|
||||||
--arg path "$REMOTE_PATH" \
|
|
||||||
--arg pass "$SFTP_PASSWORD" \
|
|
||||||
'{host:$host, port:$port, user:$user, remote_path:$path, password:$pass}' \
|
|
||||||
> /tmp/sftp-config.json
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Dev deploys skip minified files — use unminified sources for debugging
|
|
||||||
echo "*.min.js" >> .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 '<extension' {} \; 2>/dev/null | head -1 || true)
|
|
||||||
if [ -n "$MANIFEST" ]; then
|
|
||||||
EXT_NAME=$(grep -oP '<name>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "${{ github.event.repository.name }}")
|
|
||||||
EXT_TYPE=$(grep -oP '<extension[^>]+type="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "component")
|
|
||||||
EXT_ELEMENT=$(grep -oP '<element>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || basename "$MANIFEST" .xml)
|
|
||||||
EXT_CLIENT=$(grep -oP '<extension[^>]+client="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
|
|
||||||
EXT_FOLDER=$(grep -oP '<extension[^>]+group="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
|
|
||||||
TARGET_PLATFORM=$(grep -oP '<targetplatform[^/]*/' "$MANIFEST" 2>/dev/null | head -1 || true)
|
|
||||||
[ -n "$TARGET_PLATFORM" ] && TARGET_PLATFORM="${TARGET_PLATFORM}>"
|
|
||||||
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="5.*" %s>' "/")
|
|
||||||
|
|
||||||
CLIENT_TAG=""
|
|
||||||
if [ -n "$EXT_CLIENT" ]; then
|
|
||||||
CLIENT_TAG="<client>${EXT_CLIENT}</client>"
|
|
||||||
elif [ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]; then
|
|
||||||
CLIENT_TAG="<client>site</client>"
|
|
||||||
fi
|
|
||||||
|
|
||||||
FOLDER_TAG=""
|
|
||||||
if [ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ]; then
|
|
||||||
FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
|
|
||||||
fi
|
|
||||||
|
|
||||||
DOWNLOAD_URL="https://github.com/${REPO}/archive/refs/heads/${BRANCH}.zip"
|
|
||||||
|
|
||||||
{
|
|
||||||
printf '%s\n' '<?xml version="1.0" encoding="utf-8"?>'
|
|
||||||
printf '%s\n' '<updates>'
|
|
||||||
printf '%s\n' ' <update>'
|
|
||||||
printf '%s\n' " <name>${EXT_NAME}</name>"
|
|
||||||
printf '%s\n' " <description>${EXT_NAME} ${STABILITY} build</description>"
|
|
||||||
printf '%s\n' " <element>${EXT_ELEMENT}</element>"
|
|
||||||
printf '%s\n' " <type>${EXT_TYPE}</type>"
|
|
||||||
printf '%s\n' " <version>${VERSION_LABEL}</version>"
|
|
||||||
[ -n "$CLIENT_TAG" ] && printf '%s\n' " ${CLIENT_TAG}"
|
|
||||||
[ -n "$FOLDER_TAG" ] && printf '%s\n' " ${FOLDER_TAG}"
|
|
||||||
printf '%s\n' ' <tags>'
|
|
||||||
printf '%s\n' " <tag>${STABILITY}</tag>"
|
|
||||||
printf '%s\n' ' </tags>'
|
|
||||||
printf '%s\n' " <infourl title=\"${EXT_NAME}\">https://github.com/${REPO}/tree/${BRANCH}</infourl>"
|
|
||||||
printf '%s\n' ' <downloads>'
|
|
||||||
printf '%s\n' " <downloadurl type=\"full\" format=\"zip\">${DOWNLOAD_URL}</downloadurl>"
|
|
||||||
printf '%s\n' ' </downloads>'
|
|
||||||
printf '%s\n' " ${TARGET_PLATFORM}"
|
|
||||||
printf '%s\n' ' <maintainer>Moko Consulting</maintainer>'
|
|
||||||
printf '%s\n' ' <maintainerurl>https://mokoconsulting.tech</maintainerurl>'
|
|
||||||
printf '%s\n' ' </update>'
|
|
||||||
printf '%s\n' '</updates>'
|
|
||||||
} > 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
|
|
||||||
661
.github/workflows/deploy-rs.yml
vendored
661
.github/workflows/deploy-rs.yml
vendored
@@ -1,661 +0,0 @@
|
|||||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
||||||
#
|
|
||||||
# This file is part of a Moko Consulting project.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# FILE INFORMATION
|
|
||||||
# DEFGROUP: GitHub.Workflow
|
|
||||||
# INGROUP: MokoStandards.Deploy
|
|
||||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
|
||||||
# PATH: /templates/workflows/shared/deploy-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'
|
|
||||||
<?php
|
|
||||||
declare(strict_types=1);
|
|
||||||
require '/tmp/mokostandards/vendor/autoload.php';
|
|
||||||
|
|
||||||
use phpseclib3\Net\SFTP;
|
|
||||||
use phpseclib3\Crypt\PublicKeyLoader;
|
|
||||||
|
|
||||||
$host = (string) getenv('SFTP_HOST');
|
|
||||||
$port = (int) getenv('SFTP_PORT');
|
|
||||||
$username = (string) getenv('SFTP_USER');
|
|
||||||
$authMethod = (string) getenv('AUTH_METHOD');
|
|
||||||
$usePassphrase = getenv('USE_PASSPHRASE') === 'true';
|
|
||||||
$hasPassword = getenv('HAS_PASSWORD') === 'true';
|
|
||||||
$remotePath = rtrim((string) getenv('REMOTE_PATH'), '/');
|
|
||||||
|
|
||||||
echo "⚠️ Clearing remote folder: {$remotePath}\n";
|
|
||||||
|
|
||||||
$sftp = new SFTP($host, $port);
|
|
||||||
|
|
||||||
// ── Authentication ──────────────────────────────────────────────
|
|
||||||
if ($authMethod === 'key') {
|
|
||||||
$keyData = (string) getenv('SFTP_KEY');
|
|
||||||
$passphrase = $usePassphrase ? (string) getenv('SFTP_PASSWORD') : false;
|
|
||||||
$password = $hasPassword ? (string) getenv('SFTP_PASSWORD') : '';
|
|
||||||
$key = PublicKeyLoader::load($keyData, $passphrase);
|
|
||||||
if (!$sftp->login($username, $key)) {
|
|
||||||
if ($password !== '') {
|
|
||||||
echo "⚠️ Key auth failed — falling back to password\n";
|
|
||||||
if (!$sftp->login($username, $password)) {
|
|
||||||
fwrite(STDERR, "❌ Both key and password authentication failed\n");
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
echo "✅ Connected via password authentication (key fallback)\n";
|
|
||||||
} else {
|
|
||||||
fwrite(STDERR, "❌ Key authentication failed and no password fallback is available\n");
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
echo "✅ Connected via SSH key authentication\n";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!$sftp->login($username, (string) getenv('SFTP_PASSWORD'))) {
|
|
||||||
fwrite(STDERR, "❌ Password authentication failed\n");
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
echo "✅ Connected via password authentication\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Recursive delete ────────────────────────────────────────────
|
|
||||||
function rmrf(SFTP $sftp, string $path): void
|
|
||||||
{
|
|
||||||
$entries = $sftp->nlist($path);
|
|
||||||
if ($entries === false) {
|
|
||||||
return; // path does not exist — nothing to clear
|
|
||||||
}
|
|
||||||
foreach ($entries as $name) {
|
|
||||||
if ($name === '.' || $name === '..') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$entry = "{$path}/{$name}";
|
|
||||||
if ($sftp->is_dir($entry)) {
|
|
||||||
rmrf($sftp, $entry);
|
|
||||||
$sftp->rmdir($entry);
|
|
||||||
echo " 🗑️ Removed dir: {$entry}\n";
|
|
||||||
} else {
|
|
||||||
$sftp->delete($entry);
|
|
||||||
echo " 🗑️ Removed file: {$entry}\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Create remote directory tree ────────────────────────────────
|
|
||||||
function sftpMakedirs(SFTP $sftp, string $path): void
|
|
||||||
{
|
|
||||||
$parts = array_values(array_filter(explode('/', $path), fn(string $p) => $p !== ''));
|
|
||||||
$current = str_starts_with($path, '/') ? '' : '';
|
|
||||||
foreach ($parts as $part) {
|
|
||||||
$current .= '/' . $part;
|
|
||||||
$sftp->mkdir($current); // silently returns false if already exists
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rmrf($sftp, $remotePath);
|
|
||||||
sftpMakedirs($sftp, $remotePath);
|
|
||||||
echo "✅ Remote folder ready: {$remotePath}\n";
|
|
||||||
PHPEOF
|
|
||||||
php /tmp/moko_clear.php
|
|
||||||
|
|
||||||
- name: Deploy via SFTP
|
|
||||||
if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true'
|
|
||||||
env:
|
|
||||||
SFTP_HOST: ${{ steps.conn.outputs.host }}
|
|
||||||
SFTP_PORT: ${{ steps.conn.outputs.port }}
|
|
||||||
SFTP_USER: ${{ vars.RS_FTP_USERNAME }}
|
|
||||||
SFTP_KEY: ${{ secrets.RS_FTP_KEY }}
|
|
||||||
SFTP_PASSWORD: ${{ secrets.RS_FTP_PASSWORD }}
|
|
||||||
AUTH_METHOD: ${{ steps.auth.outputs.method }}
|
|
||||||
USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }}
|
|
||||||
REMOTE_PATH: ${{ steps.remote.outputs.path }}
|
|
||||||
SOURCE_DIR: ${{ steps.source.outputs.dir }}
|
|
||||||
run: |
|
|
||||||
# ── Write SSH key to temp file (key auth only) ────────────────────────
|
|
||||||
if [ "$AUTH_METHOD" = "key" ]; then
|
|
||||||
printf '%s' "$SFTP_KEY" > /tmp/deploy_key
|
|
||||||
chmod 600 /tmp/deploy_key
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Generate sftp-config.json safely via jq ───────────────────────────
|
|
||||||
if [ "$AUTH_METHOD" = "key" ]; then
|
|
||||||
jq -n \
|
|
||||||
--arg host "$SFTP_HOST" \
|
|
||||||
--argjson port "${SFTP_PORT:-22}" \
|
|
||||||
--arg user "$SFTP_USER" \
|
|
||||||
--arg path "$REMOTE_PATH" \
|
|
||||||
--arg key "/tmp/deploy_key" \
|
|
||||||
'{host:$host, port:$port, user:$user, remote_path:$path, ssh_key_file:$key}' \
|
|
||||||
> /tmp/sftp-config.json
|
|
||||||
else
|
|
||||||
jq -n \
|
|
||||||
--arg host "$SFTP_HOST" \
|
|
||||||
--argjson port "${SFTP_PORT:-22}" \
|
|
||||||
--arg user "$SFTP_USER" \
|
|
||||||
--arg path "$REMOTE_PATH" \
|
|
||||||
--arg pass "$SFTP_PASSWORD" \
|
|
||||||
'{host:$host, port:$port, user:$user, remote_path:$path, password:$pass}' \
|
|
||||||
> /tmp/sftp-config.json
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Run deploy-sftp.php from MokoStandards ────────────────────────────
|
|
||||||
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
|
|
||||||
if [ "$USE_PASSPHRASE" = "true" ]; then
|
|
||||||
DEPLOY_ARGS+=(--key-passphrase "$SFTP_PASSWORD")
|
|
||||||
fi
|
|
||||||
|
|
||||||
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
|
|
||||||
236
.github/workflows/update-server.yml
vendored
Normal file
236
.github/workflows/update-server.yml
vendored
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||||
|
#
|
||||||
|
# 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 <update> entries:
|
||||||
|
# - <tag>stable</tag> on push to main (from auto-release)
|
||||||
|
# - <tag>rc</tag> on push to rc/**
|
||||||
|
# - <tag>development</tag> 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 '<extension' {} \; 2>/dev/null | head -1)
|
||||||
|
if [ -z "$MANIFEST" ]; then
|
||||||
|
echo "No Joomla manifest found — skipping"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
EXT_NAME=$(grep -oP '<name>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "${{ github.event.repository.name }}")
|
||||||
|
EXT_TYPE=$(grep -oP '<extension[^>]+type="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "component")
|
||||||
|
EXT_ELEMENT=$(grep -oP '<element>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || basename "$MANIFEST" .xml)
|
||||||
|
EXT_CLIENT=$(grep -oP '<extension[^>]+client="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
|
||||||
|
EXT_FOLDER=$(grep -oP '<extension[^>]+group="\K[^"]+' "$MANIFEST" 2>/dev/null || echo "")
|
||||||
|
TARGET_PLATFORM=$(grep -oP '<targetplatform[^/]*/>' "$MANIFEST" 2>/dev/null | head -1 || echo "")
|
||||||
|
PHP_MINIMUM=$(grep -oP '<php_minimum>\K[^<]+' "$MANIFEST" 2>/dev/null | head -1 || echo "")
|
||||||
|
|
||||||
|
[ -z "$EXT_ELEMENT" ] && EXT_ELEMENT=$(basename "$MANIFEST" .xml)
|
||||||
|
[ -z "$TARGET_PLATFORM" ] && TARGET_PLATFORM=$(printf '<targetplatform name="joomla" version="5.*" %s>' "/")
|
||||||
|
|
||||||
|
CLIENT_TAG=""
|
||||||
|
[ -n "$EXT_CLIENT" ] && CLIENT_TAG="<client>${EXT_CLIENT}</client>"
|
||||||
|
[ -z "$CLIENT_TAG" ] && ([ "$EXT_TYPE" = "module" ] || [ "$EXT_TYPE" = "plugin" ]) && CLIENT_TAG="<client>site</client>"
|
||||||
|
|
||||||
|
FOLDER_TAG=""
|
||||||
|
[ -n "$EXT_FOLDER" ] && [ "$EXT_TYPE" = "plugin" ] && FOLDER_TAG="<folder>${EXT_FOLDER}</folder>"
|
||||||
|
|
||||||
|
PHP_TAG=""
|
||||||
|
[ -n "$PHP_MINIMUM" ] && PHP_TAG="<php_minimum>${PHP_MINIMUM}</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 <<XMLEOF
|
||||||
|
<update>
|
||||||
|
<name>${EXT_NAME}</name>
|
||||||
|
<description>${EXT_NAME} (${STABILITY})</description>
|
||||||
|
<element>${EXT_ELEMENT}</element>
|
||||||
|
<type>${EXT_TYPE}</type>
|
||||||
|
<version>${DISPLAY_VERSION}</version>
|
||||||
|
$([ -n "$CLIENT_TAG" ] && echo " ${CLIENT_TAG}")
|
||||||
|
$([ -n "$FOLDER_TAG" ] && echo " ${FOLDER_TAG}")
|
||||||
|
<tags>
|
||||||
|
<tag>${STABILITY}</tag>
|
||||||
|
</tags>
|
||||||
|
<infourl title="${EXT_NAME}">${INFO_URL}</infourl>
|
||||||
|
<downloads>
|
||||||
|
<downloadurl type="full" format="zip">${DOWNLOAD_URL}</downloadurl>
|
||||||
|
</downloads>
|
||||||
|
${TARGET_PLATFORM}
|
||||||
|
$([ -n "$PHP_TAG" ] && echo " ${PHP_TAG}")
|
||||||
|
<maintainer>Moko Consulting</maintainer>
|
||||||
|
<maintainerurl>https://mokoconsulting.tech</maintainerurl>
|
||||||
|
</update>
|
||||||
|
XMLEOF
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Merge into update.xml ─────────────────────────────────────
|
||||||
|
if [ ! -f "update.xml" ]; then
|
||||||
|
# Create fresh
|
||||||
|
printf '%s\n' '<?xml version="1.0" encoding="utf-8"?>' > update.xml
|
||||||
|
printf '%s\n' '<updates>' >> update.xml
|
||||||
|
echo "$NEW_ENTRY" >> update.xml
|
||||||
|
printf '%s\n' '</updates>' >> 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' <update>.*?<tag>${STABILITY}</tag>.*?</update>\n?'
|
||||||
|
content = re.sub(pattern, '', content, flags=re.DOTALL)
|
||||||
|
|
||||||
|
# Insert new entry before </updates>
|
||||||
|
new_entry = '''${NEW_ENTRY}'''
|
||||||
|
content = content.replace('</updates>', new_entry + '\n</updates>')
|
||||||
|
|
||||||
|
# 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 '<tag>stable</tag>' update.xml; then
|
||||||
|
STABLE_ENTRY=$(sed -n '/<update>/,/<\/update>/{ /<tag>stable<\/tag>/,/<\/update>/p; /<update>/,/<tag>stable<\/tag>/p }' update.xml | sort -u)
|
||||||
|
fi
|
||||||
|
RC_ENTRY=""
|
||||||
|
if [ "$STABILITY" != "rc" ] && grep -q '<tag>rc</tag>' update.xml; then
|
||||||
|
RC_ENTRY=$(python3 -c "
|
||||||
|
import re
|
||||||
|
with open('update.xml') as f: c = f.read()
|
||||||
|
m = re.search(r'(<update>.*?<tag>rc</tag>.*?</update>)', c, re.DOTALL)
|
||||||
|
if m: print(m.group(1))
|
||||||
|
" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
DEV_ENTRY=""
|
||||||
|
if [ "$STABILITY" != "development" ] && grep -q '<tag>development</tag>' update.xml; then
|
||||||
|
DEV_ENTRY=$(python3 -c "
|
||||||
|
import re
|
||||||
|
with open('update.xml') as f: c = f.read()
|
||||||
|
m = re.search(r'(<update>.*?<tag>development</tag>.*?</update>)', c, re.DOTALL)
|
||||||
|
if m: print(m.group(1))
|
||||||
|
" 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
printf '%s\n' '<?xml version="1.0" encoding="utf-8"?>'
|
||||||
|
printf '%s\n' '<updates>'
|
||||||
|
[ -n "$STABLE_ENTRY" ] && echo "$STABLE_ENTRY"
|
||||||
|
[ -n "$RC_ENTRY" ] && echo "$RC_ENTRY"
|
||||||
|
[ -n "$DEV_ENTRY" ] && echo "$DEV_ENTRY"
|
||||||
|
echo "$NEW_ENTRY"
|
||||||
|
printf '%s\n' '</updates>'
|
||||||
|
} > 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] <github-actions[bot]@users.noreply.github.com>"
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user