-
-
Notifications
You must be signed in to change notification settings - Fork 11.5k
Added migrate:create script for migration scaffolding #26552
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| #!/usr/bin/env node | ||
| /* eslint-disable no-console, ghost/ghost-custom/no-native-error */ | ||
|
|
||
| const path = require('path'); | ||
| const fs = require('fs'); | ||
| const semver = require('semver'); | ||
|
|
||
| const MIGRATION_TEMPLATE = `const logging = require('@tryghost/logging'); | ||
|
|
||
| // For DDL - schema changes | ||
| // const {createNonTransactionalMigration} = require('../../utils'); | ||
|
|
||
| // For DML - data changes | ||
| // const {createTransactionalMigration} = require('../../utils'); | ||
|
|
||
| // Or use a specific helper | ||
| // const {addTable, createAddColumnMigration} = require('../../utils'); | ||
|
|
||
| module.exports = /**/; | ||
| `; | ||
|
|
||
| const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/; | ||
|
|
||
| /** | ||
| * Validates that a slug is kebab-case (lowercase alphanumeric with single hyphens). | ||
| */ | ||
| function isValidSlug(slug) { | ||
| return typeof slug === 'string' && SLUG_PATTERN.test(slug); | ||
| } | ||
|
|
||
| /** | ||
| * Returns the migration version folder name for the given package version. | ||
| * | ||
| * semver.inc(v, 'minor') handles both cases: | ||
| * - Stable 6.18.0 → 6.19.0 (increments minor) | ||
| * - Prerelease 6.19.0-rc.0 → 6.19.0 (strips prerelease, keeps minor) | ||
| * | ||
| * Key invariant: 6.18.0 and 6.19.0-rc.0 both produce folder "6.19". | ||
| */ | ||
| function getNextMigrationVersion(version) { | ||
| const next = semver.inc(version, 'minor'); | ||
| if (!next) { | ||
| throw new Error(`Invalid version: ${version}`); | ||
| } | ||
| return `${semver.major(next)}.${semver.minor(next)}`; | ||
| } | ||
|
|
||
| /** | ||
| * Creates a migration file and optionally bumps package versions to RC. | ||
| * | ||
| * @param {object} options | ||
| * @param {string} options.slug - The migration name in kebab-case | ||
| * @param {string} options.coreDir - Path to ghost/core directory | ||
| * @param {Date} [options.date] - Override the timestamp (for testing) | ||
| * @returns {{migrationPath: string, rcVersion: string|null}} | ||
| */ | ||
| function createMigration({slug, coreDir, date}) { | ||
| if (!isValidSlug(slug)) { | ||
| throw new Error(`Invalid slug: "${slug}". Use kebab-case (e.g. add-column-to-posts)`); | ||
| } | ||
|
|
||
| const migrationsDir = path.join(coreDir, 'core', 'server', 'data', 'migrations', 'versions'); | ||
| const corePackagePath = path.join(coreDir, 'package.json'); | ||
|
|
||
| const corePackage = JSON.parse(fs.readFileSync(corePackagePath, 'utf8')); | ||
| const currentVersion = corePackage.version; | ||
|
|
||
| const nextVersion = getNextMigrationVersion(currentVersion); | ||
| const versionDir = path.join(migrationsDir, nextVersion); | ||
|
|
||
| const timestamp = (date || new Date()).toISOString().slice(0, 19).replace('T', '-').replaceAll(':', '-'); | ||
| const filename = `${timestamp}-${slug}.js`; | ||
| const migrationPath = path.join(versionDir, filename); | ||
|
|
||
| fs.mkdirSync(versionDir, {recursive: true}); | ||
| try { | ||
| fs.writeFileSync(migrationPath, MIGRATION_TEMPLATE, {flag: 'wx'}); | ||
| } catch (err) { | ||
| if (err.code === 'EEXIST') { | ||
| throw new Error(`Migration already exists: ${migrationPath}`); | ||
| } | ||
| throw err; | ||
| } | ||
|
|
||
| // Auto-bump to RC if this is a stable version | ||
| let rcVersion = null; | ||
| if (!semver.prerelease(currentVersion)) { | ||
| rcVersion = semver.inc(currentVersion, 'preminor', 'rc'); | ||
|
|
||
| corePackage.version = rcVersion; | ||
| fs.writeFileSync(corePackagePath, JSON.stringify(corePackage, null, 2) + '\n'); | ||
|
|
||
| const adminPackagePath = path.resolve(coreDir, '..', 'admin', 'package.json'); | ||
| if (fs.existsSync(adminPackagePath)) { | ||
| const adminPackage = JSON.parse(fs.readFileSync(adminPackagePath, 'utf8')); | ||
| adminPackage.version = rcVersion; | ||
| fs.writeFileSync(adminPackagePath, JSON.stringify(adminPackage, null, 2) + '\n'); | ||
| } | ||
| } | ||
|
|
||
| return {migrationPath, rcVersion}; | ||
| } | ||
|
|
||
| // CLI entry point | ||
| if (require.main === module) { | ||
| const slug = process.argv[2]; | ||
|
|
||
| if (!slug) { | ||
| console.error('Usage: yarn migrate:create <slug>'); | ||
| console.error(' slug: kebab-case migration name (e.g. add-column-to-posts)'); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| try { | ||
| const coreDir = path.resolve(__dirname, '..'); | ||
| const {migrationPath, rcVersion} = createMigration({slug, coreDir}); | ||
|
|
||
| console.log(`Created migration: ${migrationPath}`); | ||
| if (rcVersion) { | ||
| console.log(`Bumped version to ${rcVersion}`); | ||
| } | ||
| } catch (err) { | ||
| console.error(err.message); | ||
| process.exit(1); | ||
| } | ||
| } | ||
|
|
||
| module.exports = {isValidSlug, getNextMigrationVersion, createMigration}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,204 @@ | ||
| const assert = require('assert/strict'); | ||
| const path = require('path'); | ||
| const fs = require('fs'); | ||
| const os = require('os'); | ||
|
|
||
| const {isValidSlug, getNextMigrationVersion, createMigration} = require('../../../bin/create-migration'); | ||
|
|
||
| describe('bin/create-migration', function () { | ||
| describe('isValidSlug', function () { | ||
| it('accepts valid kebab-case slugs', function () { | ||
| assert.ok(isValidSlug('add-column')); | ||
| assert.ok(isValidSlug('fix-index')); | ||
| assert.ok(isValidSlug('a')); | ||
| assert.ok(isValidSlug('add-mentions-table')); | ||
| assert.ok(isValidSlug('a1')); | ||
| assert.ok(isValidSlug('123')); | ||
| }); | ||
|
|
||
| it('rejects invalid slugs', function () { | ||
| assert.ok(!isValidSlug(undefined)); | ||
| assert.ok(!isValidSlug('')); | ||
| assert.ok(!isValidSlug('has spaces')); | ||
| assert.ok(!isValidSlug('UPPER')); | ||
| assert.ok(!isValidSlug('camelCase')); | ||
| assert.ok(!isValidSlug('has_underscores')); | ||
| assert.ok(!isValidSlug('-leading')); | ||
| assert.ok(!isValidSlug('trailing-')); | ||
| assert.ok(!isValidSlug('double--hyphen')); | ||
| assert.ok(!isValidSlug('special!chars')); | ||
| assert.ok(!isValidSlug('path/slash')); | ||
| }); | ||
| }); | ||
|
|
||
| describe('getNextMigrationVersion', function () { | ||
| it('increments minor for stable versions', function () { | ||
| assert.equal(getNextMigrationVersion('6.18.0'), '6.19'); | ||
| assert.equal(getNextMigrationVersion('5.75.0'), '5.76'); | ||
| assert.equal(getNextMigrationVersion('6.0.0'), '6.1'); | ||
| assert.equal(getNextMigrationVersion('7.12.3'), '7.13'); | ||
| }); | ||
|
|
||
| it('uses current minor for prerelease versions', function () { | ||
| assert.equal(getNextMigrationVersion('6.19.0-rc.0'), '6.19'); | ||
| assert.equal(getNextMigrationVersion('6.19.0-rc.1'), '6.19'); | ||
| assert.equal(getNextMigrationVersion('6.0.0-alpha.0'), '6.0'); | ||
| assert.equal(getNextMigrationVersion('5.75.0-beta.1'), '5.75'); | ||
| }); | ||
|
|
||
| it('stable and its RC target the same folder', function () { | ||
| assert.equal(getNextMigrationVersion('6.18.0'), '6.19'); | ||
| assert.equal(getNextMigrationVersion('6.19.0-rc.0'), '6.19'); | ||
| }); | ||
|
|
||
| it('throws for invalid versions', function () { | ||
| assert.throws(() => getNextMigrationVersion('not-a-version'), /Invalid version/); | ||
| }); | ||
| }); | ||
|
|
||
| describe('createMigration', function () { | ||
| let tmpDir; | ||
| let coreDir; | ||
|
|
||
| beforeEach(function () { | ||
| // Create a parent dir that mirrors the monorepo layout (core/ + admin/) | ||
| // so path.resolve(coreDir, '..', 'admin') stays inside the sandbox | ||
| tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ghost-migration-test-')); | ||
| coreDir = path.join(tmpDir, 'core'); | ||
|
|
||
| fs.mkdirSync(path.join(coreDir, 'core', 'server', 'data', 'migrations', 'versions'), {recursive: true}); | ||
| }); | ||
|
|
||
| afterEach(function () { | ||
| fs.rmSync(tmpDir, {recursive: true, force: true}); | ||
| }); | ||
|
|
||
| function writePackageJson(dir, version) { | ||
| fs.writeFileSync( | ||
| path.join(dir, 'package.json'), | ||
| JSON.stringify({name: 'ghost', version}, null, 2) + '\n' | ||
| ); | ||
| } | ||
|
|
||
| function readVersion(dir) { | ||
| return JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')).version; | ||
| } | ||
|
|
||
| it('creates a migration file in the correct version folder', function () { | ||
| writePackageJson(coreDir, '6.18.0'); | ||
|
|
||
| const result = createMigration({ | ||
| slug: 'add-column', | ||
| coreDir, | ||
| date: new Date('2026-02-23T10:30:00Z') | ||
| }); | ||
|
|
||
| assert.ok(fs.existsSync(result.migrationPath)); | ||
| assert.ok(result.migrationPath.includes(path.join('versions', '6.19'))); | ||
| assert.ok(result.migrationPath.endsWith('2026-02-23-10-30-00-add-column.js')); | ||
| }); | ||
|
|
||
| it('writes the migration template', function () { | ||
| writePackageJson(coreDir, '6.18.0'); | ||
|
|
||
| const {migrationPath} = createMigration({ | ||
| slug: 'test-migration', | ||
| coreDir, | ||
| date: new Date('2026-01-01T00:00:00Z') | ||
| }); | ||
|
|
||
| const content = fs.readFileSync(migrationPath, 'utf8'); | ||
| assert.ok(content.includes('require(\'@tryghost/logging\')')); | ||
| assert.ok(content.includes('createNonTransactionalMigration')); | ||
| assert.ok(content.includes('createTransactionalMigration')); | ||
| assert.ok(content.includes('module.exports = /**/;')); | ||
| }); | ||
|
|
||
| it('creates the version directory if it does not exist', function () { | ||
| writePackageJson(coreDir, '6.18.0'); | ||
|
|
||
| const versionDir = path.join(coreDir, 'core', 'server', 'data', 'migrations', 'versions', '6.19'); | ||
| assert.ok(!fs.existsSync(versionDir)); | ||
|
|
||
| createMigration({ | ||
| slug: 'new-folder', | ||
| coreDir, | ||
| date: new Date('2026-01-01T00:00:00Z') | ||
| }); | ||
|
|
||
| assert.ok(fs.existsSync(versionDir)); | ||
| }); | ||
|
|
||
| it('bumps to RC when current version is stable', function () { | ||
| writePackageJson(coreDir, '6.18.0'); | ||
|
|
||
| const {rcVersion} = createMigration({ | ||
| slug: 'first-migration', | ||
| coreDir, | ||
| date: new Date('2026-01-01T00:00:00Z') | ||
| }); | ||
|
|
||
| assert.equal(rcVersion, '6.19.0-rc.0'); | ||
| assert.equal(readVersion(coreDir), '6.19.0-rc.0'); | ||
| }); | ||
|
|
||
| it('bumps admin package.json when it exists', function () { | ||
| writePackageJson(coreDir, '6.18.0'); | ||
|
|
||
| const adminDir = path.join(tmpDir, 'admin'); | ||
| fs.mkdirSync(adminDir, {recursive: true}); | ||
| writePackageJson(adminDir, '6.18.0'); | ||
|
|
||
| createMigration({ | ||
| slug: 'with-admin', | ||
| coreDir, | ||
| date: new Date('2026-01-01T00:00:00Z') | ||
| }); | ||
|
|
||
| assert.equal(readVersion(adminDir), '6.19.0-rc.0'); | ||
| }); | ||
|
|
||
| it('does not bump when already a prerelease', function () { | ||
| writePackageJson(coreDir, '6.19.0-rc.0'); | ||
|
|
||
| const {rcVersion} = createMigration({ | ||
| slug: 'second-migration', | ||
| coreDir, | ||
| date: new Date('2026-01-01T00:00:00Z') | ||
| }); | ||
|
|
||
| assert.equal(rcVersion, null); | ||
| assert.equal(readVersion(coreDir), '6.19.0-rc.0'); | ||
| }); | ||
|
|
||
| it('places RC migrations in the same folder as stable', function () { | ||
| writePackageJson(coreDir, '6.19.0-rc.0'); | ||
|
|
||
| const result = createMigration({ | ||
| slug: 'rc-migration', | ||
| coreDir, | ||
| date: new Date('2026-01-01T00:00:00Z') | ||
| }); | ||
|
|
||
| assert.ok(result.migrationPath.includes(path.join('versions', '6.19'))); | ||
| }); | ||
|
|
||
| it('throws for invalid slug', function () { | ||
| writePackageJson(coreDir, '6.18.0'); | ||
|
|
||
| assert.throws( | ||
| () => createMigration({slug: 'INVALID', coreDir}), | ||
| /Invalid slug/ | ||
| ); | ||
| }); | ||
|
|
||
| it('throws for missing slug', function () { | ||
| writePackageJson(coreDir, '6.18.0'); | ||
|
|
||
| assert.throws( | ||
| () => createMigration({slug: undefined, coreDir}), | ||
| /Invalid slug/ | ||
| ); | ||
| }); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.