@@ -105,6 +105,7 @@ jobs:
105105 outputs :
106106 previous_sha : ${{ steps.backup.outputs.previous_sha }}
107107 deployment_needed : ${{ steps.backup.outputs.deployment_needed }}
108+ deleted_files : ${{ steps.changed-files.outputs.deleted_files }}
108109 deploy_status : ${{ steps.deploy.outcome }}
109110 health_status : ${{ steps.health.outcome }}
110111 cleanup_status : ${{ steps.cleanup.outcome }}
@@ -362,6 +363,44 @@ jobs:
362363
363364 echo "✅ SSH connection optimization configured"
364365
366+ - name : Checkout repository for change detection
367+ uses : actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
368+ with :
369+ fetch-depth : 0 # Fetch full history for accurate change detection
370+
371+ - name : Determine previous deployment SHA
372+ id : determine-previous
373+ run : |
374+ # Use retry mechanism for SSH connection
375+ source /tmp/retry.sh
376+
377+ # Get current deployment SHA with error handling
378+ echo "🔍 Checking current deployment SHA for change detection..."
379+ if CURRENT_SHA=$(ssh_retry 3 5 "ssh -o 'StrictHostKeyChecking no' ${{ secrets.SSH_USER }}@${{ secrets.SSH_HOST }} 'cd /opt/compose && git rev-parse HEAD 2>/dev/null'"); then
380+ # Validate SHA format
381+ if [[ "$CURRENT_SHA" =~ ^[a-fA-F0-9]{40}$ ]]; then
382+ echo "✅ Current deployed SHA: $CURRENT_SHA"
383+ echo "previous_sha=$CURRENT_SHA" >> "$GITHUB_OUTPUT"
384+ else
385+ echo "⚠️ Invalid SHA format from server: $CURRENT_SHA"
386+ echo "⚠️ Using HEAD^ as fallback for change detection"
387+ echo "previous_sha=HEAD^" >> "$GITHUB_OUTPUT"
388+ fi
389+ else
390+ echo "⚠️ Could not retrieve current deployment SHA - using HEAD^ for change detection"
391+ echo "previous_sha=HEAD^" >> "$GITHUB_OUTPUT"
392+ fi
393+
394+ - name : Get changed files for removal detection
395+ id : changed-files
396+ # Pin to commit SHA instead of tag for security (CVE-2025-30066)
397+ # This is v47.0.0 released on 2024-09-13
398+ uses : tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62
399+ with :
400+ json : true
401+ sha : ${{ inputs.target-ref }}
402+ base_sha : ${{ steps.determine-previous.outputs.previous_sha }}
403+
365404 - name : Store current deployment for rollback
366405 id : backup
367406 run : |
@@ -414,18 +453,29 @@ jobs:
414453 fi
415454 echo "::endgroup::"
416455
456+ # ================================================================
417457 # STACK REMOVAL DETECTION AND CLEANUP
418- # This step runs before repository update to clean up Docker containers
419- # for stacks that have been completely removed from the repository.
458+ # ================================================================
459+ # Automatically detect and clean up Docker stacks that have been
460+ # removed from the repository using three independent detection methods.
461+ #
462+ # Detection Methods:
463+ # 1. Git Diff: Compares current deployed SHA vs target SHA
464+ # 2. Tree Comparison: Compares target commit tree vs server filesystem
465+ # (catches removals from previous undeployed commits)
466+ # 3. Discovery Analysis: Analyzes tj-actions/changed-files output
467+ # (validates removals from GitHub perspective)
420468 #
421469 # Process:
422- # 1. Compare current deployed SHA with target SHA using git diff
423- # 2. Identify deleted */compose.yaml files (one level deep only)
424- # 3. Run 'docker compose down' for each removed stack
425- # 4. Fail deployment if any cleanup fails (fail-safe)
426- # 5. Send Discord notification listing removed stacks
470+ # 1. Run all three detection methods independently on deployment server
471+ # 2. Fail deployment if ANY detection method encounters errors (fail-safe)
472+ # 3. Aggregate results using union approach (remove anything found by any method)
473+ # 4. Deduplicate and validate stack names
474+ # 5. Run 'docker compose down' for each removed stack
475+ # 6. Fail deployment if any cleanup fails
476+ # 7. Send Discord notification listing removed stacks
427477 #
428- # Design: docs/plans/2025-12-03 -stack-removal-detection-design.md
478+ # Design: docs/plans/2025-12-06-enhanced -stack-removal-detection-design.md
429479
430480 - name : Detect and clean up removed stacks
431481 id : cleanup-removed
@@ -435,12 +485,12 @@ jobs:
435485 # Source retry functions
436486 source /tmp/retry.sh
437487
438- # === DETECTION FUNCTION ===
439- # Purpose: Detect removed stacks by comparing git commits
488+ # === DETECTION FUNCTION: GIT DIFF ===
489+ # Purpose: Detect stacks removed between two git commits
440490 # Inputs: $1=current_sha, $2=target_ref
441491 # Output: Newline-separated list of stack names (stdout)
442492 # Returns: 0 on success, 1 on error
443- detect_removed_stacks () {
493+ detect_removed_stacks_gitdiff () {
444494 local current_sha="$1"
445495 local target_ref="$2"
446496
@@ -487,6 +537,109 @@ jobs:
487537 echo "$detect_script" | ssh_retry 3 5 "ssh -o \"StrictHostKeyChecking no\" deployment-server /bin/bash -s \"$current_sha\" \"$target_ref\""
488538 }
489539
540+ # === DETECTION FUNCTION: TREE COMPARISON ===
541+ # Purpose: Detect stacks on server filesystem missing from target commit tree
542+ # Inputs: $1=target_ref
543+ # Output: Newline-separated list of stack names (stdout)
544+ # Returns: 0 on success, 1 on error
545+ detect_removed_stacks_tree() {
546+ local target_ref="$1"
547+
548+ # Build detection script
549+ local detect_script
550+ detect_script=$(cat << 'DETECT_TREE_EOF'
551+ set -e
552+ TARGET_REF="$1"
553+
554+ cd /opt/compose
555+
556+ # Fetch target ref to ensure we have it
557+ if ! git fetch origin "$TARGET_REF" 2>/dev/null; then
558+ echo "⚠️ Failed to fetch target ref, trying general fetch..." >&2
559+ if ! git fetch 2>/dev/null; then
560+ echo "::error::Failed to fetch repository updates" >&2
561+ exit 1
562+ fi
563+ fi
564+
565+ # Resolve target ref to SHA
566+ TARGET_SHA=$(git rev-parse "$TARGET_REF" 2>/dev/null || echo "$TARGET_REF")
567+
568+ # Validate target SHA exists
569+ if ! git cat-file -e "$TARGET_SHA" 2>/dev/null; then
570+ echo "::error::Target SHA $TARGET_SHA not found in repository" >&2
571+ exit 1
572+ fi
573+
574+ # Get directories in target commit (one level deep, directories only)
575+ COMMIT_DIRS=$(git ls-tree --name-only "$TARGET_SHA" 2>/dev/null | sort)
576+
577+ # Get directories on server filesystem (exclude .git and hidden dirs)
578+ SERVER_DIRS=$(find /opt/compose -maxdepth 1 -mindepth 1 -type d ! -name '.*' -exec basename {} \; 2>/dev/null | sort)
579+
580+ # Find directories on server but not in commit
581+ MISSING_IN_COMMIT=$(comm -13 <(echo "$COMMIT_DIRS") <(echo "$SERVER_DIRS"))
582+
583+ # Filter for directories with compose.yaml files
584+ for dir in $MISSING_IN_COMMIT; do
585+ if [ -f "/opt/compose/$dir/compose.yaml" ]; then
586+ echo "$dir"
587+ fi
588+ done
589+ DETECT_TREE_EOF
590+ )
591+
592+ # Execute detection script on remote server
593+ echo "$detect_script" | ssh_retry 3 5 "ssh -o \"StrictHostKeyChecking no\" deployment-server /bin/bash -s \"$target_ref\""
594+ }
595+
596+ # === DETECTION FUNCTION: DISCOVERY ANALYSIS ===
597+ # Purpose: Analyze deleted files from tj-actions/changed-files output
598+ # Inputs: $1=deleted_files_json (JSON array from tj-actions/changed-files)
599+ # Output: Newline-separated list of stack names (stdout)
600+ # Returns: 0 on success, 1 on error
601+ detect_removed_stacks_discovery() {
602+ local deleted_files_json="$1"
603+
604+ # Build detection script
605+ local detect_script
606+ detect_script=$(cat << 'DETECT_DISCOVERY_EOF'
607+ set -e
608+ DELETED_FILES_JSON="$1"
609+
610+ # Parse JSON array and filter for compose.yaml deletions
611+ # Pattern: one level deep only (stack-name/compose.yaml)
612+ echo "$DELETED_FILES_JSON" | jq -r '.[]' 2>/dev/null | \
613+ grep -E '^[^/]+/compose\.yaml$' | \
614+ sed 's|/compose\.yaml||' || echo ""
615+ DETECT_DISCOVERY_EOF
616+ )
617+
618+ # Execute detection script on remote server
619+ echo "$detect_script" | ssh_retry 3 5 "ssh -o \"StrictHostKeyChecking no\" deployment-server /bin/bash -s \"$deleted_files_json\""
620+ }
621+
622+ # === AGGREGATION FUNCTION ===
623+ # Purpose: Merge and deduplicate results from all three detection methods
624+ # Inputs: $1=gitdiff_stacks, $2=tree_stacks, $3=discovery_stacks (newline-separated lists)
625+ # Output: Deduplicated newline-separated list of stack names (stdout)
626+ # Returns: 0 on success (empty string if all inputs empty, not an error)
627+ aggregate_removed_stacks() {
628+ local gitdiff_stacks="$1"
629+ local tree_stacks="$2"
630+ local discovery_stacks="$3"
631+
632+ # Concatenate all three lists, remove empty lines, sort and deduplicate
633+ {
634+ echo "$gitdiff_stacks"
635+ echo "$tree_stacks"
636+ echo "$discovery_stacks"
637+ } | \
638+ grep -v '^$' | \
639+ sort -u | \
640+ grep -E '^[a-zA-Z0-9_-]+$' || echo ""
641+ }
642+
490643 # === CLEANUP FUNCTION ===
491644 # Purpose: Clean up a single removed stack using docker compose down
492645 # Inputs: $1=stack_name
@@ -555,14 +708,51 @@ jobs:
555708 echo " Target: $TARGET_REF"
556709 echo "🔍 Checking for removed stacks..."
557710
558- # Execute detection
559- REMOVED_STACKS=$(detect_removed_stacks "$CURRENT_SHA" "$TARGET_REF")
560- DETECTION_EXIT=$?
711+ # Read deleted files from discover-stacks step
712+ DELETED_FILES='${{ steps.changed-files.outputs.deleted_files }}'
713+
714+ echo "🔍 Running three detection methods..."
715+
716+ # Execute all three detection methods independently
717+ echo " 1. Git diff detection (commit comparison)..."
718+ GITDIFF_STACKS=$(detect_removed_stacks_gitdiff "$CURRENT_SHA" "$TARGET_REF") || GITDIFF_EXIT=$?
719+
720+ echo " 2. Tree comparison detection (filesystem vs commit)..."
721+ TREE_STACKS=$(detect_removed_stacks_tree "$TARGET_REF") || TREE_EXIT=$?
722+
723+ echo " 3. Discovery analysis detection (changed files)..."
724+ DISCOVERY_STACKS=$(detect_removed_stacks_discovery "$DELETED_FILES") || DISCOVERY_EXIT=$?
561725
562- if [ $DETECTION_EXIT -ne 0 ]; then
563- echo "::error::Failed to detect removed stacks (exit code: $DETECTION_EXIT)"
726+ # Fail deployment if any detection method failed (fail-safe)
727+ if [ "${GITDIFF_EXIT:-0}" -ne 0 ]; then
728+ echo "::error::Git diff detection failed (exit code: $GITDIFF_EXIT)"
564729 exit 1
565730 fi
731+ if [ "${TREE_EXIT:-0}" -ne 0 ]; then
732+ echo "::error::Tree comparison detection failed (exit code: $TREE_EXIT)"
733+ exit 1
734+ fi
735+ if [ "${DISCOVERY_EXIT:-0}" -ne 0 ]; then
736+ echo "::error::Discovery analysis detection failed (exit code: $DISCOVERY_EXIT)"
737+ exit 1
738+ fi
739+
740+ echo "✅ All detection methods completed successfully"
741+
742+ # Aggregate results (union of all three methods)
743+ echo "📊 Aggregating results..."
744+ REMOVED_STACKS=$(aggregate_removed_stacks "$GITDIFF_STACKS" "$TREE_STACKS" "$DISCOVERY_STACKS")
745+
746+ # Debug logging
747+ if [ -n "$GITDIFF_STACKS" ]; then
748+ echo " Git diff found: $(echo "$GITDIFF_STACKS" | tr '\n' ', ' | sed 's/,$//')"
749+ fi
750+ if [ -n "$TREE_STACKS" ]; then
751+ echo " Tree comparison found: $(echo "$TREE_STACKS" | tr '\n' ', ' | sed 's/,$//')"
752+ fi
753+ if [ -n "$DISCOVERY_STACKS" ]; then
754+ echo " Discovery analysis found: $(echo "$DISCOVERY_STACKS" | tr '\n' ', ' | sed 's/,$//')"
755+ fi
566756
567757 # Process results
568758 if [ -z "$REMOVED_STACKS" ]; then
0 commit comments