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
+44
View File
@@ -0,0 +1,44 @@
const fs = require('fs/promises');
const exec = require('util').promisify(require('child_process').exec);
const path = require('path');
const semver = require('semver');
(async () => {
const core = await import('@actions/core');
const corePackageJsonPath = path.join(__dirname, '../../ghost/core/package.json');
const corePackageJson = require(corePackageJsonPath);
const current_version = corePackageJson.version;
console.log(`Current version: ${current_version}`);
const firstArg = process.argv[2];
console.log('firstArg', firstArg);
const buildString = await exec('git rev-parse --short HEAD').then(({stdout}) => stdout.trim());
let newVersion;
if (firstArg === 'canary' || firstArg === 'six') {
const bumpedVersion = semver.inc(current_version, 'minor');
newVersion = `${bumpedVersion}-pre-g${buildString}`;
} else {
newVersion = `${current_version}-0-g${buildString}`;
}
newVersion += '+moya';
console.log('newVersion', newVersion);
corePackageJson.version = newVersion;
await fs.writeFile(corePackageJsonPath, JSON.stringify(corePackageJson, null, 2));
const adminPackageJsonPath = path.join(__dirname, '../../ghost/admin/package.json');
const adminPackageJson = require(adminPackageJsonPath);
adminPackageJson.version = newVersion;
await fs.writeFile(adminPackageJsonPath, JSON.stringify(adminPackageJson, null, 2));
console.log('Version bumped to', newVersion);
core.setOutput('BUILD_VERSION', newVersion);
core.setOutput('GIT_COMMIT_HASH', buildString);
})();
+256
View File
@@ -0,0 +1,256 @@
const fs = require('fs');
const path = require('path');
const execFileSync = require('child_process').execFileSync;
const MONITORED_APPS = {
portal: {
packageName: '@tryghost/portal',
path: 'apps/portal'
},
sodoSearch: {
packageName: '@tryghost/sodo-search',
path: 'apps/sodo-search'
},
comments: {
packageName: '@tryghost/comments-ui',
path: 'apps/comments-ui'
},
announcementBar: {
packageName: '@tryghost/announcement-bar',
path: 'apps/announcement-bar'
},
signupForm: {
packageName: '@tryghost/signup-form',
path: 'apps/signup-form'
}
};
const MONITORED_APP_ENTRIES = Object.entries(MONITORED_APPS);
const MONITORED_APP_PATHS = MONITORED_APP_ENTRIES.map(([, app]) => app.path);
function runGit(args) {
try {
return execFileSync('git', args, {encoding: 'utf8'}).trim();
} catch (error) {
const stderr = error.stderr ? error.stderr.toString().trim() : '';
const stdout = error.stdout ? error.stdout.toString().trim() : '';
const message = stderr || stdout || error.message;
throw new Error(`Failed to run "git ${args.join(' ')}": ${message}`);
}
}
function readVersionFromPackageJson(packageJsonContent, sourceLabel) {
let parsedPackageJson;
try {
parsedPackageJson = JSON.parse(packageJsonContent);
} catch (error) {
throw new Error(`Unable to parse ${sourceLabel}: ${error.message}`);
}
if (!parsedPackageJson.version || typeof parsedPackageJson.version !== 'string') {
throw new Error(`${sourceLabel} does not contain a valid "version" field`);
}
return parsedPackageJson.version;
}
function parseSemver(version) {
const match = version.match(/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/);
if (!match) {
throw new Error(`Invalid semver version "${version}"`);
}
const prerelease = match[4] ? match[4].split('.').map((identifier) => {
if (/^\d+$/.test(identifier)) {
return Number(identifier);
}
return identifier;
}) : [];
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
prerelease
};
}
function comparePrereleaseIdentifier(a, b) {
const isANumber = typeof a === 'number';
const isBNumber = typeof b === 'number';
if (isANumber && isBNumber) {
if (a === b) {
return 0;
}
return a > b ? 1 : -1;
}
if (isANumber) {
return -1;
}
if (isBNumber) {
return 1;
}
if (a === b) {
return 0;
}
return a > b ? 1 : -1;
}
function compareSemver(a, b) {
const aVersion = parseSemver(a);
const bVersion = parseSemver(b);
if (aVersion.major !== bVersion.major) {
return aVersion.major > bVersion.major ? 1 : -1;
}
if (aVersion.minor !== bVersion.minor) {
return aVersion.minor > bVersion.minor ? 1 : -1;
}
if (aVersion.patch !== bVersion.patch) {
return aVersion.patch > bVersion.patch ? 1 : -1;
}
const aPrerelease = aVersion.prerelease;
const bPrerelease = bVersion.prerelease;
if (!aPrerelease.length && !bPrerelease.length) {
return 0;
}
if (!aPrerelease.length) {
return 1;
}
if (!bPrerelease.length) {
return -1;
}
const maxLength = Math.max(aPrerelease.length, bPrerelease.length);
for (let i = 0; i < maxLength; i += 1) {
const aIdentifier = aPrerelease[i];
const bIdentifier = bPrerelease[i];
if (aIdentifier === undefined) {
return -1;
}
if (bIdentifier === undefined) {
return 1;
}
const identifierComparison = comparePrereleaseIdentifier(aIdentifier, bIdentifier);
if (identifierComparison !== 0) {
return identifierComparison;
}
}
return 0;
}
function getChangedFiles(baseSha, compareSha) {
let mergeBaseSha;
try {
mergeBaseSha = runGit(['merge-base', baseSha, compareSha]);
} catch (error) {
throw new Error(`Unable to determine merge-base for ${baseSha} and ${compareSha}. Ensure the base branch history is available in the checkout.\n${error.message}`);
}
return runGit(['diff', '--name-only', mergeBaseSha, compareSha, '--', ...MONITORED_APP_PATHS])
.split('\n')
.map(file => file.trim())
.filter(Boolean);
}
function getChangedApps(changedFiles) {
return MONITORED_APP_ENTRIES
.filter(([, app]) => {
return changedFiles.some((file) => {
return file === app.path || file.startsWith(`${app.path}/`);
});
})
.map(([key, app]) => ({key, ...app}));
}
function getPrVersion(app) {
const packageJsonPath = path.resolve(__dirname, `../../${app.path}/package.json`);
if (!fs.existsSync(packageJsonPath)) {
throw new Error(`${app.path}/package.json does not exist in this PR`);
}
return readVersionFromPackageJson(
fs.readFileSync(packageJsonPath, 'utf8'),
`${app.path}/package.json from PR`
);
}
function getMainVersion(app) {
return readVersionFromPackageJson(
runGit(['show', `origin/main:${app.path}/package.json`]),
`${app.path}/package.json from main`
);
}
function main() {
const baseSha = process.env.PR_BASE_SHA;
const compareSha = process.env.PR_COMPARE_SHA || process.env.GITHUB_SHA;
if (!baseSha) {
throw new Error('Missing PR_BASE_SHA environment variable');
}
if (!compareSha) {
throw new Error('Missing PR_COMPARE_SHA/GITHUB_SHA environment variable');
}
const changedFiles = getChangedFiles(baseSha, compareSha);
const changedApps = getChangedApps(changedFiles);
if (changedApps.length === 0) {
console.log(`No app changes detected. Skipping version bump check.`);
return;
}
console.log(`Checking version bump for apps: ${changedApps.map(app => app.key).join(', ')}`);
const failedApps = [];
for (const app of changedApps) {
const prVersion = getPrVersion(app);
const mainVersion = getMainVersion(app);
if (compareSemver(prVersion, mainVersion) <= 0) {
failedApps.push(
`${app.key} (${app.packageName}) was changed but version was not bumped above main (${prVersion} <= ${mainVersion}). Please run "pnpm ship" in ${app.path} to bump the package version.`
);
continue;
}
console.log(`${app.key} version bump check passed (${prVersion} > ${mainVersion})`);
}
if (failedApps.length) {
throw new Error(`Version bump checks failed:\n- ${failedApps.join('\n- ')}`);
}
console.log('All monitored app version bump checks passed.');
}
try {
main();
} catch (error) {
console.error(error.message);
process.exit(1);
}
+44
View File
@@ -0,0 +1,44 @@
// NOTE: this file can't use any NPM dependencies because it needs to run even if dependencies aren't installed yet or are corrupted
const {execSync} = require('child_process');
resetNxCache();
deleteNodeModules();
deleteBuildArtifacts();
console.log('Cleanup complete!');
function deleteBuildArtifacts() {
console.log('Deleting all build artifacts...');
try {
execSync('find ./ghost -type d -name "build" -exec rm -rf \'{}\' +', {
stdio: 'inherit'
});
execSync('find ./ghost -type f -name "tsconfig.tsbuildinfo" -delete', {
stdio: 'inherit'
});
} catch (error) {
console.error('Failed to delete build artifacts:', error);
process.exit(1);
}
}
function deleteNodeModules() {
console.log('Deleting all node_modules directories...');
try {
execSync('find . -name "node_modules" -type d -prune -exec rm -rf \'{}\' +', {
stdio: 'inherit'
});
} catch (error) {
console.error('Failed to delete node_modules directories:', error);
process.exit(1);
}
}
function resetNxCache() {
console.log('Resetting NX cache...');
try {
execSync('rm -rf .nxcache .nx');
} catch (error) {
console.error('Failed to reset NX cache:', error);
process.exit(1);
}
}
+710
View File
@@ -0,0 +1,710 @@
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const jsonc = require('jsonc-parser');
const { execSync } = require('child_process');
/**
* Parse pnpm outdated --json output into an array of
* [packageName, current, wanted, latest, dependencyType] tuples.
*
* pnpm's JSON output is an object keyed by package name:
* { "pkg": { "wanted": "1.0.1", "latest": "2.0.0", "dependencyType": "dependencies" } }
*
* pnpm's JSON output does not include a "current" field — "wanted"
* represents the lockfile-resolved version, so we use it as current.
*/
function parsePnpmOutdatedOutput(stdout) {
if (!stdout || !stdout.trim()) {
return [];
}
const data = JSON.parse(stdout);
return Object.entries(data).map(([name, info]) => [
name,
info.wanted,
info.wanted,
info.latest,
info.dependencyType
]);
}
/**
* Smart lockfile drift detector that focuses on actionable updates
* and avoids API rate limits by using pnpm's built-in commands where possible
*/
class LockfileDriftDetector {
constructor() {
this.workspaces = [];
this.directDeps = new Map();
this.outdatedInfo = [];
this.workspaceStats = new Map();
this.workspaceDepsCount = new Map();
this.ignoredWorkspaceDeps = new Set();
this.renovateIgnoredDeps = new Set();
// Parse command line arguments
this.args = process.argv.slice(2);
this.filterSeverity = null;
// Check for severity filters
if (this.args.includes('--patch')) {
this.filterSeverity = 'patch';
} else if (this.args.includes('--minor')) {
this.filterSeverity = 'minor';
} else if (this.args.includes('--major')) {
this.filterSeverity = 'major';
}
// Check for help flag
if (this.args.includes('--help') || this.args.includes('-h')) {
this.showHelp();
process.exit(0);
}
}
/**
* Show help message
*/
showHelp() {
console.log(`
Dependency Inspector - Smart lockfile drift detector
Usage: dependency-inspector.js [options]
Options:
--patch Show all packages with patch updates
--minor Show all packages with minor updates
--major Show all packages with major updates
--help, -h Show this help message
Without flags, shows high-priority updates sorted by impact.
With a severity flag, shows all packages with that update type.
`);
}
/**
* Load ignored dependencies from renovate configuration
*/
loadRenovateConfig() {
console.log('🔧 Loading renovate configuration...');
try {
// Read renovate.json from project root (two levels up from .github/scripts/)
const renovateConfigPath = path.join(__dirname, '../../.github/renovate.json5');
const renovateConfig = jsonc.parse(fs.readFileSync(renovateConfigPath, 'utf8'));
if (renovateConfig.ignoreDeps) {
for (const dep of renovateConfig.ignoreDeps) {
this.renovateIgnoredDeps.add(dep);
}
console.log(`📝 Loaded ${renovateConfig.ignoreDeps.length} ignored dependencies from renovate.json`);
console.log(` Ignored: ${Array.from(this.renovateIgnoredDeps).join(', ')}`);
} else {
console.log('📝 No ignoreDeps found in renovate.json');
}
} catch (error) {
console.warn('⚠️ Could not load renovate.json:', error.message);
}
}
/**
* Get all workspace package.json files
*/
async findWorkspaces() {
// Read from project root (two levels up from .github/scripts/)
const rootDir = path.join(__dirname, '../..');
const rootPackage = JSON.parse(fs.readFileSync(path.join(rootDir, 'package.json'), 'utf8'));
// Read workspace patterns from pnpm-workspace.yaml (primary) or package.json (fallback)
let workspacePatterns = [];
const pnpmWorkspacePath = path.join(rootDir, 'pnpm-workspace.yaml');
if (fs.existsSync(pnpmWorkspacePath)) {
const content = fs.readFileSync(pnpmWorkspacePath, 'utf8');
let inPackages = false;
for (const line of content.split('\n')) {
if (/^packages:/.test(line)) {
inPackages = true;
continue;
}
if (inPackages) {
const match = line.match(/^\s+-\s+['"]?([^'"]+)['"]?\s*$/);
if (match) {
workspacePatterns.push(match[1]);
} else if (/^\S/.test(line)) {
break;
}
}
}
} else {
workspacePatterns = rootPackage.workspaces || [];
}
console.log('📦 Scanning workspaces...');
// Add root package
this.workspaces.push({
name: rootPackage.name || 'root',
path: '.',
packageJson: rootPackage
});
// Find workspace packages
for (const pattern of workspacePatterns) {
const globPattern = path.join(rootDir, pattern.replace(/\*$/, ''));
try {
const dirs = fs.readdirSync(globPattern, { withFileTypes: true })
.filter(dirent => dirent.isDirectory())
.map(dirent => path.join(globPattern, dirent.name));
for (const dir of dirs) {
const packageJsonPath = path.join(dir, 'package.json');
if (fs.existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
// Skip ghost/admin directory but track its dependencies for filtering
if (path.basename(dir) === 'admin' && dir.includes('ghost')) {
console.log(`🚫 Ignoring ghost/admin workspace (tracking deps for filtering)`);
const deps = {
...packageJson.dependencies,
...packageJson.devDependencies,
...packageJson.peerDependencies,
...packageJson.optionalDependencies
};
// Add all ghost/admin dependencies to ignore list
for (const depName of Object.keys(deps || {})) {
this.ignoredWorkspaceDeps.add(depName);
}
continue;
}
this.workspaces.push({
name: packageJson.name || path.basename(dir),
path: dir,
packageJson
});
} catch (e) {
console.warn(`⚠️ Skipped ${packageJsonPath}: ${e.message}`);
}
}
}
} catch (e) {
console.warn(`⚠️ Skipped pattern ${pattern}: ${e.message}`);
}
}
console.log(`Found ${this.workspaces.length} workspaces`);
return this.workspaces;
}
/**
* Extract all direct dependencies from workspaces
*/
extractDirectDependencies() {
console.log('🔍 Extracting direct dependencies...');
for (const workspace of this.workspaces) {
const { packageJson } = workspace;
const deps = {
...packageJson.dependencies,
...packageJson.devDependencies,
...packageJson.peerDependencies,
...packageJson.optionalDependencies
};
// Count total dependencies for this workspace
const totalDepsForWorkspace = Object.keys(deps || {}).length;
this.workspaceDepsCount.set(workspace.name, totalDepsForWorkspace);
for (const [name, range] of Object.entries(deps || {})) {
if (!this.directDeps.has(name)) {
this.directDeps.set(name, new Set());
}
this.directDeps.get(name).add({
workspace: workspace.name,
range,
path: workspace.path
});
}
}
return this.directDeps;
}
/**
* Use pnpm outdated to get comprehensive outdated info
* This is much faster and more reliable than manual API calls
*/
async getOutdatedPackages() {
console.log('🔄 Running pnpm outdated (this may take a moment)...');
let stdout;
try {
stdout = execSync('pnpm outdated --json', {
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024 // 10MB buffer for large output
});
} catch (error) {
// pnpm outdated exits with code 1 when there are outdated packages
if (error.status === 1 && error.stdout) {
stdout = error.stdout;
} else {
console.error('Failed to run pnpm outdated:', error.message);
return [];
}
}
return parsePnpmOutdatedOutput(stdout);
}
/**
* Analyze the severity of version differences
*/
analyzeVersionDrift(current, wanted, latest) {
const parseVersion = (v) => {
const match = v.match(/(\d+)\.(\d+)\.(\d+)/);
if (!match) return { major: 0, minor: 0, patch: 0 };
return {
major: parseInt(match[1]),
minor: parseInt(match[2]),
patch: parseInt(match[3])
};
};
const currentVer = parseVersion(current);
const latestVer = parseVersion(latest);
const majorDiff = latestVer.major - currentVer.major;
const minorDiff = latestVer.minor - currentVer.minor;
const patchDiff = latestVer.patch - currentVer.patch;
let severity = 'patch';
let score = patchDiff;
if (majorDiff > 0) {
severity = 'major';
score = majorDiff * 1000 + minorDiff * 100 + patchDiff;
} else if (minorDiff > 0) {
severity = 'minor';
score = minorDiff * 100 + patchDiff;
}
return { severity, score, majorDiff, minorDiff, patchDiff };
}
/**
* Process and categorize outdated packages
*/
processOutdatedPackages(outdatedData) {
console.log('📊 Processing outdated package information...');
// Initialize workspace stats
for (const workspace of this.workspaces) {
this.workspaceStats.set(workspace.name, {
total: 0,
major: 0,
minor: 0,
patch: 0,
packages: [],
outdatedPackageNames: new Set() // Track unique package names per workspace
});
}
const results = {
direct: [],
transitive: [],
stats: {
total: 0,
major: 0,
minor: 0,
patch: 0
}
};
for (const [packageName, current, wanted, latest, packageType] of outdatedData) {
const isDirect = this.directDeps.has(packageName);
// Skip packages that are only used by ignored workspaces (like ghost/admin)
if (!isDirect && this.ignoredWorkspaceDeps.has(packageName)) {
continue;
}
// Skip packages that are ignored by renovate configuration
if (this.renovateIgnoredDeps.has(packageName)) {
continue;
}
const analysis = this.analyzeVersionDrift(current, wanted, latest);
const packageInfo = {
name: packageName,
current,
wanted,
latest,
type: packageType || 'dependencies',
isDirect,
...analysis,
workspaces: isDirect ? Array.from(this.directDeps.get(packageName)) : []
};
// Update workspace statistics for direct dependencies
if (isDirect) {
for (const workspaceInfo of packageInfo.workspaces) {
const stats = this.workspaceStats.get(workspaceInfo.workspace);
if (stats && !stats.outdatedPackageNames.has(packageName)) {
// Only count each package once per workspace
stats.outdatedPackageNames.add(packageName);
stats.total++;
stats[analysis.severity]++;
stats.packages.push({
name: packageName,
current,
latest,
severity: analysis.severity
});
}
}
results.direct.push(packageInfo);
} else {
results.transitive.push(packageInfo);
}
results.stats.total++;
results.stats[analysis.severity]++;
}
// Deduplicate direct dependencies and count workspace impact
const directDepsMap = new Map();
for (const pkg of results.direct) {
if (!directDepsMap.has(pkg.name)) {
directDepsMap.set(pkg.name, {
...pkg,
workspaceCount: pkg.workspaces.length,
impact: pkg.workspaces.length // Number of workspaces affected
});
}
}
// Sort by impact: workspace count first, then severity, then score
const sortByImpact = (a, b) => {
// First by number of workspaces (more workspaces = higher priority)
if (a.impact !== b.impact) {
return b.impact - a.impact;
}
// Then by severity
if (a.severity !== b.severity) {
const severityOrder = { major: 3, minor: 2, patch: 1 };
return severityOrder[b.severity] - severityOrder[a.severity];
}
// Finally by version drift score
return b.score - a.score;
};
results.direct = Array.from(directDepsMap.values()).sort(sortByImpact);
results.transitive.sort((a, b) => {
if (a.severity !== b.severity) {
const severityOrder = { major: 3, minor: 2, patch: 1 };
return severityOrder[b.severity] - severityOrder[a.severity];
}
return b.score - a.score;
});
return results;
}
/**
* Display filtered results by severity
*/
displayFilteredResults(results) {
const severityEmoji = {
major: '🔴',
minor: '🟡',
patch: '🟢'
};
const emoji = severityEmoji[this.filterSeverity];
const filterTitle = this.filterSeverity.toUpperCase();
console.log(`${emoji} ${filterTitle} UPDATES ONLY:\n`);
// Filter direct dependencies
const filteredDirect = results.direct.filter(pkg => pkg.severity === this.filterSeverity);
const filteredTransitive = results.transitive.filter(pkg => pkg.severity === this.filterSeverity);
console.log(`Found ${filteredDirect.length} direct and ${filteredTransitive.length} transitive ${this.filterSeverity} updates.\n`);
if (filteredDirect.length > 0) {
console.log('📦 DIRECT DEPENDENCIES:');
console.log('─'.repeat(80));
// Sort by workspace impact, then by package name
filteredDirect.sort((a, b) => {
if (a.impact !== b.impact) {
return b.impact - a.impact;
}
return a.name.localeCompare(b.name);
});
for (const pkg of filteredDirect) {
const workspaceList = pkg.workspaces.map(w => w.workspace).join(', ');
const impactNote = pkg.workspaceCount > 1 ? ` (${pkg.workspaceCount} workspaces)` : '';
console.log(` ${emoji} ${pkg.name}: ${pkg.current}${pkg.latest}${impactNote}`);
console.log(` Workspaces: ${workspaceList}`);
}
console.log('\n🚀 UPDATE COMMANDS:');
console.log('─'.repeat(80));
for (const pkg of filteredDirect) {
console.log(` pnpm update ${pkg.name}@latest`);
}
}
if (filteredTransitive.length > 0) {
console.log('\n\n🔄 TRANSITIVE DEPENDENCIES:');
console.log('─'.repeat(80));
console.log(' These will likely be updated automatically when you update direct deps.\n');
// Sort by package name for easier scanning
filteredTransitive.sort((a, b) => a.name.localeCompare(b.name));
for (const pkg of filteredTransitive) {
console.log(` ${emoji} ${pkg.name}: ${pkg.current}${pkg.latest}`);
}
}
// Show workspace-specific breakdown
console.log('\n\n🏢 WORKSPACE BREAKDOWN:');
console.log('─'.repeat(80));
for (const [workspaceName, stats] of this.workspaceStats.entries()) {
const severityCount = stats[this.filterSeverity];
if (severityCount > 0) {
const packages = stats.packages.filter(p => p.severity === this.filterSeverity);
console.log(`\n 📦 ${workspaceName}: ${severityCount} ${this.filterSeverity} update${severityCount !== 1 ? 's' : ''}`);
// Show all packages for this workspace with the selected severity
for (const pkg of packages) {
console.log(` ${emoji} ${pkg.name}: ${pkg.current}${pkg.latest}`);
}
}
}
console.log('');
}
/**
* Display results in a helpful format
*/
displayResults(results) {
console.log('\n🎯 DEPENDENCY ANALYSIS RESULTS\n');
// If filtering by severity, show filtered results
if (this.filterSeverity) {
this.displayFilteredResults(results);
return;
}
// Workspace-specific statistics
console.log('🏢 WORKSPACE BREAKDOWN:');
console.log(' Outdated packages per workspace:\n');
// Sort workspaces by percentage of outdated packages (descending), then by total count
const sortedWorkspaces = Array.from(this.workspaceStats.entries())
.sort(([nameA, a], [nameB, b]) => {
const totalA = this.workspaceDepsCount.get(nameA) || 0;
const totalB = this.workspaceDepsCount.get(nameB) || 0;
const percentageA = totalA > 0 ? (a.total / totalA) * 100 : 0;
const percentageB = totalB > 0 ? (b.total / totalB) * 100 : 0;
// Sort by percentage first, then by total count
if (Math.abs(percentageA - percentageB) > 0.1) {
return percentageB - percentageA;
}
return b.total - a.total;
});
for (const [workspaceName, stats] of sortedWorkspaces) {
const totalDeps = this.workspaceDepsCount.get(workspaceName) || 0;
const outdatedCount = stats.total;
const percentage = totalDeps > 0 ? ((outdatedCount / totalDeps) * 100).toFixed(1) : '0.0';
if (stats.total === 0) {
console.log(`${workspaceName}: All ${totalDeps} dependencies up to date! (0% outdated)`);
} else {
console.log(` 📦 ${workspaceName}: ${outdatedCount}/${totalDeps} outdated (${percentage}%)`);
console.log(` 🔴 Major: ${stats.major} | 🟡 Minor: ${stats.minor} | 🟢 Patch: ${stats.patch}`);
// Show top 3 most outdated packages for this workspace
const topPackages = stats.packages
.sort((a, b) => {
const severityOrder = { major: 3, minor: 2, patch: 1 };
return severityOrder[b.severity] - severityOrder[a.severity];
})
.slice(0, 3);
if (topPackages.length > 0) {
console.log(` Top issues: ${topPackages.map(p => {
const emoji = p.severity === 'major' ? '🔴' : p.severity === 'minor' ? '🟡' : '🟢';
return `${emoji} ${p.name} (${p.current}${p.latest})`;
}).join(', ')}`);
}
console.log('');
}
}
console.log('');
// Direct dependencies (most actionable)
if (results.direct.length > 0) {
console.log('🎯 DIRECT DEPENDENCIES (High Priority):');
console.log(' Sorted by impact: workspace count → severity → version drift\n');
const topDirect = results.direct.slice(0, 15);
for (const pkg of topDirect) {
const emoji = pkg.severity === 'major' ? '🔴' : pkg.severity === 'minor' ? '🟡' : '🟢';
const impactEmoji = pkg.workspaceCount >= 5 ? '🌟' : pkg.workspaceCount >= 3 ? '⭐' : '';
console.log(` ${emoji} ${impactEmoji} ${pkg.name}`);
console.log(` ${pkg.current}${pkg.latest} (${pkg.severity})`);
console.log(` Used in ${pkg.workspaceCount} workspace${pkg.workspaceCount !== 1 ? 's' : ''}: ${pkg.workspaces.map(w => w.workspace).join(', ')}`);
console.log('');
}
if (results.direct.length > 15) {
console.log(` ... and ${results.direct.length - 15} more direct dependencies\n`);
}
}
// Sample of most outdated transitive dependencies
if (results.transitive.length > 0) {
console.log('🔄 MOST OUTDATED TRANSITIVE DEPENDENCIES (Lower Priority):');
console.log(' These will likely be updated automatically when you update direct deps.\n');
const topTransitive = results.transitive.slice(0, 10);
for (const pkg of topTransitive) {
const emoji = pkg.severity === 'major' ? '🔴' : pkg.severity === 'minor' ? '🟡' : '🟢';
console.log(` ${emoji} ${pkg.name}: ${pkg.current}${pkg.latest} (${pkg.severity})`);
}
if (results.transitive.length > 10) {
console.log(` ... and ${results.transitive.length - 10} more transitive dependencies\n`);
}
}
// Generate update commands for highest impact packages
const topUpdates = results.direct.slice(0, 5);
if (topUpdates.length > 0) {
console.log('🚀 SUGGESTED COMMANDS (highest impact first):');
for (const pkg of topUpdates) {
const impactNote = pkg.workspaceCount > 1 ? ` (affects ${pkg.workspaceCount} workspaces)` : '';
console.log(` pnpm update ${pkg.name}@latest${impactNote}`);
}
console.log('');
}
const generatedAt = new Date().toISOString();
const latestCommit = this.getLatestCommitRef();
// Summary at the end
console.log('📈 SUMMARY:');
console.log(` Generated at: ${generatedAt}`);
console.log(` Latest commit: ${latestCommit}`);
console.log(` Total dependencies: ${this.directDeps.size}`);
console.log(` Total outdated: ${results.stats.total}`);
console.log(` Major updates: ${results.stats.major}`);
console.log(` Minor updates: ${results.stats.minor}`);
console.log(` Patch updates: ${results.stats.patch}`);
console.log(` Direct deps: ${results.direct.length}`);
console.log(` Transitive deps: ${results.transitive.length}\n`);
}
/**
* Get the latest commit reference for the current checkout
*/
getLatestCommitRef() {
try {
return execSync("git log -1 --format='%h %ad %s' --date=iso-strict", {
encoding: 'utf8'
}).trim();
} catch (error) {
return 'Unavailable';
}
}
/**
* Run pnpm audit and display a vulnerability summary
*/
displayAuditSummary() {
console.log('🔒 SECURITY AUDIT:\n');
try {
let stdout = '';
try {
stdout = execSync('pnpm audit --json', {
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024
});
} catch (error) {
// pnpm audit exits with non-zero when vulnerabilities are found
stdout = error.stdout || '';
}
if (!stdout || !stdout.trim()) {
console.log(' ⚠️ Could not parse audit summary\n');
return;
}
const data = JSON.parse(stdout);
if (data.metadata && data.metadata.vulnerabilities) {
const v = data.metadata.vulnerabilities;
const total = v.info + v.low + v.moderate + v.high + v.critical;
console.log(` Total vulnerabilities: ${total}`);
console.log(` 🔴 Critical: ${v.critical}`);
console.log(` 🟠 High: ${v.high}`);
console.log(` 🟡 Moderate: ${v.moderate}`);
console.log(` 🟢 Low: ${v.low}`);
if (v.info > 0) {
console.log(` ️ Info: ${v.info}`);
}
console.log(` Total dependencies scanned: ${data.metadata.totalDependencies}\n`);
} else {
console.log(' ⚠️ Could not parse audit summary\n');
}
} catch (error) {
console.log(` ⚠️ Audit failed: ${error.message}\n`);
}
}
async run() {
try {
// Change to project root directory to run commands correctly
const rootDir = path.join(__dirname, '../..');
process.chdir(rootDir);
this.loadRenovateConfig();
await this.findWorkspaces();
this.extractDirectDependencies();
const outdatedData = await this.getOutdatedPackages();
if (outdatedData.length === 0) {
console.log('🎉 All packages are up to date!');
return;
}
const results = this.processOutdatedPackages(outdatedData);
this.displayResults(results);
this.displayAuditSummary();
} catch (error) {
console.error('❌ Error:', error.message);
process.exit(1);
}
}
}
// Run the detector
const detector = new LockfileDriftDetector();
detector.run();
@@ -0,0 +1,25 @@
const userAgent = process.env.npm_config_user_agent || '';
if (/\bpnpm\//.test(userAgent)) {
process.exit(0);
}
const detectedPackageManager = userAgent.split(' ')[0] || 'unknown';
console.error(`
Ghost now uses pnpm for dependency installation.
Detected package manager: ${detectedPackageManager}
Use one of these instead:
corepack enable pnpm
pnpm install
Common command replacements:
yarn setup -> pnpm run setup
yarn dev -> pnpm dev
yarn test -> pnpm test
yarn lint -> pnpm lint
`);
process.exit(1);
+215
View File
@@ -0,0 +1,215 @@
const path = require('path');
const fs = require('fs/promises');
const exec = require('util').promisify(require('child_process').exec);
const readline = require('readline/promises');
const semver = require('semver');
// Maps a package name to the config key in defaults.json
const CONFIG_KEYS = {
'@tryghost/portal': 'portal',
'@tryghost/sodo-search': 'sodoSearch',
'@tryghost/comments-ui': 'comments',
'@tryghost/announcement-bar': 'announcementBar',
'@tryghost/signup-form': 'signupForm'
};
const CURRENT_DIR = process.cwd();
const packageJsonPath = path.join(CURRENT_DIR, 'package.json');
const packageJson = require(packageJsonPath);
const APP_NAME = packageJson.name;
const APP_VERSION = packageJson.version;
async function safeExec(command) {
try {
return await exec(command);
} catch (err) {
return {
stdout: err.stdout,
stderr: err.stderr
};
}
}
async function ensureEnabledApp() {
const ENABLED_APPS = Object.keys(CONFIG_KEYS);
if (!ENABLED_APPS.includes(APP_NAME)) {
console.error(`${APP_NAME} is not enabled, please modify ${__filename}`);
process.exit(1);
}
}
async function ensureNotOnMain() {
const currentGitBranch = await safeExec(`git branch --show-current`);
if (currentGitBranch.stderr) {
console.error(`There was an error checking the current git branch`)
console.error(`${currentGitBranch.stderr}`);
process.exit(1);
}
if (currentGitBranch.stdout.trim() === 'main') {
console.error(`The release can not be done on the "main" branch`)
process.exit(1);
}
}
async function ensureCleanGit() {
const localGitChanges = await safeExec(`git status --porcelain`);
if (localGitChanges.stderr) {
console.error(`There was an error checking the local git status`)
console.error(`${localGitChanges.stderr}`);
process.exit(1);
}
if (localGitChanges.stdout) {
console.error(`You have local git changes - are you sure you're ready to release?`)
console.error(`${localGitChanges.stdout}`);
process.exit(1);
}
}
async function getNewVersion() {
const rl = readline.createInterface({input: process.stdin, output: process.stdout});
const bumpTypeInput = await rl.question('Is this a patch, minor or major (patch)? ');
rl.close();
const bumpType = bumpTypeInput.trim().toLowerCase() || 'patch';
if (!['patch', 'minor', 'major'].includes(bumpType)) {
console.error(`Unknown bump type ${bumpTypeInput} - expected one of "patch", "minor, "major"`)
process.exit(1);
}
return semver.inc(APP_VERSION, bumpType);
}
async function updateConfig(newVersion) {
const defaultConfigPath = path.resolve(__dirname, '../../ghost/core/core/shared/config/defaults.json');
const defaultConfig = require(defaultConfigPath);
const configKey = CONFIG_KEYS[APP_NAME];
defaultConfig[configKey].version = `${semver.major(newVersion)}.${semver.minor(newVersion)}`;
await fs.writeFile(defaultConfigPath, JSON.stringify(defaultConfig, null, 4) + '\n');
}
async function updatePackageJson(newVersion) {
const newPackageJson = Object.assign({}, packageJson, {
version: newVersion
});
await fs.writeFile(packageJsonPath, JSON.stringify(newPackageJson, null, 2) + '\n');
}
async function getChangelog(newVersion) {
const rl = readline.createInterface({input: process.stdin, output: process.stdout});
const i18nChangesInput = await rl.question('Does this release contain i18n updates (Y/n)? ');
rl.close();
const i18nChanges = i18nChangesInput.trim().toLowerCase() !== 'n';
let changelogItems = [];
if (i18nChanges) {
changelogItems.push('Updated i18n translations');
}
// Restrict git log to only the current directory (the specific app)
const lastFiftyCommits = await safeExec(`git log -n 50 --oneline -- .`);
if (lastFiftyCommits.stderr) {
console.error(`There was an error getting the last 50 commits`);
process.exit(1);
}
const lastFiftyCommitsList = lastFiftyCommits.stdout.split('\n');
const releaseRegex = new RegExp(`Released ${APP_NAME} v${APP_VERSION}`);
const indexOfLastRelease = lastFiftyCommitsList.findIndex((commitLine) => {
const commitMessage = commitLine.slice(11); // Take the hash off the front
return releaseRegex.test(commitMessage);
});
if (indexOfLastRelease === -1) {
console.warn(`Could not find commit for previous release. Will include recent commits affecting this app.`);
// Fallback: get recent commits for this app (last 20)
const recentCommits = await safeExec(`git log -n 20 --pretty=format:"%h%n%B__SPLIT__" -- .`);
if (recentCommits.stderr) {
console.error(`There was an error getting recent commits`);
process.exit(1);
}
const recentCommitsList = recentCommits.stdout.split('__SPLIT__');
const recentCommitsWhichMentionLinear = recentCommitsList.filter((commitBlock) => {
return commitBlock.includes('https://linear.app/ghost');
});
const commitChangelogItems = recentCommitsWhichMentionLinear.map((commitBlock) => {
const lines = commitBlock.split('\n');
if (!lines.length || !lines[0].trim()) {
return null; // Skip entries with no hash
}
const hash = lines[0].trim();
return `https://github.com/TryGhost/Ghost/commit/${hash}`;
}).filter(Boolean); // Filter out any null entries
changelogItems.push(...commitChangelogItems);
} else {
const lastReleaseCommit = lastFiftyCommitsList[indexOfLastRelease];
const lastReleaseCommitHash = lastReleaseCommit.slice(0, 10);
// Also restrict this git log to only the current directory (the specific app)
const commitsSinceLastRelease = await safeExec(`git log ${lastReleaseCommitHash}..HEAD --pretty=format:"%h%n%B__SPLIT__" -- .`);
if (commitsSinceLastRelease.stderr) {
console.error(`There was an error getting commits since the last release`);
process.exit(1);
}
const commitsSinceLastReleaseList = commitsSinceLastRelease.stdout.split('__SPLIT__');
const commitsSinceLastReleaseWhichMentionLinear = commitsSinceLastReleaseList.filter((commitBlock) => {
return commitBlock.includes('https://linear.app/ghost');
});
const commitChangelogItems = commitsSinceLastReleaseWhichMentionLinear.map((commitBlock) => {
const lines = commitBlock.split('\n');
if (!lines.length || !lines[0].trim()) {
return null; // Skip entries with no hash
}
const hash = lines[0].trim();
return `https://github.com/TryGhost/Ghost/commit/${hash}`;
}).filter(Boolean); // Filter out any null entries
changelogItems.push(...commitChangelogItems);
}
const changelogList = changelogItems.map(item => ` - ${item}`).join('\n');
return `Changelog for v${APP_VERSION} -> ${newVersion}: \n${changelogList}`;
}
async function main() {
await ensureEnabledApp();
await ensureNotOnMain();
await ensureCleanGit();
console.log(`Running release for ${APP_NAME}`);
console.log(`Current version is ${APP_VERSION}`);
const newVersion = await getNewVersion();
console.log(`Bumping to version ${newVersion}`);
const changelog = await getChangelog(newVersion);
await updatePackageJson(newVersion);
await exec(`git add package.json`);
await updateConfig(newVersion);
await exec(`git add ../../ghost/core/core/shared/config/defaults.json`);
await exec(`git commit -m 'Released ${APP_NAME} v${newVersion}\n\n${changelog}'`);
console.log(`Release commit created - please double check it and use "git commit --amend" to make any changes before opening a PR to merge into main`)
}
main();