From e64529468cf7180f1ec847a876e4ec3f070d5750 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 6 Apr 2026 19:27:33 +0200 Subject: [PATCH] Replace deleted_files with manifest-based updater cleanup, add release workflow - Add manifest.json generation script (scripts/generate-manifest.php) - Add Updater::cleanStaleFiles() that removes files not in manifest - Add /api/v1/update/clean endpoint with backward compatibility - Add configurable update_protected_paths in config/invoiceshelf.php - Update frontend to use clean step instead of delete step - Add GitHub Actions release workflow triggered on version tags - Add .github/release.yml for auto-generated changelog categories - Update Makefile to include manifest generation and scripts directory --- .github/release.yml | 18 ++++ .github/workflows/release.yaml | 96 +++++++++++++++++++ Makefile | 2 + .../Controllers/Admin/UpdateController.php | 16 +++- app/Services/Update/Updater.php | 74 ++++++++++++++ config/invoiceshelf.php | 19 ++++ lang/en.json | 1 + resources/scripts-v2/api/endpoints.ts | 1 + .../scripts-v2/api/services/update.service.ts | 5 + .../views/settings/AdminUpdateAppView.vue | 10 +- routes/api.php | 1 + scripts/generate-manifest.php | 61 ++++++++++++ 12 files changed, 297 insertions(+), 7 deletions(-) create mode 100644 .github/release.yml create mode 100644 .github/workflows/release.yaml create mode 100644 scripts/generate-manifest.php diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..0f3f6b47 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,18 @@ +changelog: + categories: + - title: New Features + labels: + - enhancement + - feature + - title: Bug Fixes + labels: + - bug + - fix + - title: Maintenance + labels: + - chore + - dependencies + - ci + - title: Other Changes + labels: + - "*" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 00000000..b9f359f2 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,96 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + name: Build & Release + runs-on: ubuntu-latest + env: + extensions: bcmath, curl, dom, gd, imagick, json, libxml, mbstring, pcntl, pdo, pdo_mysql, zip + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + extensions: ${{ env.extensions }} + tools: composer + + - name: Install Composer dependencies + run: composer install --no-dev --optimize-autoloader --no-interaction + + - name: Use Node.js 24 + uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Install npm dependencies + run: npm ci + + - name: Build frontend + run: npm run build + + - name: Prepare release directory + run: | + mkdir -p /tmp/InvoiceShelf/public + + cp -r app /tmp/InvoiceShelf/ + cp -r bootstrap /tmp/InvoiceShelf/ + cp -r config /tmp/InvoiceShelf/ + cp -r database /tmp/InvoiceShelf/ + cp -r public/build /tmp/InvoiceShelf/public/ + cp -r public/favicons /tmp/InvoiceShelf/public/ + cp public/.htaccess /tmp/InvoiceShelf/public/ + cp public/index.php /tmp/InvoiceShelf/public/ + cp public/robots.txt /tmp/InvoiceShelf/public/ + cp public/web.config /tmp/InvoiceShelf/public/ + cp -r resources /tmp/InvoiceShelf/ + cp -r lang /tmp/InvoiceShelf/ + cp -r routes /tmp/InvoiceShelf/ + cp -r storage /tmp/InvoiceShelf/ + cp -r vendor /tmp/InvoiceShelf/ 2>/dev/null || true + cp -r scripts /tmp/InvoiceShelf/ + cp version.md /tmp/InvoiceShelf/ + cp .env.example /tmp/InvoiceShelf/ + cp artisan /tmp/InvoiceShelf/ + cp composer.json /tmp/InvoiceShelf/ + cp composer.lock /tmp/InvoiceShelf/ + cp LICENSE /tmp/InvoiceShelf/ + cp readme.md /tmp/InvoiceShelf/ + cp SECURITY.md /tmp/InvoiceShelf/ + cp server.php /tmp/InvoiceShelf/ + + # Clean up runtime artifacts + find /tmp/InvoiceShelf -wholename '*/[Tt]ests/*' -delete + find /tmp/InvoiceShelf -wholename '*/[Tt]est/*' -delete + rm -rf /tmp/InvoiceShelf/storage/framework/cache/data/* 2>/dev/null || true + rm -f /tmp/InvoiceShelf/storage/framework/sessions/* 2>/dev/null || true + rm -f /tmp/InvoiceShelf/storage/framework/views/* 2>/dev/null || true + rm -f /tmp/InvoiceShelf/storage/logs/* 2>/dev/null || true + touch /tmp/InvoiceShelf/storage/logs/laravel.log + + - name: Generate manifest + run: php scripts/generate-manifest.php /tmp/InvoiceShelf + + - name: Create zip + working-directory: /tmp + run: zip -r InvoiceShelf.zip InvoiceShelf/ + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: /tmp/InvoiceShelf.zip + generate_release_notes: true + make_latest: true diff --git a/Makefile b/Makefile index de45ca6c..04fbe696 100644 --- a/Makefile +++ b/Makefile @@ -29,6 +29,7 @@ dist-gen: clean composer npm-build @cp -r routes InvoiceShelf @cp -r storage InvoiceShelf @cp -r vendor InvoiceShelf 2> /dev/null || true + @cp -r scripts InvoiceShelf @cp -r version.md InvoiceShelf @cp -r .env.example InvoiceShelf @cp -r artisan InvoiceShelf @@ -47,6 +48,7 @@ dist-clean: dist-gen @rm InvoiceShelf/storage/framework/sessions/* 2> /dev/null || true @rm InvoiceShelf/storage/framework/views/* 2> /dev/null || true @rm InvoiceShelf/storage/logs/* 2> /dev/null || true + @php scripts/generate-manifest.php InvoiceShelf dist: dist-clean @zip -r InvoiceShelf.zip InvoiceShelf diff --git a/app/Http/Controllers/Admin/UpdateController.php b/app/Http/Controllers/Admin/UpdateController.php index c56a2908..a66ece2e 100644 --- a/app/Http/Controllers/Admin/UpdateController.php +++ b/app/Http/Controllers/Admin/UpdateController.php @@ -66,14 +66,26 @@ class UpdateController extends Controller } public function delete(Request $request): JsonResponse + { + return $this->clean($request); + } + + public function clean(Request $request): JsonResponse { $this->ensureSuperAdmin(); - if (isset($request->deleted_files) && ! empty($request->deleted_files)) { + // Backward compatibility: use deleted_files when no manifest exists + if (! File::exists(base_path('manifest.json')) + && isset($request->deleted_files) + && ! empty($request->deleted_files)) { Updater::deleteFiles($request->deleted_files); + + return response()->json(['success' => true, 'cleaned' => 0]); } - return response()->json(['success' => true]); + $result = Updater::cleanStaleFiles(); + + return response()->json($result); } public function migrate(Request $request): JsonResponse diff --git a/app/Services/Update/Updater.php b/app/Services/Update/Updater.php index caa7d999..26c9870b 100644 --- a/app/Services/Update/Updater.php +++ b/app/Services/Update/Updater.php @@ -131,6 +131,80 @@ class Updater return true; } + public static function cleanStaleFiles(): array + { + $manifestPath = base_path('manifest.json'); + + if (! File::exists($manifestPath)) { + return ['success' => true, 'cleaned' => 0]; + } + + $manifest = json_decode(File::get($manifestPath), true); + + if (! is_array($manifest)) { + return ['success' => false, 'error' => 'Invalid manifest']; + } + + $manifestLookup = array_flip($manifest); + $protectedPaths = config('invoiceshelf.update_protected_paths', []); + $cleaned = 0; + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator(base_path(), \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $file) { + $relativePath = substr($file->getPathname(), strlen(base_path()) + 1); + + if (static::isProtectedPath($relativePath, $protectedPaths)) { + continue; + } + + if ($file->isFile() && ! isset($manifestLookup[$relativePath])) { + File::delete($file->getPathname()); + $cleaned++; + } + } + + // Second pass: remove empty directories + $dirIterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator(base_path(), \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($dirIterator as $item) { + if (! $item->isDir()) { + continue; + } + + $relativePath = substr($item->getPathname(), strlen(base_path()) + 1); + + if (static::isProtectedPath($relativePath, $protectedPaths)) { + continue; + } + + $entries = scandir($item->getPathname()); + + if (count($entries) <= 2) { + @rmdir($item->getPathname()); + } + } + + return ['success' => true, 'cleaned' => $cleaned]; + } + + private static function isProtectedPath(string $relativePath, array $protectedPaths): bool + { + foreach ($protectedPaths as $protected) { + if ($relativePath === $protected || str_starts_with($relativePath, $protected.'/')) { + return true; + } + } + + return false; + } + public static function migrateUpdate() { Artisan::call('migrate --force'); diff --git a/config/invoiceshelf.php b/config/invoiceshelf.php index e3ee9a5c..3be8e622 100644 --- a/config/invoiceshelf.php +++ b/config/invoiceshelf.php @@ -46,6 +46,25 @@ return [ */ 'base_url' => 'https://invoiceshelf.com', + /* + * Paths protected from cleanup during updates. + * The updater will never delete files under these prefixes. + */ + 'update_protected_paths' => [ + '.env', + 'storage', + 'vendor', + 'node_modules', + 'Modules', + 'public/storage', + '.git', + 'bootstrap/cache', + 'manifest.json', + 'android', + 'ios', + 'mobile', + ], + /* * List of languages supported by InvoiceShelf. */ diff --git a/lang/en.json b/lang/en.json index 211dd8f2..ddd7cc46 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1366,6 +1366,7 @@ "unzipping_package": "Unzipping Package", "copying_files": "Copying Files", "deleting_files": "Deleting Unused files", + "cleaning_stale_files": "Cleaning stale files", "running_migrations": "Running Migrations", "finishing_update": "Finishing Update", "update_failed": "Update Failed", diff --git a/resources/scripts-v2/api/endpoints.ts b/resources/scripts-v2/api/endpoints.ts index 9b45ae7c..1b5fb08f 100644 --- a/resources/scripts-v2/api/endpoints.ts +++ b/resources/scripts-v2/api/endpoints.ts @@ -153,6 +153,7 @@ export const API = { UPDATE_UNZIP: '/api/v1/update/unzip', UPDATE_COPY: '/api/v1/update/copy', UPDATE_DELETE: '/api/v1/update/delete', + UPDATE_CLEAN: '/api/v1/update/clean', UPDATE_MIGRATE: '/api/v1/update/migrate', UPDATE_FINISH: '/api/v1/update/finish', diff --git a/resources/scripts-v2/api/services/update.service.ts b/resources/scripts-v2/api/services/update.service.ts index f68aac94..178ed71d 100644 --- a/resources/scripts-v2/api/services/update.service.ts +++ b/resources/scripts-v2/api/services/update.service.ts @@ -62,6 +62,11 @@ export const updateService = { return data }, + async clean(payload?: { deleted_files?: string | string[] | null }): Promise { + const { data } = await client.post(API.UPDATE_CLEAN, payload ?? {}) + return data + }, + async migrate(): Promise { const { data } = await client.post(API.UPDATE_MIGRATE) return data diff --git a/resources/scripts-v2/features/admin/views/settings/AdminUpdateAppView.vue b/resources/scripts-v2/features/admin/views/settings/AdminUpdateAppView.vue index cc3d61fe..e0243dc6 100644 --- a/resources/scripts-v2/features/admin/views/settings/AdminUpdateAppView.vue +++ b/resources/scripts-v2/features/admin/views/settings/AdminUpdateAppView.vue @@ -14,7 +14,7 @@ type UpdateStepKey = | 'download' | 'unzip' | 'copy' - | 'delete' + | 'clean' | 'migrate' | 'finish' @@ -58,8 +58,8 @@ const updateSteps = ref([ time: null, }, { - key: 'delete', - translationKey: 'settings.update_app.deleting_files', + key: 'clean', + translationKey: 'settings.update_app.cleaning_stale_files', status: 'pending', time: null, }, @@ -202,8 +202,8 @@ async function startUpdate(): Promise { break } - case 'delete': - await updateService.delete({ + case 'clean': + await updateService.clean({ deleted_files: updateRelease.value.deleted_files ?? null, }) break diff --git a/routes/api.php b/routes/api.php index c2dd6791..530394b5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -434,6 +434,7 @@ Route::prefix('/v1')->group(function () { Route::post('/update/unzip', [UpdateController::class, 'unzip']); Route::post('/update/copy', [UpdateController::class, 'copy']); Route::post('/update/delete', [UpdateController::class, 'delete']); + Route::post('/update/clean', [UpdateController::class, 'clean']); Route::post('/update/migrate', [UpdateController::class, 'migrate']); Route::post('/update/finish', [UpdateController::class, 'finish']); diff --git a/scripts/generate-manifest.php b/scripts/generate-manifest.php new file mode 100644 index 00000000..ad8c49eb --- /dev/null +++ b/scripts/generate-manifest.php @@ -0,0 +1,61 @@ +isFile()) { + continue; + } + + $relativePath = substr($file->getPathname(), strlen($basePath) + 1); + + foreach ($excludedPrefixes as $prefix) { + if (str_starts_with($relativePath, $prefix)) { + continue 2; + } + } + + $files[] = $relativePath; +} + +sort($files); + +$json = json_encode($files, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + +file_put_contents($basePath.'/manifest.json', $json."\n"); + +$count = count($files); +fwrite(STDOUT, "manifest.json written with {$count} files.\n");