Files
mygit/scripts/release.js
T
DuckQ1u 93d1b7c3d3
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled
first commit
2026-04-22 19:51:20 +07:00

327 lines
10 KiB
JavaScript

#!/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);
});