diff --git a/.github/workflows/deploy-rs.yml b/.github/workflows/deploy-rs.yml new file mode 100644 index 0000000..eef55b8 --- /dev/null +++ b/.github/workflows/deploy-rs.yml @@ -0,0 +1,645 @@ +# Copyright (C) 2026 Moko Consulting +# +# This file is part of a Moko Consulting project. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# FILE INFORMATION +# DEFGROUP: GitHub.Workflow +# INGROUP: MokoStandards.Deploy +# REPO: https://github.com/mokoconsulting-tech/MokoStandards +# PATH: /templates/workflows/shared/deploy-rs.yml +# VERSION: 04.01.00 +# BRIEF: SFTP deployment workflow for release staging server — synced to all governed repos +# NOTE: Synced via bulk-repo-sync to .github/workflows/deploy-rs.yml in all governed repos. +# Port is resolved in order: RS_FTP_PORT variable → :port suffix in RS_FTP_HOST → 22. + +name: Deploy to RS Server (SFTP) + +# Deploys the contents of the src/ directory to the release staging server via SFTP. +# Triggers on push/merge to main — deploys the production-ready build to the release staging server. +# +# Required org-level variables: RS_FTP_HOST, RS_FTP_PATH, RS_FTP_USERNAME +# Optional org-level variable: RS_FTP_PORT (auto-detected from host or defaults to 22) +# Optional org/repo variable: RS_FTP_SUFFIX — when set, appended to RS_FTP_PATH to form the +# full remote destination: RS_FTP_PATH/RS_FTP_SUFFIX +# Ignore rules: Place a .ftp_ignore file in the repository root. Each non-empty, +# non-comment line is a regex pattern tested against the relative path +# of each file (e.g. "subdir/file.txt"). The .gitignore is also +# respected automatically. +# Required org-level secret: RS_FTP_KEY (preferred) or RS_FTP_PASSWORD +# +# Access control: only users with admin or maintain role on the repository may deploy. + +on: + push: + branches: + - main + - master + paths: + - 'src/**' + 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 → 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: | + 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 + git check-ignore -q "$rel" 2>/dev/null && { + IGNORED_FILES+=("$rel | .gitignore") + continue + } || true + fi + WILL_UPLOAD+=("$rel") + done < <(find "$SOURCE_DIR" -type f -print0 | sort -z) + + UPLOAD_COUNT="${#WILL_UPLOAD[@]}" + IGNORE_COUNT="${#IGNORED_FILES[@]}" + + echo "ℹ️ ${UPLOAD_COUNT} file(s) will be uploaded, ${IGNORE_COUNT} ignored" + + # ── Write deployment preview to step summary ────────────────────────── + { + echo "## 📋 Deployment Preview" + echo "" + echo "| Field | Value |" + echo "|---|---|" + echo "| Source | \`${SOURCE_DIR}/\` |" + echo "| Files to upload | **${UPLOAD_COUNT}** |" + echo "| Files ignored | **${IGNORE_COUNT}** |" + echo "" + if [ "${UPLOAD_COUNT}" -gt 0 ]; then + echo "### 📂 Files that will be uploaded" + echo '```' + printf '%s\n' "${WILL_UPLOAD[@]}" + echo '```' + echo "" + fi + if [ "${IGNORE_COUNT}" -gt 0 ]; then + echo "### ⏭️ Files excluded" + echo "| File | Reason |" + echo "|---|---|" + for entry in "${IGNORED_FILES[@]}"; do + f="${entry% | *}"; r="${entry##* | }" + echo "| \`${f}\` | ${r} |" + done + echo "" + fi + } >> "$GITHUB_STEP_SUMMARY" + + - name: Resolve SFTP host and port + if: steps.source.outputs.skip == 'false' + id: conn + env: + HOST_RAW: ${{ vars.RS_FTP_HOST }} + PORT_VAR: ${{ vars.RS_FTP_PORT }} + run: | + HOST="$HOST_RAW" + PORT="$PORT_VAR" + + # Priority 1 — explicit RS_FTP_PORT variable + if [ -n "$PORT" ]; then + echo "ℹ️ Using explicit RS_FTP_PORT=${PORT}" + + # Priority 2 — port embedded in RS_FTP_HOST (host:port) + elif [[ "$HOST" == *:* ]]; then + PORT="${HOST##*:}" + HOST="${HOST%:*}" + echo "ℹ️ Extracted port ${PORT} from RS_FTP_HOST" + + # Priority 3 — SFTP default + else + PORT="22" + echo "ℹ️ No port specified — defaulting to SFTP port 22" + fi + + echo "host=${HOST}" >> "$GITHUB_OUTPUT" + echo "port=${PORT}" >> "$GITHUB_OUTPUT" + echo "SFTP target: ${HOST}:${PORT}" + + - name: Build remote path + if: steps.source.outputs.skip == 'false' + id: remote + env: + RS_FTP_PATH: ${{ vars.RS_FTP_PATH }} + RS_FTP_SUFFIX: ${{ vars.RS_FTP_SUFFIX }} + run: | + BASE="$RS_FTP_PATH" + + if [ -z "$BASE" ]; then + echo "❌ RS_FTP_PATH is not set." + echo " Configure it as an org-level variable (Settings → Variables) and" + echo " ensure this repository has been granted access to it." + exit 1 + fi + + # RS_FTP_SUFFIX is required — it identifies the remote subdirectory for this repo. + # Without it we cannot safely determine the deployment target. + if [ -z "$RS_FTP_SUFFIX" ]; then + echo "⏭️ RS_FTP_SUFFIX variable is not set — skipping deployment." + echo " Set RS_FTP_SUFFIX as a repo or org variable to enable deploy-rs." + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "path=" >> "$GITHUB_OUTPUT" + exit 0 + fi + + REMOTE="${BASE%/}/${RS_FTP_SUFFIX#/}" + + # ── Platform-specific path safety guards ────────────────────────────── + PLATFORM="" + if [ -f ".moko-standards" ]; then + PLATFORM=$(grep -E '^platform:' .moko-standards | 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@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.RS_FTP_USERNAME }} + SFTP_KEY: ${{ secrets.RS_FTP_KEY }} + SFTP_PASSWORD: ${{ secrets.RS_FTP_PASSWORD }} + AUTH_METHOD: ${{ steps.auth.outputs.method }} + USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }} + HAS_PASSWORD: ${{ steps.auth.outputs.has_password }} + REMOTE_PATH: ${{ steps.remote.outputs.path }} + run: | + cat > /tmp/moko_clear.php << 'PHPEOF' + login($username, $key)) { + if ($password !== '') { + echo "⚠️ Key auth failed — falling back to password\n"; + if (!$sftp->login($username, $password)) { + fwrite(STDERR, "❌ Both key and password authentication failed\n"); + exit(1); + } + echo "✅ Connected via password authentication (key fallback)\n"; + } else { + fwrite(STDERR, "❌ Key authentication failed and no password fallback is available\n"); + exit(1); + } + } else { + echo "✅ Connected via SSH key authentication\n"; + } + } else { + if (!$sftp->login($username, (string) getenv('SFTP_PASSWORD'))) { + fwrite(STDERR, "❌ Password authentication failed\n"); + exit(1); + } + echo "✅ Connected via password authentication\n"; + } + + // ── Recursive delete ──────────────────────────────────────────── + function rmrf(SFTP $sftp, string $path): void + { + $entries = $sftp->nlist($path); + if ($entries === false) { + return; // path does not exist — nothing to clear + } + foreach ($entries as $name) { + if ($name === '.' || $name === '..') { + continue; + } + $entry = "{$path}/{$name}"; + if ($sftp->is_dir($entry)) { + rmrf($sftp, $entry); + $sftp->rmdir($entry); + echo " 🗑️ Removed dir: {$entry}\n"; + } else { + $sftp->delete($entry); + echo " 🗑️ Removed file: {$entry}\n"; + } + } + } + + // ── Create remote directory tree ──────────────────────────────── + function sftpMakedirs(SFTP $sftp, string $path): void + { + $parts = array_values(array_filter(explode('/', $path), fn(string $p) => $p !== '')); + $current = str_starts_with($path, '/') ? '' : ''; + foreach ($parts as $part) { + $current .= '/' . $part; + $sftp->mkdir($current); // silently returns false if already exists + } + } + + rmrf($sftp, $remotePath); + sftpMakedirs($sftp, $remotePath); + echo "✅ Remote folder ready: {$remotePath}\n"; + PHPEOF + php /tmp/moko_clear.php + + - name: Deploy via SFTP + if: steps.source.outputs.skip == 'false' && steps.remote.outputs.skip != 'true' + env: + SFTP_HOST: ${{ steps.conn.outputs.host }} + SFTP_PORT: ${{ steps.conn.outputs.port }} + SFTP_USER: ${{ vars.RS_FTP_USERNAME }} + SFTP_KEY: ${{ secrets.RS_FTP_KEY }} + SFTP_PASSWORD: ${{ secrets.RS_FTP_PASSWORD }} + AUTH_METHOD: ${{ steps.auth.outputs.method }} + USE_PASSPHRASE: ${{ steps.auth.outputs.use_passphrase }} + REMOTE_PATH: ${{ steps.remote.outputs.path }} + SOURCE_DIR: ${{ steps.source.outputs.dir }} + run: | + # ── Write SSH key to temp file (key auth only) ──────────────────────── + if [ "$AUTH_METHOD" = "key" ]; then + printf '%s' "$SFTP_KEY" > /tmp/deploy_key + chmod 600 /tmp/deploy_key + fi + + # ── Generate sftp-config.json safely via jq ─────────────────────────── + if [ "$AUTH_METHOD" = "key" ]; then + jq -n \ + --arg host "$SFTP_HOST" \ + --argjson port "${SFTP_PORT:-22}" \ + --arg user "$SFTP_USER" \ + --arg path "$REMOTE_PATH" \ + --arg key "/tmp/deploy_key" \ + '{host:$host, port:$port, user:$user, remote_path:$path, ssh_key_file:$key}' \ + > /tmp/sftp-config.json + else + jq -n \ + --arg host "$SFTP_HOST" \ + --argjson port "${SFTP_PORT:-22}" \ + --arg user "$SFTP_USER" \ + --arg path "$REMOTE_PATH" \ + --arg pass "$SFTP_PASSWORD" \ + '{host:$host, port:$port, user:$user, remote_path:$path, password:$pass}' \ + > /tmp/sftp-config.json + fi + + # ── Run deploy-sftp.php from MokoStandards ──────────────────────────── + DEPLOY_ARGS=(--path . --src-dir "$SOURCE_DIR" --config /tmp/sftp-config.json) + if [ "$USE_PASSPHRASE" = "true" ]; then + DEPLOY_ARGS+=(--key-passphrase "$SFTP_PASSWORD") + fi + + php /tmp/mokostandards/api/deploy/deploy-sftp.php "${DEPLOY_ARGS[@]}" + # (deploy-sftp.php handles dotfile skipping and .ftp_ignore natively) + # Remove temp files that should never be left behind + rm -f /tmp/deploy_key /tmp/sftp-config.json + + - name: Create or update failure issue + if: failure() + 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 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 "### ✅ 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