chore: add .github/workflows/deploy-demo.yml from MokoStandards
This commit is contained in:
665
.github/workflows/deploy-demo.yml
vendored
Normal file
665
.github/workflows/deploy-demo.yml
vendored
Normal file
@@ -0,0 +1,665 @@
|
||||
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
||||
#
|
||||
# This file is part of a Moko Consulting project.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: GitHub.Workflow
|
||||
# INGROUP: MokoStandards.Deploy
|
||||
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
|
||||
# PATH: /templates/workflows/shared/deploy-demo.yml
|
||||
# VERSION: 04.01.00
|
||||
# BRIEF: SFTP deployment workflow for demo server — synced to all governed repos
|
||||
# NOTE: Synced via bulk-repo-sync to .github/workflows/deploy-demo.yml in all governed repos.
|
||||
# Port is resolved in order: DEMO_FTP_PORT variable → :port suffix in DEMO_FTP_HOST → 22.
|
||||
|
||||
name: Deploy to Demo Server (SFTP)
|
||||
|
||||
# Deploys the contents of the src/ directory to the demo server via SFTP.
|
||||
# Triggers on push/merge to main — deploys the production-ready build to the demo server.
|
||||
#
|
||||
# Required org-level variables: DEMO_FTP_HOST, DEMO_FTP_PATH, DEMO_FTP_USERNAME
|
||||
# Optional org-level variable: DEMO_FTP_PORT (auto-detected from host or defaults to 22)
|
||||
# Optional org/repo variable: DEMO_FTP_SUFFIX — when set, appended to DEMO_FTP_PATH to form the
|
||||
# full remote destination: DEMO_FTP_PATH/DEMO_FTP_SUFFIX
|
||||
# Ignore rules: Place a .ftp_ignore file in the repository root. Each non-empty,
|
||||
# non-comment line is a regex pattern tested against the relative path
|
||||
# of each file (e.g. "subdir/file.txt"). The .gitignore is also
|
||||
# respected automatically.
|
||||
# Required org-level secret: DEMO_FTP_KEY (preferred) or DEMO_FTP_PASSWORD
|
||||
#
|
||||
# Access control: only users with admin or maintain role on the repository may deploy.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- 'src/**'
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, closed]
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
paths:
|
||||
- 'src/**'
|
||||
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
|
||||
|
||||
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: |
|
||||
SRC="src"
|
||||
if [ ! -d "$SRC" ]; then
|
||||
echo "⚠️ No src/ directory found — skipping deployment"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
COUNT=$(find "$SRC" -maxdepth 0 -type d > /dev/null && find "$SRC" -type f | wc -l)
|
||||
echo "✅ Source: src/ (${COUNT} file(s))"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "dir=${SRC}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Preview files to deploy
|
||||
if: steps.source.outputs.skip == 'false'
|
||||
env:
|
||||
SOURCE_DIR: ${{ steps.source.outputs.dir }}
|
||||
run: |
|
||||
# ── Convert a gitignore-style glob line to an ERE pattern ──────────────
|
||||
ftp_ignore_to_regex() {
|
||||
local line="$1"
|
||||
local anchored=false
|
||||
# Strip inline comments and whitespace
|
||||
line=$(printf '%s' "$line" | sed 's/[[:space:]]*#.*$//' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
[ -z "$line" ] && return
|
||||
# Skip negation patterns (not supported)
|
||||
[[ "$line" == !* ]] && return
|
||||
# Trailing slash = directory marker; strip it
|
||||
line="${line%/}"
|
||||
# Leading slash = anchored to root; strip it
|
||||
if [[ "$line" == /* ]]; then
|
||||
anchored=true
|
||||
line="${line#/}"
|
||||
fi
|
||||
# Escape ERE special chars, then restore glob semantics
|
||||
local regex
|
||||
regex=$(printf '%s' "$line" \
|
||||
| sed 's/[.+^${}()|[\\]/\\&/g' \
|
||||
| sed 's/\\\*\\\*/\x01/g' \
|
||||
| sed 's/\\\*/[^\/]*/g' \
|
||||
| sed 's/\x01/.*/g' \
|
||||
| sed 's/\\\?/[^\/]/g')
|
||||
if $anchored; then
|
||||
printf '^%s(/|$)' "$regex"
|
||||
else
|
||||
printf '(^|/)%s(/|$)' "$regex"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Read .ftp_ignore (gitignore-style globs) ─────────────────────────
|
||||
IGNORE_PATTERNS=()
|
||||
IGNORE_SOURCES=()
|
||||
if [ -f ".ftp_ignore" ]; then
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ ^[[:space:]]*$ || "$line" =~ ^[[:space:]]*# ]] && continue
|
||||
regex=$(ftp_ignore_to_regex "$line")
|
||||
[ -n "$regex" ] && IGNORE_PATTERNS+=("$regex") && IGNORE_SOURCES+=("$line")
|
||||
done < ".ftp_ignore"
|
||||
fi
|
||||
|
||||
# ── Walk src/ and classify every file ────────────────────────────────
|
||||
WILL_UPLOAD=()
|
||||
IGNORED_FILES=()
|
||||
while IFS= read -r -d '' file; do
|
||||
rel="${file#${SOURCE_DIR}/}"
|
||||
SKIP=false
|
||||
for i in "${!IGNORE_PATTERNS[@]}"; do
|
||||
if echo "$rel" | grep -qE "${IGNORE_PATTERNS[$i]}" 2>/dev/null; then
|
||||
IGNORED_FILES+=("$rel | .ftp_ignore \`${IGNORE_SOURCES[$i]}\`")
|
||||
SKIP=true; break
|
||||
fi
|
||||
done
|
||||
$SKIP && continue
|
||||
if [ -f ".gitignore" ]; then
|
||||
if [ -f ".gitignore" ]; then
|
||||
git check-ignore -q "$rel" 2>/dev/null && {
|
||||
IGNORED_FILES+=("$rel | .gitignore")
|
||||
continue
|
||||
} || true
|
||||
fi
|
||||
WILL_UPLOAD+=("$rel")
|
||||
done < <(find "$SOURCE_DIR" -type f -print0 | sort -z)
|
||||
|
||||
UPLOAD_COUNT="${#WILL_UPLOAD[@]}"
|
||||
IGNORE_COUNT="${#IGNORED_FILES[@]}"
|
||||
|
||||
echo "ℹ️ ${UPLOAD_COUNT} file(s) will be uploaded, ${IGNORE_COUNT} ignored"
|
||||
|
||||
# ── Write deployment preview to step summary ──────────────────────────
|
||||
{
|
||||
echo "## 📋 Deployment Preview"
|
||||
echo ""
|
||||
echo "| Field | Value |"
|
||||
echo "|---|---|"
|
||||
echo "| Source | \`${SOURCE_DIR}/\` |"
|
||||
echo "| Files to upload | **${UPLOAD_COUNT}** |"
|
||||
echo "| Files ignored | **${IGNORE_COUNT}** |"
|
||||
echo ""
|
||||
if [ "${UPLOAD_COUNT}" -gt 0 ]; then
|
||||
echo "### 📂 Files that will be uploaded"
|
||||
echo '```'
|
||||
printf '%s\n' "${WILL_UPLOAD[@]}"
|
||||
echo '```'
|
||||
echo ""
|
||||
fi
|
||||
if [ "${IGNORE_COUNT}" -gt 0 ]; then
|
||||
echo "### ⏭️ Files excluded"
|
||||
echo "| File | Reason |"
|
||||
echo "|---|---|"
|
||||
for entry in "${IGNORED_FILES[@]}"; do
|
||||
f="${entry% | *}"; r="${entry##* | }"
|
||||
echo "| \`${f}\` | ${r} |"
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Resolve SFTP host and port
|
||||
if: steps.source.outputs.skip == 'false'
|
||||
id: conn
|
||||
env:
|
||||
HOST_RAW: ${{ vars.DEMO_FTP_HOST }}
|
||||
PORT_VAR: ${{ vars.DEMO_FTP_PORT }}
|
||||
run: |
|
||||
HOST="$HOST_RAW"
|
||||
PORT="$PORT_VAR"
|
||||
|
||||
# Priority 1 — explicit DEMO_FTP_PORT variable
|
||||
if [ -n "$PORT" ]; then
|
||||
echo "ℹ️ Using explicit DEMO_FTP_PORT=${PORT}"
|
||||
|
||||
# Priority 2 — port embedded in DEMO_FTP_HOST (host:port)
|
||||
elif [[ "$HOST" == *:* ]]; then
|
||||
PORT="${HOST##*:}"
|
||||
HOST="${HOST%:*}"
|
||||
echo "ℹ️ Extracted port ${PORT} from DEMO_FTP_HOST"
|
||||
|
||||
# Priority 3 — SFTP default
|
||||
else
|
||||
PORT="22"
|
||||
echo "ℹ️ No port specified — defaulting to SFTP port 22"
|
||||
fi
|
||||
|
||||
echo "host=${HOST}" >> "$GITHUB_OUTPUT"
|
||||
echo "port=${PORT}" >> "$GITHUB_OUTPUT"
|
||||
echo "SFTP target: ${HOST}:${PORT}"
|
||||
|
||||
- name: Build remote path
|
||||
if: steps.source.outputs.skip == 'false'
|
||||
id: remote
|
||||
env:
|
||||
DEMO_FTP_PATH: ${{ vars.DEMO_FTP_PATH }}
|
||||
DEMO_FTP_SUFFIX: ${{ vars.DEMO_FTP_SUFFIX }}
|
||||
run: |
|
||||
BASE="$DEMO_FTP_PATH"
|
||||
|
||||
if [ -z "$BASE" ]; then
|
||||
echo "❌ DEMO_FTP_PATH is not set."
|
||||
echo " Configure it as an org-level variable (Settings → Variables) and"
|
||||
echo " ensure this repository has been granted access to it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# DEMO_FTP_SUFFIX is required — it identifies the remote subdirectory for this repo.
|
||||
# Without it we cannot safely determine the deployment target.
|
||||
if [ -z "$DEMO_FTP_SUFFIX" ]; then
|
||||
echo "⏭️ DEMO_FTP_SUFFIX variable is not set — skipping deployment."
|
||||
echo " Set DEMO_FTP_SUFFIX as a repo or org variable to enable deploy-demo."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
echo "path=" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REMOTE="${BASE%/}/${DEMO_FTP_SUFFIX#/}"
|
||||
|
||||
# ── Platform-specific path safety guards ──────────────────────────────
|
||||
PLATFORM=""
|
||||
if [ -f ".moko-standards" ]; then
|
||||
PLATFORM=$(grep -E '^platform:' .moko-standards | 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@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2.31.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 --quiet \
|
||||
"https://x-access-token:${GH_TOKEN}@github.com/mokoconsulting-tech/MokoStandards.git" \
|
||||
/tmp/mokostandards
|
||||
cd /tmp/mokostandards
|
||||
composer install --no-dev --no-interaction --quiet
|
||||
|
||||
- name: Clear remote destination folder
|
||||
if: steps.source.outputs.skip == 'false' && 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 }}
|
||||
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
|
||||
|
||||
# ── Run deploy-sftp.php from MokoStandards ────────────────────────────
|
||||
DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json)
|
||||
if [ "$USE_PASSPHRASE" = "true" ]; then
|
||||
DEPLOY_ARGS+=(--key-passphrase "$SFTP_PASSWORD")
|
||||
fi
|
||||
|
||||
php /tmp/mokostandards/api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}"
|
||||
# (deploy-sftp.php handles dotfile skipping and .ftp_ignore natively)
|
||||
# Remove temp files that should never be left behind
|
||||
rm -f /tmp/deploy_key /tmp/sftp-config.json
|
||||
|
||||
- name: Create or update failure issue
|
||||
if: failure()
|
||||
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=open&per_page=1" \
|
||||
--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" \
|
||||
--silent
|
||||
echo "📋 Failure issue #${EXISTING} updated: ${REPO}" >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
gh issue create \
|
||||
--repo "$REPO" \
|
||||
--title "$TITLE" \
|
||||
--body "$BODY" \
|
||||
--label "$LABEL" \
|
||||
| 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
|
||||
Reference in New Issue
Block a user