diff --git a/.github/workflows/upgrade-command.yml b/.github/workflows/upgrade-command.yml new file mode 100644 index 0000000000..9227d3d753 --- /dev/null +++ b/.github/workflows/upgrade-command.yml @@ -0,0 +1,155 @@ +name: Upgrade command + +on: + workflow_dispatch: + push: + branches: [2.x] + paths: + - packages/upgrade/** + - packages/core/database/migrations/** + - .github/workflows/upgrade-command.yml + pull_request: + branches: [2.x] + paths: + - packages/upgrade/** + - packages/core/database/migrations/** + - .github/workflows/upgrade-command.yml + +jobs: + lunar-upgrade: + name: v1.5 to v2 (${{ matrix.database }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - database: sqlite + db_connection: sqlite + db_database: /tmp/vanilla-lunar/database/database.sqlite + - database: mysql + db_connection: mysql + db_port: "3306" + db_database: lunar_upgrade + db_username: root + db_password: root + - database: postgres + db_connection: pgsql + db_port: "5432" + db_database: lunar_upgrade + db_username: postgres + db_password: postgres + - database: mariadb + db_connection: mariadb + db_port: "3307" + db_database: lunar_upgrade + db_username: root + db_password: root + env: + APP_DIR: /tmp/vanilla-lunar + LUNAR_V1: "1.5.0" + APP_ENV: local + APP_DEBUG: "true" + SCOUT_DRIVER: collection + QUEUE_CONNECTION: sync + DB_CONNECTION: ${{ matrix.db_connection }} + DB_HOST: 127.0.0.1 + DB_PORT: ${{ matrix.db_port }} + DB_DATABASE: ${{ matrix.db_database }} + DB_USERNAME: ${{ matrix.db_username }} + DB_PASSWORD: ${{ matrix.db_password }} + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: lunar_upgrade + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h 127.0.0.1 -uroot -proot" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + mariadb: + image: mariadb:11 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: lunar_upgrade + ports: + - 3307:3306 + options: >- + --health-cmd="mariadb-admin ping -h 127.0.0.1 -uroot -proot" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + POSTGRES_DB: lunar_upgrade + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s + --health-timeout=5s + --health-retries=10 + steps: + - uses: actions/checkout@v4 + + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.5" + extensions: bcmath, exif, intl, mbstring, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite + tools: composer:v2 + coverage: none + + - name: Create Laravel 13 app + run: composer create-project laravel/laravel:^13.0 "$APP_DIR" --prefer-dist --no-interaction --no-dev + + - name: Configure app + working-directory: /tmp/vanilla-lunar + run: | + php artisan key:generate --force --no-interaction + if [ "${{ matrix.database }}" = "sqlite" ]; then + mkdir -p database + touch database/database.sqlite + fi + { + echo "APP_ENV=local" + echo "APP_DEBUG=true" + echo "SCOUT_DRIVER=collection" + echo "QUEUE_CONNECTION=sync" + echo "DB_CONNECTION=$DB_CONNECTION" + echo "DB_HOST=$DB_HOST" + echo "DB_PORT=$DB_PORT" + echo "DB_DATABASE=$DB_DATABASE" + echo "DB_USERNAME=$DB_USERNAME" + echo "DB_PASSWORD=$DB_PASSWORD" + } >> .env + + - name: Install Lunar v1.5 core + working-directory: /tmp/vanilla-lunar + run: composer require "lunarphp/core:${LUNAR_V1}" --no-interaction --no-progress + + - name: Migrate v1 schema + working-directory: /tmp/vanilla-lunar + run: php artisan migrate --force --no-interaction + + - name: Switch app to this v2 checkout + working-directory: /tmp/vanilla-lunar + run: | + composer config minimum-stability dev + composer config prefer-stable true + composer config repositories.lunar-core "{\"type\":\"path\",\"url\":\"${GITHUB_WORKSPACE}/packages/core\",\"options\":{\"symlink\":true}}" + composer config repositories.lunar-upgrade "{\"type\":\"path\",\"url\":\"${GITHUB_WORKSPACE}/packages/upgrade\",\"options\":{\"symlink\":true}}" + composer require lunarphp/core:@dev lunarphp/upgrade:@dev --no-interaction --no-progress --with-all-dependencies + + - name: Run lunar:upgrade + working-directory: /tmp/vanilla-lunar + run: php artisan lunar:upgrade --no-interaction + + - name: Apply remaining migrations + working-directory: /tmp/vanilla-lunar + run: php artisan migrate --force --no-interaction diff --git a/packages/upgrade/config/upgrade.php b/packages/upgrade/config/upgrade.php index 8b02ecb30a..7609806959 100644 --- a/packages/upgrade/config/upgrade.php +++ b/packages/upgrade/config/upgrade.php @@ -165,6 +165,7 @@ '2026_01_01_000068_create_country_region_table', '2026_01_01_000069_add_region_id_to_carts_and_orders', '2026_01_01_000059_add_orders_cart_id_foreign_key', + '2026_01_01_000071_add_product_types_default_tax_class_foreign_key', '2026_01_01_900000_create_staff_table', '2026_01_01_900001_create_activity_log_table', '2026_01_01_900002_create_permission_tables', diff --git a/packages/upgrade/database/migrations/2026_06_01_000003_convert_attribute_data_keys_to_ids.php b/packages/upgrade/database/migrations/2026_06_01_000003_convert_attribute_data_keys_to_ids.php index a35985ffc8..e54ec394da 100644 --- a/packages/upgrade/database/migrations/2026_06_01_000003_convert_attribute_data_keys_to_ids.php +++ b/packages/upgrade/database/migrations/2026_06_01_000003_convert_attribute_data_keys_to_ids.php @@ -24,9 +24,9 @@ * 6. Convert `attributes.validation_rules` from the v1 pipe-delimited string * (`min:1|max:10`) to the v2 json list (`["min:1", "max:10"]`) — split on * `|` exactly as Laravel's validator parses string rules. - * 7. Add `attribute_groups.system`, drop the morph columns and unused - * `section` / `default_value`, make `attributes.attribute_group_id` - * nullable. + * 7. Add `attribute_groups.system`, drop the morph columns (and the v1 + * indexes that still sit on them) and unused `section` / `default_value`, + * make `attributes.attribute_group_id` nullable. * * One-way — `down()` is intentionally absent. Restore from backup to reverse. */ @@ -319,6 +319,9 @@ protected function reshapeAttributeGroupsSchema(): void } if (Schema::hasColumn($table, 'attributable_type')) { + // SQLite refuses DROP COLUMN while the v1 index remains. + $this->dropIndexIfExists($table, ['attributable_type']); + Schema::table($table, function (Blueprint $table) { $table->dropColumn('attributable_type'); }); @@ -393,6 +396,12 @@ protected function reshapeAttributesSchema(): void )); if ($drops !== []) { + if (in_array('attribute_type', $drops, true)) { + // MariaDB 1072 / SQLite: DROP COLUMN fails while these remain. + $this->dropIndexIfExists($table, ['attribute_type', 'handle'], 'unique'); + $this->dropIndexIfExists($table, ['attribute_type']); + } + Schema::table($table, function (Blueprint $blueprint) use ($drops) { $blueprint->dropColumn($drops); }); @@ -406,6 +415,35 @@ protected function reshapeAttributesSchema(): void } } + /** + * Drop a v1 index by its live name. `Schema::hasIndex(..., 'index')` misses + * SQLite, which reports the type as `btree` rather than `index`. + * + * @param list $columns + */ + protected function dropIndexIfExists(string $table, array $columns, string $type = 'index'): void + { + $wantUnique = $type === 'unique'; + + foreach (Schema::getIndexes($table) as $index) { + if ($index['columns'] !== $columns || (bool) $index['unique'] !== $wantUnique) { + continue; + } + + Schema::table($table, function (Blueprint $blueprint) use ($index, $wantUnique): void { + if ($wantUnique) { + $blueprint->dropUnique($index['name']); + + return; + } + + $blueprint->dropIndex($index['name']); + }); + + return; + } + } + protected function normaliseMorph(?string $value): ?string { if ($value === null || $value === '') { diff --git a/packages/upgrade/src/Steps/LedgerRewriteStep.php b/packages/upgrade/src/Steps/LedgerRewriteStep.php index c8cbbf32c9..59865ea8e6 100644 --- a/packages/upgrade/src/Steps/LedgerRewriteStep.php +++ b/packages/upgrade/src/Steps/LedgerRewriteStep.php @@ -6,6 +6,7 @@ use Illuminate\Database\ConnectionResolverInterface; use Illuminate\Database\Migrations\Migrator; +use Illuminate\Support\Facades\Schema; use Lunar\Upgrade\Support\StepReport; /** @@ -19,7 +20,8 @@ * the app's own files do not, and deleting their rows would make `migrate` * re-run them), and a baseline row is only inserted when its file is present * (marking-run a migration from an uninstalled sub-package would silently - * skip it if that package is installed later). + * skip it if that package is installed later). Staff is extra: the create + * file now lives in core, so we also require the table to exist. */ class LedgerRewriteStep implements UpgradeStep { @@ -72,7 +74,8 @@ public function run(StepContext $context): void $toInsert = array_values(array_filter( array_diff($config['v2_baseline'], $existing), - fn (string $migration): bool => isset($known[$migration]), + fn (string $migration): bool => isset($known[$migration]) + && $this->baselineSchemaPresent($migration, $context->connection), )); if ($context->dryRun) { @@ -107,6 +110,23 @@ public function run(StepContext $context): void ); } + /** + * Staff lived in the v1 admin package. v2 folded `create_staff` into core, + * so the migration file is always present even when the table was never + * created (core-only / headless v1). Marking it run would skip the create + * and leave later core staff migrations altering a missing table. + */ + protected function baselineSchemaPresent(string $migration, ?string $connection): bool + { + if ($migration !== '2026_01_01_900000_create_staff_table') { + return true; + } + + return Schema::connection($connection)->hasTable( + config('lunar.database.table_prefix').'staff', + ); + } + /** * Migration names the application can currently load, keyed by name — * every path registered with the migrator (package `loadMigrationsFrom` diff --git a/tests/upgrade/Feature/ConvertAttributeDataKeysToIdsTest.php b/tests/upgrade/Feature/ConvertAttributeDataKeysToIdsTest.php index 5dd343c57e..00fbd57fee 100644 --- a/tests/upgrade/Feature/ConvertAttributeDataKeysToIdsTest.php +++ b/tests/upgrade/Feature/ConvertAttributeDataKeysToIdsTest.php @@ -50,7 +50,7 @@ function simulateV1AttributeShape(): void Schema::create(SPEC0019_PREFIX.'attribute_groups', function (Blueprint $table) { $table->id(); - $table->string('attributable_type'); + $table->string('attributable_type')->index(); $table->json('name'); $table->string('handle'); $table->integer('position'); @@ -59,7 +59,7 @@ function simulateV1AttributeShape(): void Schema::create(SPEC0019_PREFIX.'attributes', function (Blueprint $table) { $table->id(); - $table->string('attribute_type'); + $table->string('attribute_type')->index(); $table->foreignId('attribute_group_id'); $table->integer('position'); $table->json('name'); @@ -74,6 +74,8 @@ function simulateV1AttributeShape(): void $table->boolean('searchable')->default(true); $table->boolean('filterable')->default(false); $table->timestamps(); + + $table->unique(['attribute_type', 'handle']); }); Schema::create(SPEC0019_PREFIX.'attributables', function (Blueprint $table) { diff --git a/tests/upgrade/Unit/LedgerRewriteStepTest.php b/tests/upgrade/Unit/LedgerRewriteStepTest.php index 1e6492439c..dae6bf51af 100644 --- a/tests/upgrade/Unit/LedgerRewriteStepTest.php +++ b/tests/upgrade/Unit/LedgerRewriteStepTest.php @@ -134,6 +134,62 @@ assertDatabaseHas('migrations', ['migration' => '2026_06_01_000000_rewrite_lunar_class_strings']); }); +it('does not mark create_staff as run when the staff table is missing', function () { + touch($this->migrationPath.'/2026_01_01_900000_create_staff_table.php'); + + Config::set('lunar.upgrade.ledger', [ + 'v1_match' => [], + 'v2_baseline' => [ + '2026_01_01_000000_create_channels_table', + '2026_01_01_900000_create_staff_table', + ], + ]); + + $report = new StepReport; + $context = new StepContext( + dryRun: false, + paths: [], + output: new OutputStyle(new StringInput(''), new BufferedOutput), + report: $report, + ); + + app(LedgerRewriteStep::class)->run($context); + + assertDatabaseHas('migrations', ['migration' => '2026_01_01_000000_create_channels_table']); + assertDatabaseMissing('migrations', ['migration' => '2026_01_01_900000_create_staff_table']); +}); + +it('marks create_staff as run when the staff table already exists', function () { + $table = config('lunar.database.table_prefix').'staff'; + + Schema::create($table, function ($blueprint) { + $blueprint->id(); + }); + + touch($this->migrationPath.'/2026_01_01_900000_create_staff_table.php'); + + Config::set('lunar.upgrade.ledger', [ + 'v1_match' => [], + 'v2_baseline' => ['2026_01_01_900000_create_staff_table'], + ]); + + $report = new StepReport; + $context = new StepContext( + dryRun: false, + paths: [], + output: new OutputStyle(new StringInput(''), new BufferedOutput), + report: $report, + ); + + try { + app(LedgerRewriteStep::class)->run($context); + + assertDatabaseHas('migrations', ['migration' => '2026_01_01_900000_create_staff_table']); + } finally { + Schema::dropIfExists($table); + } +}); + it('does not insert baseline rows for migrations the app cannot load', function () { Config::set('lunar.upgrade.ledger', [ 'v1_match' => [],