diff --git a/appinfo/info.xml b/appinfo/info.xml
index 7617ab4293..eca901211f 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -63,6 +63,9 @@ https://libresign.coop
OCA\Libresign\BackgroundJob\SignSingleFileJob
+
+ OCA\Libresign\Migration\RefreshAutoloaderBeforeMigrations
+
OCA\Libresign\Migration\DeleteOldBinaries
OCA\Libresign\Migration\ResynchronizeDatabaseSequences
diff --git a/lib/Migration/RefreshAutoloaderBeforeMigrations.php b/lib/Migration/RefreshAutoloaderBeforeMigrations.php
new file mode 100644
index 0000000000..396e1a8390
--- /dev/null
+++ b/lib/Migration/RefreshAutoloaderBeforeMigrations.php
@@ -0,0 +1,52 @@
+ `OC::init()` -> `AppManager::loadApps()`.
+ * - When an update later drops new LibreSign classes into place, the original Composer loader can
+ * keep previous class misses cached. In the bug that triggered this step, `MigrationService`
+ * reported `Migration step ... is unknown` for a newly introduced migration class.
+ * - Requiring `composer/autoload.php` again is not enough in this flow: `OC_App::registerAutoloading()`
+ * uses `require_once`, and Composer keeps the app-specific loader in static state after the first
+ * bootstrap. We therefore rebuild a fresh loader from Composer's generated metadata files.
+ * - This is the safest app-level mitigation we found, not a full hot-reload primitive: PHP still
+ * cannot redefine classes or functions that were already loaded earlier in the same request. A
+ * complete fix for that broader limitation would need to happen in Nextcloud core by upgrading the
+ * app before it is booted, or by performing the upgrade in a fresh PHP process.
+ * - Keeping this logic in a `pre-migration` repair step is still the earliest reliable app-level
+ * extension point in `Installer::updateAppstoreApp()` -> `AppManager::upgradeApp()`.
+ *
+ * References:
+ * - LibreSign/libresign#7892
+ * - Related but different root cause: nextcloud/calendar_resource_management#238 / #239 fixed an
+ * OCC upgrade by removing `classmap-authoritative`; LibreSign's main app autoloader does not
+ * enable that flag.
+ *
+ * If this step is ever removed or changed, reproduce those upgrade scenarios first and confirm that
+ * newly introduced LibreSign classes still load correctly in both web- and OCC-driven upgrades.
+ */
+final class RefreshAutoloaderBeforeMigrations implements IRepairStep {
+ #[\Override]
+ public function getName(): string {
+ return 'Refresh LibreSign autoloader before migrations';
+ }
+
+ #[\Override]
+ public function run(IOutput $output): void {
+ UpgradeSafeAutoloader::registerCurrentAppLoader(dirname(__DIR__, 2));
+ }
+}
diff --git a/lib/Migration/UpgradeSafeAutoloader.php b/lib/Migration/UpgradeSafeAutoloader.php
new file mode 100644
index 0000000000..73edba1b87
--- /dev/null
+++ b/lib/Migration/UpgradeSafeAutoloader.php
@@ -0,0 +1,197 @@
+
+ */
+ private static array $registeredRoots = [];
+
+ private function __construct() {
+ }
+
+ public static function register(ClassLoader $loader, string $appRoot): void {
+ $normalizedRoot = rtrim($appRoot, DIRECTORY_SEPARATOR);
+ if ($normalizedRoot === '' || isset(self::$registeredRoots[$normalizedRoot])) {
+ return;
+ }
+
+ $replacementLoader = self::buildCurrentLoader($normalizedRoot);
+ if (!$replacementLoader instanceof ClassLoader) {
+ return;
+ }
+
+ $loader->unregister();
+ $replacementLoader->register(true);
+ self::loadComposerFiles($normalizedRoot);
+ self::$registeredRoots[$normalizedRoot] = true;
+ }
+
+ public static function registerCurrentAppLoader(string $appRoot): void {
+ $loader = self::findRegisteredLoader(rtrim($appRoot, DIRECTORY_SEPARATOR));
+ if (!$loader instanceof ClassLoader) {
+ return;
+ }
+
+ self::register($loader, $appRoot);
+ }
+
+ /**
+ * @return array>|null
+ */
+ private static function readPsr0Namespaces(string $appRoot): ?array {
+ return self::readComposerArrayFile($appRoot, self::AUTOLOAD_NAMESPACES_FILE);
+ }
+
+ /**
+ * @return array>|null
+ */
+ private static function readPsr4Namespaces(string $appRoot): ?array {
+ return self::readComposerArrayFile($appRoot, self::AUTOLOAD_PSR4_FILE);
+ }
+
+ /**
+ * @return array|null
+ */
+ private static function readCurrentClassMap(string $appRoot): ?array {
+ return self::readComposerArrayFile($appRoot, self::CLASSMAP_FILE);
+ }
+
+ /**
+ * @return array|null
+ */
+ private static function readComposerFiles(string $appRoot): ?array {
+ return self::readComposerArrayFile($appRoot, self::AUTOLOAD_FILES_FILE);
+ }
+
+ /**
+ * @return array|null
+ */
+ private static function readComposerArrayFile(string $appRoot, string $relativeFile): ?array {
+ $file = $appRoot . $relativeFile;
+ if (!is_file($file)) {
+ return null;
+ }
+
+ $data = require $file;
+ if (!is_array($data)) {
+ return null;
+ }
+
+ return $data;
+ }
+
+ private static function buildCurrentLoader(string $appRoot): ?ClassLoader {
+ $psr0Namespaces = self::readPsr0Namespaces($appRoot);
+ $psr4Namespaces = self::readPsr4Namespaces($appRoot);
+ $classMap = self::readCurrentClassMap($appRoot);
+ if ($psr0Namespaces === null || $psr4Namespaces === null || $classMap === null) {
+ return null;
+ }
+
+ $loader = new ClassLoader($appRoot . '/vendor');
+ foreach ($psr0Namespaces as $prefix => $paths) {
+ if (!is_string($prefix)) {
+ continue;
+ }
+ $normalizedPaths = self::normalizePathList($paths);
+ if ($normalizedPaths === []) {
+ continue;
+ }
+ $loader->add($prefix, $normalizedPaths);
+ }
+
+ foreach ($psr4Namespaces as $prefix => $paths) {
+ if (!is_string($prefix)) {
+ continue;
+ }
+ $normalizedPaths = self::normalizePathList($paths);
+ if ($normalizedPaths === []) {
+ continue;
+ }
+ $loader->addPsr4($prefix, $normalizedPaths);
+ }
+
+ $loader->addClassMap(self::normalizeClassMap($classMap));
+
+ return $loader;
+ }
+
+ /**
+ * @param mixed $paths
+ * @return list
+ */
+ private static function normalizePathList(mixed $paths): array {
+ if (is_string($paths)) {
+ return [$paths];
+ }
+
+ if (!is_array($paths)) {
+ return [];
+ }
+
+ $normalizedPaths = [];
+ foreach ($paths as $path) {
+ if (is_string($path)) {
+ $normalizedPaths[] = $path;
+ }
+ }
+
+ return $normalizedPaths;
+ }
+
+ /**
+ * @param array $classMap
+ * @return array
+ */
+ private static function normalizeClassMap(array $classMap): array {
+ $normalizedClassMap = [];
+ foreach ($classMap as $class => $path) {
+ if (is_string($class) && is_string($path)) {
+ $normalizedClassMap[$class] = $path;
+ }
+ }
+
+ return $normalizedClassMap;
+ }
+
+ private static function findRegisteredLoader(string $appRoot): ?ClassLoader {
+ $vendorDir = $appRoot . '/vendor';
+
+ return ClassLoader::getRegisteredLoaders()[$vendorDir] ?? null;
+ }
+
+ private static function loadComposerFiles(string $appRoot): void {
+ $files = self::readComposerFiles($appRoot);
+ if ($files === null) {
+ return;
+ }
+
+ if (!isset($GLOBALS['__composer_autoload_files']) || !is_array($GLOBALS['__composer_autoload_files'])) {
+ $GLOBALS['__composer_autoload_files'] = [];
+ }
+
+ foreach ($files as $identifier => $file) {
+ if (!is_string($identifier) || !is_string($file) || isset($GLOBALS['__composer_autoload_files'][$identifier])) {
+ continue;
+ }
+
+ $GLOBALS['__composer_autoload_files'][$identifier] = true;
+ require $file;
+ }
+ }
+}
diff --git a/tests/php/Unit/Migration/RefreshAutoloaderBeforeMigrationsTest.php b/tests/php/Unit/Migration/RefreshAutoloaderBeforeMigrationsTest.php
new file mode 100644
index 0000000000..143b44b899
--- /dev/null
+++ b/tests/php/Unit/Migration/RefreshAutoloaderBeforeMigrationsTest.php
@@ -0,0 +1,91 @@
+getName());
+ }
+
+ /**
+ * @runInSeparateProcess
+ */
+ public function testRunRefreshesCurrentAppLoaderForNewMigrationFile(): void {
+ $appRoot = dirname(__DIR__, 4);
+ $helperFile = $appRoot . self::HELPER_FILE;
+ $migrationFile = $appRoot . self::MIGRATION_FILE;
+ @unlink($helperFile);
+ @unlink($migrationFile);
+
+ try {
+ clearstatcache(true, $helperFile);
+ clearstatcache(true, $migrationFile);
+ self::assertFalse(class_exists(self::MIGRATION_CLASS));
+ self::assertFalse(class_exists(self::HELPER_CLASS));
+
+ self::assertNotFalse(file_put_contents(
+ $helperFile,
+ <<<'PHP'
+ run($this->createMock(IOutput::class));
+
+ self::assertTrue(class_exists(self::MIGRATION_CLASS));
+ self::assertTrue(class_exists(self::HELPER_CLASS));
+ $migrationClass = self::MIGRATION_CLASS;
+ self::assertSame('pong', $migrationClass::run());
+ } finally {
+ @unlink($helperFile);
+ @unlink($migrationFile);
+ }
+ }
+}
diff --git a/tests/php/Unit/Migration/UpgradeSafeAutoloaderTest.php b/tests/php/Unit/Migration/UpgradeSafeAutoloaderTest.php
new file mode 100644
index 0000000000..5d1773486b
--- /dev/null
+++ b/tests/php/Unit/Migration/UpgradeSafeAutoloaderTest.php
@@ -0,0 +1,382 @@
+addPsr4('OCA\\Libresign\\', [$appRoot . '/lib'], true);
+ $loader->register(true);
+
+ try {
+ mkdir($appRoot . '/lib/Migration', 0755, true);
+ self::writeComposerMetadata($appRoot);
+
+ self::assertFalse(class_exists(self::MIGRATION_CLASS));
+
+ file_put_contents(
+ $appRoot . self::MIGRATION_FILE,
+ <<<'PHP'
+addPsr4('OCA\\\\Libresign\\\\', [dirname(__DIR__) . '/lib'], true);\n"
+ . " \$loader->register(true);\n"
+ . "}\n"
+ . "return \$loader;\n",
+ );
+
+ file_put_contents(
+ $appRoot . '/composer/autoload.php',
+ " $appRoot . self::VENDOR_FILE,
+ ]);
+
+ require_once $appRoot . '/composer/autoload.php';
+ self::assertSame(1, $GLOBALS[self::AUTOLOAD_COUNTER_KEY]);
+ self::assertFalse(class_exists(self::MIGRATION_CLASS));
+ self::assertFalse(class_exists(self::HELPER_CLASS));
+ self::assertFalse(class_exists(self::VENDOR_CLASS));
+
+ UpgradeSafeAutoloader::registerCurrentAppLoader($appRoot);
+ $registeredLoader = ClassLoader::getRegisteredLoaders()[$appRoot . '/vendor'] ?? null;
+ self::assertInstanceOf(ClassLoader::class, $registeredLoader);
+ self::assertNotSame($loader, $registeredLoader);
+ self::assertTrue(class_exists(self::MIGRATION_CLASS));
+ self::assertTrue(class_exists(self::HELPER_CLASS));
+ self::assertTrue(class_exists(self::VENDOR_CLASS));
+ $migrationClass = self::MIGRATION_CLASS;
+ self::assertSame('pong-widget', $migrationClass::run());
+ } finally {
+ self::unregisterLoaderForAppRoot($appRoot);
+ unset($GLOBALS[self::AUTOLOAD_COUNTER_KEY]);
+ self::removeDirectoryRecursively($appRoot);
+ }
+ }
+
+ /**
+ * @runInSeparateProcess
+ */
+ public function testFindsRegisteredLoaderForCurrentAppRoot(): void {
+ $appRoot = sys_get_temp_dir() . '/libresign-upgrade-autoload-' . uniqid('', true);
+ $loader = new ClassLoader($appRoot . '/vendor');
+ $loader->addPsr4('OCA\\Libresign\\', [$appRoot . '/lib'], true);
+ $loader->register(true);
+
+ try {
+ mkdir($appRoot . '/lib/Migration', 0755, true);
+ self::writeComposerMetadata($appRoot);
+
+ self::assertFalse(class_exists(self::MIGRATION_CLASS));
+
+ file_put_contents(
+ $appRoot . self::MIGRATION_FILE,
+ <<<'PHP'
+addPsr4('OCA\\Libresign\\', [$appRoot . '/lib'], true);
+ $loader->register(true);
+
+ try {
+ mkdir($appRoot . '/vendor-prefixed/Demo', 0755, true);
+
+ file_put_contents(
+ $appRoot . self::VENDOR_FILE,
+ <<<'PHP'
+ $appRoot . self::VENDOR_FILE,
+ ]);
+
+ self::assertFalse(class_exists(self::VENDOR_CLASS));
+
+ UpgradeSafeAutoloader::register($loader, $appRoot);
+
+ $registeredLoader = ClassLoader::getRegisteredLoaders()[$appRoot . '/vendor'] ?? null;
+ self::assertInstanceOf(ClassLoader::class, $registeredLoader);
+ self::assertNotSame($loader, $registeredLoader);
+ self::assertTrue(class_exists(self::VENDOR_CLASS));
+ $vendorClass = self::VENDOR_CLASS;
+ self::assertSame('widget', $vendorClass::name());
+ } finally {
+ self::unregisterLoaderForAppRoot($appRoot);
+ self::removeDirectoryRecursively($appRoot);
+ }
+ }
+
+ /**
+ * @runInSeparateProcess
+ */
+ public function testLoadsComposerFilesFromFreshLoader(): void {
+ $appRoot = sys_get_temp_dir() . '/libresign-upgrade-autoload-' . uniqid('', true);
+ $loader = new ClassLoader($appRoot . '/vendor');
+ $loader->register(true);
+
+ try {
+ mkdir(dirname($appRoot . self::PROBE_FUNCTION_FILE), 0755, true);
+
+ file_put_contents(
+ $appRoot . self::PROBE_FUNCTION_FILE,
+ <<<'PHP'
+ $appRoot . self::PROBE_FUNCTION_FILE,
+ ]);
+
+ self::assertFalse(function_exists(self::PROBE_FUNCTION));
+
+ UpgradeSafeAutoloader::register($loader, $appRoot);
+
+ self::assertTrue(function_exists(self::PROBE_FUNCTION));
+ $function = self::PROBE_FUNCTION;
+ self::assertSame('ok', $function());
+ } finally {
+ self::unregisterLoaderForAppRoot($appRoot);
+ self::removeDirectoryRecursively($appRoot);
+ }
+ }
+
+ private static function writeComposerMetadata(string $appRoot, array $classMap = [], array $files = []): void {
+ $directory = $appRoot . '/vendor/composer';
+ if (!is_dir($directory)) {
+ mkdir($directory, 0755, true);
+ }
+
+ file_put_contents(
+ $directory . '/autoload_namespaces.php',
+ " [$appRoot . '/lib'],
+ ], true) . ";\n",
+ );
+ file_put_contents(
+ $directory . '/autoload_classmap.php',
+ "unregister();
+ }
+ }
+
+ private static function removeDirectoryRecursively(string $path): void {
+ if (!file_exists($path)) {
+ return;
+ }
+
+ if (is_file($path)) {
+ @unlink($path);
+ return;
+ }
+
+ $entries = scandir($path);
+ if (!is_array($entries)) {
+ @rmdir($path);
+ return;
+ }
+
+ foreach ($entries as $entry) {
+ if ($entry === '.' || $entry === '..') {
+ continue;
+ }
+
+ self::removeDirectoryRecursively($path . '/' . $entry);
+ }
+
+ @rmdir($path);
+ }
+}