diff --git a/.github/scripts/generate-pr-plugin.sh b/.github/scripts/generate-pr-plugin.sh index 68450bd08e..b79cc77886 100755 --- a/.github/scripts/generate-pr-plugin.sh +++ b/.github/scripts/generate-pr-plugin.sh @@ -4,6 +4,15 @@ IFS=$'\n\t' # Generate PR plugin file for Unraid # Usage: ./generate-pr-plugin.sh [plugin_url] +# +# The tarball payload contains: +# pr.patch unified diff of all changed TEXT files (system-relative paths, apply with patch -p1 at /) +# binary/ whole copies of changed BINARY files (cannot be diffed) +# binary_files.txt list of binary paths (system-relative) +# +# Install applies pr.patch with `patch`, so several PR plugins can stack on the +# same file as long as their hunks do not overlap. Binaries fall back to a +# whole-file replace, guarded so two plugins never fight over the same binary. VERSION=$1 PR_NUMBER=$2 @@ -45,24 +54,25 @@ cat > "$PLUGIN_NAME" << 'EOF' ]> - ##&version; - Test build for PR #≺ (commit &commit;) -- This plugin installs modified files from the PR for testing -- Original files are backed up and restored upon removal +- Applies the PR's changes as a patch, so multiple PR test plugins can be + installed together as long as they do not edit the same lines +- Changes are reversed when the plugin is removed - + - + /dev/null 2>&1; then + echo "⚠️ Warning: could not re-apply $(basename "$other")'s changes; reinstall it or reboot to restore them" + logger -t webgui-pr "re-apply of $(basename "$other") patch failed during $(basename "$PLUGIN_DIR") rollback" + fi + done +fi +rm -f "$PATCH_APPLIED" +rm -rf "$PLUGIN_DIR/orig" +: > "$PLUGIN_DIR/text_files.txt" -# First restore original files to ensure clean state +# Restore any previously installed binary files if [ -f "$MANIFEST" ]; then - echo "Step 1: Restoring original files before update..." - echo "------------------------------------------------" - + echo "Restoring binary files from previous version..." while IFS='|' read -r system_file backup_file; do + [ -n "$system_file" ] || continue if [ "$backup_file" == "NEW" ]; then - # This was a new file from previous version, remove it - if [ -e "$system_file" ] || [ -L "$system_file" ]; then - echo "Removing PR file: $system_file" - rm -f "$system_file" - fi - else - # Restore original from backup - if [ -f "$backup_file" ]; then - echo "Restoring original: $system_file" - cp -fp "$backup_file" "$system_file" - else - echo "⚠️ Missing backup for: $system_file" - fi + [ -e "$system_file" ] && { echo "Removing PR file: $system_file"; rm -f "$system_file"; } + elif [ -f "$backup_file" ]; then + echo "Restoring original: $system_file" + cp -fp "$backup_file" "$system_file" fi done < "$MANIFEST" - - echo "" - echo "✅ Original files restored" - echo "" -else - echo "⚠️ No previous manifest found, proceeding with fresh install" - echo "" fi -# Clear the old manifest for the new version (dir now guaranteed) -> "$MANIFEST" - -echo "Step 2: Update will now proceed with installation of new PR files..." -echo "" - -# The update continues by running the install method which will extract new files +: > "$MANIFEST" +rm -rf "$PAYLOAD" +echo "Ready for fresh install of PR files." ]]> - + + - + TXZ_URL_PLACEHOLDER &sha256; - + "$MANIFEST" - -# Get file list first -echo "Examining tarball contents..." -tar -tzf "$TARBALL" | head -20 -echo "" - -# Count total files -FILE_COUNT=$(tar -tzf "$TARBALL" | grep -v '/$' | wc -l) -echo "Total files to process: $FILE_COUNT" -echo "" - -# Get file list -tar -tzf "$TARBALL" > /tmp/plugin_files.txt - -# Abort if any files are already managed by another PR plugin -OTHER_MANIFESTS="/tmp/pr_plugin_existing_files.txt" -> "$OTHER_MANIFESTS" -for plugin_dir in /boot/config/plugins/webgui-pr-*; do - if [ ! -d "$plugin_dir" ]; then - continue - fi - if [ "$plugin_dir" == "/boot/config/plugins/webgui-pr-PR_PLACEHOLDER" ]; then - continue - fi - manifest="$plugin_dir/installed_files.txt" - if [ -f "$manifest" ]; then - plugin_name=$(basename "$plugin_dir") - while IFS='|' read -r other_file _; do - if [ -n "$other_file" ]; then - echo "${other_file}|${plugin_name}" >> "$OTHER_MANIFESTS" - fi - done < "$manifest" - fi -done - -if [ -s "$OTHER_MANIFESTS" ]; then - declare -A existing_files - while IFS='|' read -r existing_file existing_plugin; do - if [ -z "$existing_file" ] || [ -z "$existing_plugin" ]; then - continue - fi - if [ -n "${existing_files[$existing_file]:-}" ]; then - if [[ ",${existing_files[$existing_file]}," != *",${existing_plugin},"* ]]; then - existing_files[$existing_file]="${existing_files[$existing_file]},${existing_plugin}" - fi - else - existing_files[$existing_file]="$existing_plugin" - fi - done < "$OTHER_MANIFESTS" - - conflict_found=0 - declare -A conflict_plugins - while IFS= read -r file; do - # Skip directories - if [[ "$file" == */ ]]; then - continue - fi - - system_file="/${file}" - if [ -n "${existing_files[$system_file]:-}" ]; then - conflict_found=1 - echo "Conflict: $system_file is already managed by ${existing_files[$system_file]}" - IFS=',' read -r -a plugin_list <<< "${existing_files[$system_file]}" - for plugin in "${plugin_list[@]}"; do - conflict_plugins[$plugin]=1 - done - fi - done < /tmp/plugin_files.txt - - if [ "$conflict_found" -eq 1 ]; then +PLUGIN_DIR="/boot/config/plugins/webgui-pr-PR_PLACEHOLDER" +BACKUP_DIR="$PLUGIN_DIR/backups" +MANIFEST="$PLUGIN_DIR/installed_files.txt" +PATCH_APPLIED="$PLUGIN_DIR/applied.patch" +TARBALL="$PLUGIN_DIR/REMOTE_TARBALL_PLACEHOLDER" +PAYLOAD="$PLUGIN_DIR/payload" + +echo "Extracting payload..." +rm -rf "$PAYLOAD" +mkdir -p "$PAYLOAD" +tar -xzf "$TARBALL" -C "$PAYLOAD" +: > "$MANIFEST" + +# ---- Text changes: apply unified diff ------------------------------------- +if [ -s "$PAYLOAD/pr.patch" ]; then + echo "Checking patch applies cleanly..." + if ! patch -p1 -d / --dry-run --forward --batch < "$PAYLOAD/pr.patch" > /tmp/pr_patch_check.txt 2>&1; then echo "" - echo "❌ Install aborted." - echo "One or more files are already managed by another PR plugin." - echo "Please uninstall the conflicting plugin(s) and try again:" - for plugin in "${!conflict_plugins[@]}"; do - echo " plugin remove ${plugin}.plg" - done - rm -f "$OTHER_MANIFESTS" /tmp/plugin_files.txt + echo "❌ Install aborted: this PR's changes do not apply cleanly." + echo " Another installed PR plugin likely edits the same lines." + echo "------------------------------------------------------------" + grep -Ei 'FAILED|can.t find file|hunk' /tmp/pr_patch_check.txt || cat /tmp/pr_patch_check.txt + echo "------------------------------------------------------------" + echo "Remove the conflicting webgui-pr-* plugin and try again." + rm -f /tmp/pr_patch_check.txt exit 1 fi + rm -f /tmp/pr_patch_check.txt + echo "Applying patch..." + patch -p1 -d / --forward --batch < "$PAYLOAD/pr.patch" + cp -f "$PAYLOAD/pr.patch" "$PATCH_APPLIED" + # Persist the originals + file list so removal can rebuild deterministically + rm -rf "$PLUGIN_DIR/orig" + [ -d "$PAYLOAD/orig" ] && cp -a "$PAYLOAD/orig" "$PLUGIN_DIR/orig" + [ -f "$PAYLOAD/text_files.txt" ] && cp -f "$PAYLOAD/text_files.txt" "$PLUGIN_DIR/text_files.txt" + echo "✅ Patch applied" fi -rm -f "$OTHER_MANIFESTS" - -# Backup original files BEFORE extraction -while IFS= read -r file; do - # Skip directories - if [[ "$file" == */ ]]; then - continue - fi - - # The tarball contains usr/local/emhttp/... (no leading slash) - # When we extract with -C /, it becomes /usr/local/emhttp/... - SYSTEM_FILE="/${file}" - BACKUP_FILE="$BACKUP_DIR/$(echo "$file" | tr '/' '_')" - - echo "Processing: $file" - - # Only backup if we haven't already backed up this file - # (preserves original backups across updates) - if [ -f "$BACKUP_FILE" ]; then - echo " → Using existing backup: $BACKUP_FILE" - echo "$SYSTEM_FILE|$BACKUP_FILE" >> "$MANIFEST" - elif [ -f "$SYSTEM_FILE" ]; then - echo " → Creating backup of original: $SYSTEM_FILE" - cp -p "$SYSTEM_FILE" "$BACKUP_FILE" - echo "$SYSTEM_FILE|$BACKUP_FILE" >> "$MANIFEST" - else - echo " → Will create new: $SYSTEM_FILE" - echo "$SYSTEM_FILE|NEW" >> "$MANIFEST" - fi -done < /tmp/plugin_files.txt - -# Clean up temp file -rm -f /tmp/plugin_files.txt - -# Extract the tarball to root with verbose output -# Since tarball contains usr/local/emhttp/..., extracting to / makes it /usr/local/emhttp/... -echo "" -echo "Extracting files to system (verbose mode)..." -echo "----------------------------------------" -tar -xzvf "$TARBALL" -C / -EXTRACT_STATUS=$? -echo "----------------------------------------" -echo "Extraction completed with status: $EXTRACT_STATUS" -echo "" - -# Verify extraction -echo "Verifying installation..." -INSTALLED_COUNT=0 -while IFS='|' read -r file backup; do - if [ -f "$file" ]; then - INSTALLED_COUNT=$((INSTALLED_COUNT + 1)) - fi -done < "$MANIFEST" - -echo "Successfully installed $INSTALLED_COUNT files" - -echo "" -echo "✅ Installation complete!" -echo "" -echo "Summary:" -echo "--------" -echo "Files deployed: $INSTALLED_COUNT" -echo "" -if [ $INSTALLED_COUNT -gt 0 ]; then - echo "Modified files:" - while IFS='|' read -r file backup; do - if [ -f "$file" ]; then - if [ "$backup" == "NEW" ]; then - echo " [NEW] $file" - else - echo " [MOD] $file" +# ---- Binary changes: whole-file replace (cannot be merged) ----------------- +if [ -s "$PAYLOAD/binary_files.txt" ]; then + # Guard: a binary may only be managed by one PR plugin at a time + while IFS= read -r sys; do + [ -n "$sys" ] || continue + for other in /boot/config/plugins/webgui-pr-*; do + [ -d "$other" ] || continue + [ "$other" == "$PLUGIN_DIR" ] && continue + if [ -f "$other/installed_files.txt" ] && grep -q "^/$sys|" "$other/installed_files.txt"; then + echo "❌ Install aborted: binary /$sys is already managed by $(basename "$other")." + echo " Remove that plugin first: plugin remove $(basename "$other").plg" + exit 1 fi - fi - done < "$MANIFEST" -else - echo "⚠️ WARNING: No files were installed!" - echo "Check that the tarball structure matches the expected format." + done + done < "$PAYLOAD/binary_files.txt" + + while IFS= read -r sys; do + [ -n "$sys" ] || continue + SYS="/$sys" + SRC="$PAYLOAD/binary/$sys" + BK="$BACKUP_DIR/$(echo "$sys" | tr '/' '_')" + mkdir -p "$(dirname "$SYS")" + if [ -f "$SYS" ] && [ ! -f "$BK" ]; then cp -p "$SYS" "$BK"; fi + if [ -f "$BK" ]; then echo "$SYS|$BK" >> "$MANIFEST"; else echo "$SYS|NEW" >> "$MANIFEST"; fi + cp -f "$SRC" "$SYS" + echo "Installed binary: $SYS" + done < "$PAYLOAD/binary_files.txt" fi echo "" -echo "⚠️ This is a TEST plugin for PR #PR_PLACEHOLDER" -echo "⚠️ Remove this plugin before applying production updates" -echo "" -echo "⚠️ NOTE: Under certain circumstances, it might be necessary to reboot the server for the code changes to take effect" +echo "✅ Installation complete for PR #PR_PLACEHOLDER" +echo "⚠️ This is a TEST plugin — remove it before applying production updates" +echo "⚠️ A reboot may be required for some changes to take effect" ]]> @@ -360,7 +273,7 @@ Link='nav-user' window.uninstallPRPlugin = function() { swal({ title: "Uninstall PR Test Plugin?", - text: "This will restore all original files and remove the test plugin.", + text: "This will reverse all of this PR's changes and remove the test plugin.", type: "warning", showCancelButton: true, confirmButtonText: "Yes, uninstall", @@ -369,8 +282,6 @@ Link='nav-user' showLoaderOnConfirm: true }, function(isConfirm) { if (isConfirm) { - // Execute plugin removal using openPlugin (Unraid's standard method) - // The "refresh" parameter will automatically reload the page when uninstall is completed openPlugin("plugin remove webgui-pr-PR_PLACEHOLDER.plg", "Removing PR Test Plugin", "", "refresh"); } }); @@ -390,48 +301,60 @@ echo "WebGUI PR Test Plugin Removal" echo "====================================" echo "" -BACKUP_DIR="/boot/config/plugins/webgui-pr-PR_PLACEHOLDER/backups" -MANIFEST="/boot/config/plugins/webgui-pr-PR_PLACEHOLDER/installed_files.txt" +PLUGIN_DIR="/boot/config/plugins/webgui-pr-PR_PLACEHOLDER" +MANIFEST="$PLUGIN_DIR/installed_files.txt" +PATCH_APPLIED="$PLUGIN_DIR/applied.patch" + +# Restore this plugin's text files to their shipped originals, then re-apply the +# other still-installed PR plugins so their (non-overlapping) changes survive. +if [ -f "$PLUGIN_DIR/text_files.txt" ]; then + echo "Restoring text files..." + while IFS= read -r sys; do + [ -n "$sys" ] || continue + SYS="/$sys" + if [ -f "$PLUGIN_DIR/orig/$sys" ]; then + mkdir -p "$(dirname "$SYS")"; cp -f "$PLUGIN_DIR/orig/$sys" "$SYS" + else + rm -f "$SYS" # file was newly added by this PR + fi + done < "$PLUGIN_DIR/text_files.txt" + for other in /boot/config/plugins/webgui-pr-*; do + [ -d "$other" ] || continue + [ "$other" == "$PLUGIN_DIR" ] && continue + [ -f "$other/applied.patch" ] || continue + # Non-blocking: all patches passed dry-run at install time, so failure here + # is a rare edge case worth surfacing rather than swallowing. + if ! patch -p1 -d / --forward --batch < "$other/applied.patch" >/dev/null 2>&1; then + echo "⚠️ Warning: could not re-apply $(basename "$other")'s changes; reinstall it or reboot to restore them" + logger -t webgui-pr "re-apply of $(basename "$other") patch failed during $(basename "$PLUGIN_DIR") rollback" + fi + done + echo "✅ Text changes restored" +fi +# Restore binary files if [ -f "$MANIFEST" ]; then - echo "Restoring original files..." - + echo "Restoring binary files..." while IFS='|' read -r system_file backup_file; do + [ -n "$system_file" ] || continue if [ "$backup_file" == "NEW" ]; then - # This was a new file, remove it - if [ -e "$system_file" ] || [ -L "$system_file" ]; then - echo "Removing new file: $system_file" - rm -f "$system_file" - fi + [ -e "$system_file" ] && { echo "Removing new file: $system_file"; rm -f "$system_file"; } + elif [ -f "$backup_file" ]; then + echo "Restoring: $system_file" + cp -fp "$backup_file" "$system_file" else - # Restore from backup - if [ -f "$backup_file" ]; then - echo "Restoring: $system_file" - cp -fp "$backup_file" "$system_file" - else - echo "⚠️ Missing backup for: $system_file" - fi + echo "⚠️ Missing backup for: $system_file" fi done < "$MANIFEST" - - echo "" - echo "✅ Original files restored" -else - echo "⚠️ No manifest found, cannot restore files" fi -# Clean up echo "Cleaning up plugin files..." -# Remove the banner rm -rf "/usr/local/emhttp/plugins/webgui-pr-PR_PLACEHOLDER" -# Remove the plugin directory (which includes the tarball and backups) -rm -rf "/boot/config/plugins/webgui-pr-PR_PLACEHOLDER" -# Note: The .plg file is handled automatically by the plugin system and should not be removed here +rm -rf "$PLUGIN_DIR" echo "" echo "✅ Plugin removed successfully" -echo "" -echo "⚠️ A reboot of the server might be required under certain circumstances to completely remove all traces of this plugin" +echo "⚠️ A reboot may be required to fully clear all changes" ]]> diff --git a/.github/workflows/pr-plugin-build.yml b/.github/workflows/pr-plugin-build.yml index 14cb44a06c..64412b91e5 100644 --- a/.github/workflows/pr-plugin-build.yml +++ b/.github/workflows/pr-plugin-build.yml @@ -81,58 +81,86 @@ jobs: - name: Create plugin package if: steps.changed-files.outputs.has_changes == 'true' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | - # Create build directory - mkdir -p build/usr/local - - # Copy changed files preserving directory structure + set -euo pipefail + # Payload layout: + # build/pr.patch unified diff of all changed TEXT files (system-relative paths) + # build/binary/ whole copies of changed BINARY files (cannot be diffed) + # build/binary_files.txt list of binary paths (system-relative) + # The plugin applies pr.patch with `patch` so multiple PR plugins can + # stack on the same file; binaries fall back to whole-file replace. + mkdir -p build old new + + # Map a repo path to its system-relative install path (no leading slash), + # matching the historical install layout. + map_path() { + case "$1" in + etc/rc.d/*) echo "usr/local/etc/rc.d/${1#etc/rc.d/}" ;; + etc/*) echo "$1" ;; + sbin/*) echo "usr/local/sbin/${1#sbin/}" ;; + share/*) echo "usr/local/share/${1#share/}" ;; + emhttp/*) echo "usr/local/$1" ;; + *) echo "" ;; + esac + } + + : > build/binary_files.txt + : > build/text_files.txt + has_text=0 + while IFS= read -r file; do + [ -n "$file" ] || continue + sys=$(map_path "$file") + if [ -z "$sys" ]; then echo "Skipping unsupported path: $file"; continue; fi + + # numstat reports "-\t-" for binary files (one line per single file) + if git diff --numstat "$BASE_SHA" HEAD -- "$file" | grep -qP '^-\t-\t'; then + if [ -f "$file" ]; then + mkdir -p "build/binary/$(dirname "$sys")" + cp "$file" "build/binary/$sys" + echo "$sys" >> build/binary_files.txt + echo "Binary: $file -> $sys" + else + echo "Skipping deleted binary (unsupported): $file" + fi + continue + fi + + # Text: stage base version under old/, head version under new/ + if git cat-file -e "$BASE_SHA:$file" 2>/dev/null; then + mkdir -p "old/$(dirname "$sys")" + git show "$BASE_SHA:$file" > "old/$sys" + fi if [ -f "$file" ]; then - # Create directory structure in build with target mappings - case "$file" in - etc/rc.d/*) - target_dir="build/usr/local/etc/rc.d" - ;; - etc/*) - target_dir="build/${file%/*}" - ;; - sbin/*) - target_dir="build/usr/local/sbin/${file#sbin/}" - target_dir="${target_dir%/*}" - ;; - share/*) - target_dir="build/usr/local/share/${file#share/}" - target_dir="${target_dir%/*}" - ;; - emhttp/*) - target_dir="build/usr/local/${file%/*}" - ;; - *) - echo "Skipping unsupported path: $file" - continue - ;; - esac - mkdir -p "$target_dir" - cp "$file" "$target_dir/" - echo "Added: $file" + mkdir -p "new/$(dirname "$sys")" + cp "$file" "new/$sys" fi + has_text=1 + echo "$sys" >> build/text_files.txt + echo "Text: $file -> $sys" done < changed_files.txt - - # Generate file list for plugin/tar (relative paths) + + # Unified diff with old/ and new/ headers (apply with patch -p1 at /). + # diff exits 1 when differences exist; only 2 is a real error. + if [ "$has_text" -eq 1 ]; then + diff -ruN old new > build/pr.patch || [ $? -eq 1 ] + echo "pr.patch hunks: $(grep -c '^@@' build/pr.patch || echo 0)" + # Ship the base (original) version of each text file so removal can + # rebuild deterministically: restore originals + re-apply the other + # still-installed PR plugins' patches. + mkdir -p build/orig + cp -a old/. build/orig/ 2>/dev/null || true + fi + find build -type f | sed 's|^build/||' > file_list.txt - echo "File list for plugin:" - cat file_list.txt - - # Create tarball - consistent filename for updates - cd build - echo "Creating tarball with contents:" - cat ../file_list.txt - tar -czf ../${{ steps.version.outputs.local_txz }} -T ../file_list.txt - cd .. - - # Verify tarball contents + echo "Payload contents:"; cat file_list.txt + + # Tarball the payload (extracted to a temp dir by the plugin, not to /) + tar -czf "${{ steps.version.outputs.local_txz }}" -C build . echo "Tarball contents:" - tar -tzf ${{ steps.version.outputs.local_txz }} + tar -tzf "${{ steps.version.outputs.local_txz }}" - name: Generate plugin file if: steps.changed-files.outputs.has_changes == 'true'