first commit
Copilot Setup Steps / copilot-setup-steps (push) Has been cancelled

This commit is contained in:
2026-04-22 19:51:20 +07:00
commit 93d1b7c3d3
579 changed files with 99797 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
module.exports = {
parserOptions: {
ecmaVersion: 2022
},
env: {
node: true,
es2022: true
}
};
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env node
'use strict';
const {execSync} = require('node:child_process');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '../..');
const REPO_URL = 'https://github.com/TryGhost/Ghost';
// Emoji priority order (lowest index = lowest priority, sorted descending)
const EMOJI_ORDER = ['💡', '🐛', '🎨', '💄', '✨', '🔒'];
// User-facing emojis — only these are included in release notes
const USER_FACING_EMOJIS = new Set(EMOJI_ORDER);
function getCommitLog(fromTag, toTag) {
const range = `${fromTag}..${toTag}`;
const format = '* %s - %an';
const cmd = `git log --no-merges --pretty=tformat:'${format}' ${range}`;
let log;
try {
log = execSync(cmd, {cwd: ROOT, encoding: 'utf8'}).trim();
} catch {
return [];
}
if (!log) {
return [];
}
return log.split('\n').map(line => line.trim());
}
function extractLeadingEmoji(line) {
// Line format: * <message> - <author>
const match = line.match(/^\* (.)/u);
return match ? match[1] : '';
}
function filterAndSortByEmoji(lines) {
const emojiLines = lines.filter((line) => {
const emoji = extractLeadingEmoji(line);
return USER_FACING_EMOJIS.has(emoji);
});
emojiLines.sort((a, b) => {
const emojiA = extractLeadingEmoji(a);
const emojiB = extractLeadingEmoji(b);
const indexA = EMOJI_ORDER.indexOf(emojiA);
const indexB = EMOJI_ORDER.indexOf(emojiB);
return indexB - indexA;
});
return emojiLines;
}
function generateReleaseNotes(fromTag, toTag) {
const lines = getCommitLog(fromTag, toTag);
const filtered = filterAndSortByEmoji(lines);
let body;
if (filtered.length === 0) {
body = 'This release contains fixes for minor bugs and issues reported by Ghost users.';
} else {
// Deduplicate (preserving order)
body = [...new Set(filtered)].join('\n');
}
body += `\n\n---\n\nView the changelog for full details: ${REPO_URL}/compare/${fromTag}...${toTag}`;
return body;
}
// CLI: node release-notes.js <from-tag> <to-tag>
if (require.main === module) {
const [fromTag, toTag] = process.argv.slice(2);
if (!fromTag || !toTag) {
console.error('Usage: node release-notes.js <from-tag> <to-tag>');
process.exit(1);
}
process.stdout.write(generateReleaseNotes(fromTag, toTag));
}
module.exports = {generateReleaseNotes};
+31
View File
@@ -0,0 +1,31 @@
const semver = require('semver');
const {execSync} = require('node:child_process');
/**
* Resolve the base git tag for diff/log comparisons during release preparation.
*
* For stable versions (e.g. "6.18.0"), returns "v6.18.0" — the tag for that version.
* For prerelease versions (e.g. "6.19.0-rc.0"), the tag "v6.19.0-rc.0" won't exist,
* so we find the most recent stable version tag in HEAD's ancestry using git describe.
*
* @param {string} version - The current Ghost version from package.json
* @param {string} repoDir - Path to the Ghost repo checkout
* @returns {{tag: string, isPrerelease: boolean}}
*/
function resolveBaseTag(version, repoDir) {
if (semver.prerelease(version)) {
const tag = execSync(
`git describe --tags --abbrev=0 --match 'v[0-9]*.[0-9]*.[0-9]*' --exclude 'v*-*' HEAD`,
{cwd: repoDir, encoding: 'utf8'}
).trim();
return {tag, isPrerelease: true};
}
return {
tag: `v${version}`,
isPrerelease: false
};
}
module.exports = {resolveBaseTag};
+326
View File
@@ -0,0 +1,326 @@
#!/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);
});
+179
View File
@@ -0,0 +1,179 @@
const {describe, it, before, after} = require('node:test');
const assert = require('node:assert');
const os = require('node:os');
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');
/**
* Create a temporary git repo with semver tags for testing.
* Returns the repo path. Caller is responsible for cleanup.
*/
function createTestRepo() {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ghost-release-test-'));
execSync([
`cd ${tmpDir}`,
'git init',
'git config user.email "test@test.com"',
'git config user.name "Test"',
// Initial commit + stable tag v6.17.0
'echo "initial" > file.txt',
'git add .',
'git commit -m "initial"',
'git tag v6.17.0',
// Second commit + stable tag v6.17.1
'echo "patch" >> file.txt',
'git add .',
'git commit -m "patch release"',
'git tag v6.17.1',
// Third commit (no tag — simulates post-release development)
'echo "new work" >> file.txt',
'git add .',
'git commit -m "post-release commit"'
].join(' && '));
return tmpDir;
}
describe('resolveBaseTag', () => {
let testRepo;
before(() => {
testRepo = createTestRepo();
});
after(() => {
fs.rmSync(testRepo, {recursive: true, force: true});
});
describe('semver.prerelease detection', () => {
it('returns null for stable versions', () => {
assert.strictEqual(semver.prerelease('6.17.1'), null);
assert.strictEqual(semver.prerelease('6.18.0'), null);
assert.strictEqual(semver.prerelease('5.0.0'), null);
});
it('returns prerelease components for rc versions', () => {
const result = semver.prerelease('6.19.0-rc.0');
assert.deepStrictEqual(result, ['rc', 0]);
});
it('returns prerelease components for alpha versions', () => {
const result = semver.prerelease('7.0.0-alpha.1');
assert.deepStrictEqual(result, ['alpha', 1]);
});
});
describe('stable version path', () => {
it('constructs tag directly from version', () => {
// Stable versions don't hit git — the tag is just v{version}
const {tag, isPrerelease} = resolveBaseTag('6.17.1', '/nonexistent');
assert.strictEqual(tag, 'v6.17.1');
assert.strictEqual(isPrerelease, false);
});
it('works for any stable version format', () => {
const {tag} = resolveBaseTag('5.0.0', '/nonexistent');
assert.strictEqual(tag, 'v5.0.0');
});
});
describe('prerelease version path', () => {
it('resolves to the latest stable tag', () => {
const {tag, isPrerelease} = resolveBaseTag('6.19.0-rc.0', testRepo);
assert.strictEqual(isPrerelease, true);
assert.strictEqual(tag, 'v6.17.1');
});
it('resolved tag is a stable version (no prerelease suffix)', () => {
const {tag} = resolveBaseTag('6.19.0-rc.0', testRepo);
assert.match(tag, /^v\d+\.\d+\.\d+$/);
assert.strictEqual(semver.prerelease(tag.slice(1)), null);
});
it('resolved tag is a valid git ref', () => {
const {tag} = resolveBaseTag('6.19.0-rc.0', testRepo);
const sha = execSync(`cd ${testRepo} && git rev-parse ${tag}`, {encoding: 'utf8'});
assert.ok(sha.trim().length > 0, 'Tag should resolve to a commit SHA');
});
it('git diff succeeds with the resolved tag', () => {
const {tag} = resolveBaseTag('6.19.0-rc.0', testRepo);
const result = execSync(
`cd ${testRepo} && git diff --diff-filter=A --name-only ${tag} HEAD`,
{encoding: 'utf8'}
);
assert.strictEqual(typeof result, 'string');
});
it('git log succeeds with the resolved tag', () => {
const {tag} = resolveBaseTag('6.19.0-rc.0', testRepo);
const result = execSync(
`cd ${testRepo} && git log --oneline ${tag}..HEAD`,
{encoding: 'utf8'}
);
assert.strictEqual(typeof result, 'string');
// Should have at least 1 commit (the post-release commit)
assert.ok(result.trim().length > 0, 'Should find commits after the tag');
});
});
describe('prerelease excludes prerelease tags', () => {
let repoWithPrereleaseTag;
before(() => {
// Create a repo that has both stable and prerelease tags
repoWithPrereleaseTag = fs.mkdtempSync(path.join(os.tmpdir(), 'ghost-release-test-pre-'));
execSync([
`cd ${repoWithPrereleaseTag}`,
'git init',
'git config user.email "test@test.com"',
'git config user.name "Test"',
'echo "initial" > file.txt',
'git add .',
'git commit -m "initial"',
'git tag v6.17.0',
'echo "alpha" >> file.txt',
'git add .',
'git commit -m "alpha work"',
'git tag v7.0.0-alpha.0',
'echo "more" >> file.txt',
'git add .',
'git commit -m "more work"'
].join(' && '));
});
after(() => {
fs.rmSync(repoWithPrereleaseTag, {recursive: true, force: true});
});
it('skips prerelease tags and finds the stable one', () => {
const {tag} = resolveBaseTag('7.0.0-rc.0', repoWithPrereleaseTag);
// Should find v6.17.0 (stable), NOT v7.0.0-alpha.0 (prerelease)
assert.strictEqual(tag, 'v6.17.0');
});
});
describe('semver.inc compatibility', () => {
it('patch of an rc produces the stable release', () => {
assert.strictEqual(semver.inc('6.19.0-rc.0', 'patch'), '6.19.0');
});
it('minor of an rc produces the stable release (not next minor)', () => {
assert.strictEqual(semver.inc('6.19.0-rc.0', 'minor'), '6.19.0');
});
it('prerelease rc of an rc increments the rc number', () => {
assert.strictEqual(semver.inc('6.19.0-rc.0', 'prerelease', 'rc'), '6.19.0-rc.1');
});
});
});