#!/usr/bin/env node 'use strict'; const path = require('node:path'); const fs = require('node:fs'); const {execSync} = require('node:child_process'); const semver = require('semver'); const {resolveBaseTag} = require('./lib/resolve-base-tag'); const ROOT = path.resolve(__dirname, '..'); const GHOST_CORE_PKG = path.join(ROOT, 'ghost/core/package.json'); const GHOST_ADMIN_PKG = path.join(ROOT, 'ghost/admin/package.json'); const CASPER_DIR = path.join(ROOT, 'ghost/core/content/themes/casper'); const SOURCE_DIR = path.join(ROOT, 'ghost/core/content/themes/source'); const MAX_WAIT_MS = 30 * 60 * 1000; // 30 minutes const POLL_INTERVAL_MS = 30 * 1000; // 30 seconds // --- Argument parsing --- function parseArgs() { const args = process.argv.slice(2); const opts = { bumpType: 'auto', branch: 'main', dryRun: false, skipChecks: false }; for (const arg of args) { if (arg.startsWith('--bump-type=')) { opts.bumpType = arg.split('=')[1]; } else if (arg.startsWith('--branch=')) { opts.branch = arg.split('=')[1]; } else if (arg === '--dry-run') { opts.dryRun = true; } else if (arg === '--skip-checks') { opts.skipChecks = true; } else { console.error(`Unknown argument: ${arg}`); process.exit(1); } } return opts; } // --- Helpers --- function run(cmd, opts = {}) { const result = execSync(cmd, {cwd: ROOT, encoding: 'utf8', ...opts}); return result.trim(); } function readPkgVersion(pkgPath) { return JSON.parse(fs.readFileSync(pkgPath, 'utf8')).version; } function writePkgVersion(pkgPath, version) { const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); pkg.version = version; fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n'); } function log(msg) { console.log(` ${msg}`); } function logStep(msg) { console.log(`\nā–ø ${msg}`); } // --- Version detection --- function detectBumpType(baseTag, bumpType) { // Check for new migration files const migrationsPath = 'ghost/core/core/server/data/migrations/versions/'; try { const addedFiles = run(`git diff --diff-filter=A --name-only ${baseTag} HEAD -- ${migrationsPath}`); if (addedFiles?.includes('core/')) { log('New migrations detected'); if (bumpType === 'auto') { log('Auto-detecting: bumping to minor'); bumpType = 'minor'; } } else { log('No new migrations detected'); } } catch { log('Warning: could not diff migrations'); } // Check for feature commits (✨ or šŸŽ‰) try { const commits = run(`git log --oneline ${baseTag}..HEAD`); if (commits) { const featureCommits = commits.split('\n').filter(c => c.includes('✨') || c.includes('šŸŽ‰') || c.includes(':sparkles:')); if (featureCommits.length) { log(`Feature commits detected (${featureCommits.length})`); if (bumpType === 'auto') { log('Auto-detecting: bumping to minor'); bumpType = 'minor'; } } else { log('No feature commits detected'); } } else { log('No commits since base tag'); } } catch { log('Warning: could not read commit log'); } if (bumpType === 'auto') { log('Defaulting to patch'); bumpType = 'patch'; } return bumpType; } // --- CI check polling --- const REQUIRED_CHECK_NAME = 'All required tests passed or skipped'; async function waitForChecks(commit) { logStep(`Waiting for CI checks on ${commit.slice(0, 8)}...`); const token = process.env.GITHUB_TOKEN || process.env.RELEASE_TOKEN; if (!token) { throw new Error('GITHUB_TOKEN or RELEASE_TOKEN required for check polling'); } const startTime = Date.now(); while (true) { // eslint-disable-line no-constant-condition const response = await fetch(`https://api.github.com/repos/TryGhost/Ghost/commits/${commit}/check-runs`, { headers: { Authorization: `token ${token}`, Accept: 'application/vnd.github+json' } }); if (!response.ok) { throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); } const {check_runs: checkRuns} = await response.json(); // Find the required check — this is the CI gate that aggregates all mandatory checks const requiredCheck = checkRuns.find(r => r.name === REQUIRED_CHECK_NAME); if (requiredCheck) { if (requiredCheck.status === 'completed' && requiredCheck.conclusion === 'success') { log(`Required check "${REQUIRED_CHECK_NAME}" passed`); return; } if (requiredCheck.status === 'completed' && requiredCheck.conclusion !== 'success') { throw new Error(`Required check "${REQUIRED_CHECK_NAME}" failed (${requiredCheck.conclusion})`); } log(`Required check is ${requiredCheck.status}, waiting...`); } else { log('Required check not found yet, waiting...'); } const elapsedMs = Date.now() - startTime; const elapsed = Math.round(elapsedMs / 1000); if (elapsedMs >= MAX_WAIT_MS) { throw new Error(`Timed out waiting for "${REQUIRED_CHECK_NAME}" after ${elapsed}s`); } log(`(${elapsed}s elapsed), polling in 30s...`); await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); } } // --- Theme submodule updates --- function updateThemeSubmodule(themeDir, themeName) { if (!fs.existsSync(themeDir)) { log(`${themeName} not present, skipping`); return false; } const currentPkg = JSON.parse(fs.readFileSync(path.join(themeDir, 'package.json'), 'utf8')); const currentVersion = currentPkg.version; // Checkout latest stable tag on main branch try { execSync( `git checkout $(git describe --abbrev=0 --tags $(git rev-list --tags --max-count=1 --branches=main))`, {cwd: themeDir, encoding: 'utf8', stdio: 'pipe'} ); } catch (err) { log(`Warning: failed to update ${themeName}: ${err.message}`); return false; } const updatedPkg = JSON.parse(fs.readFileSync(path.join(themeDir, 'package.json'), 'utf8')); const newVersion = updatedPkg.version; if (semver.gt(newVersion, currentVersion)) { log(`${themeName} updated: v${currentVersion} → v${newVersion}`); run(`git add -f ${path.relative(ROOT, themeDir)}`); run(`git commit -m "šŸŽØ Updated ${themeName} to v${newVersion}"`); return true; } log(`${themeName} already at latest (v${currentVersion})`); return false; } // --- Main --- async function main() { const opts = parseArgs(); console.log('Ghost Release Script'); console.log('===================='); log(`Branch: ${opts.branch}`); log(`Bump type: ${opts.bumpType}`); log(`Dry run: ${opts.dryRun}`); // 1. Read current version logStep('Reading current version'); const currentVersion = readPkgVersion(GHOST_CORE_PKG); log(`Current version: ${currentVersion}`); // 2. Resolve base tag logStep('Resolving base tag'); const {tag: baseTag, isPrerelease} = resolveBaseTag(currentVersion, ROOT); if (isPrerelease) { log(`Prerelease detected (${currentVersion}), resolved base tag: ${baseTag}`); } else { log(`Base tag: ${baseTag}`); } // 3. Detect bump type logStep('Detecting bump type'); const resolvedBumpType = detectBumpType(baseTag, opts.bumpType); const newVersion = semver.inc(currentVersion, resolvedBumpType); if (!newVersion) { console.error(`Failed to calculate new version from ${currentVersion} with bump type ${resolvedBumpType}`); process.exit(1); } log(`Bump type: ${resolvedBumpType}`); log(`New version: ${newVersion}`); // 4. Check tag doesn't exist logStep('Checking remote tags'); try { const tagCheck = run(`git ls-remote --tags origin refs/tags/v${newVersion}`); if (tagCheck) { console.error(`Tag v${newVersion} already exists on remote. Cannot release this version.`); process.exit(1); } } catch { // ls-remote returns non-zero if no match — that's what we want } log(`Tag v${newVersion} does not exist on remote`); // 5. Wait for CI checks if (!opts.skipChecks) { const headSha = run('git rev-parse HEAD'); await waitForChecks(headSha); } else { log('Skipping CI checks'); } // 6. Update theme submodules (main branch only) if (opts.branch === 'main') { logStep('Updating theme submodules'); run('git submodule update --init'); updateThemeSubmodule(CASPER_DIR, 'Casper'); updateThemeSubmodule(SOURCE_DIR, 'Source'); } else { logStep('Skipping theme updates (not main branch)'); } // 7. Bump versions logStep(`Bumping version to ${newVersion}`); writePkgVersion(GHOST_CORE_PKG, newVersion); writePkgVersion(GHOST_ADMIN_PKG, newVersion); // 8. Commit and tag run(`git add ${path.relative(ROOT, GHOST_CORE_PKG)} ${path.relative(ROOT, GHOST_ADMIN_PKG)}`); run(`git commit -m "v${newVersion}"`); run(`git tag v${newVersion}`); log(`Created tag v${newVersion}`); // 9. Push if (opts.dryRun) { logStep('DRY RUN — skipping push'); log(`Would push branch ${opts.branch} and tag v${newVersion}`); } else { logStep('Pushing'); run('git push origin HEAD'); run(`git push origin v${newVersion}`); log('Pushed branch and tag'); } // 10. Advance to next RC logStep('Advancing to next RC'); const nextMinor = semver.inc(newVersion, 'minor'); const nextRc = `${nextMinor}-rc.0`; log(`Next RC: ${nextRc}`); writePkgVersion(GHOST_CORE_PKG, nextRc); writePkgVersion(GHOST_ADMIN_PKG, nextRc); run(`git add ${path.relative(ROOT, GHOST_CORE_PKG)} ${path.relative(ROOT, GHOST_ADMIN_PKG)}`); run(`git commit -m "Bumped version to ${nextRc}"`); if (opts.dryRun) { log('DRY RUN — skipping RC push'); } else { run('git push origin HEAD'); log('Pushed RC version'); } console.log(`\nāœ“ Release ${newVersion} complete`); } main().catch((err) => { console.error(`\nāœ— Release failed: ${err.message}`); process.exit(1); });