diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a61c95a79ea..08058476750 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -243,7 +243,7 @@ jobs: # itself per-app — instead we emit the matrix here and it skips when # the result is `[]`. The publish job's own is_main / event gates keep # tags and PRs from publishing regardless of what this lists. - PUBLISH_PUBLIC_APPS_MATRIX=$(node scripts/build-public-apps-matrix.cjs "$AFFECTED_PROJECTS") + PUBLISH_PUBLIC_APPS_MATRIX=$(node scripts/build-public-apps-matrix.js "$AFFECTED_PROJECTS") echo "publish_public_apps_matrix=$PUBLISH_PUBLIC_APPS_MATRIX" >> "$GITHUB_OUTPUT" outputs: @@ -290,11 +290,24 @@ jobs: - name: Fetch main branch run: git fetch --no-tags origin main + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + env: + FORCE_COLOR: 0 + with: + node-version: ${{ env.NODE_VERSION }} + cache: pnpm + + # The script's only runtime dep is semver, so install @internal/scripts + # alone rather than the whole workspace — ~0.5s and a single package. + - name: Install scripts dependencies + run: pnpm install --frozen-lockfile --filter @internal/scripts --prod --ignore-scripts + - name: Check app version bump env: PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} PR_COMPARE_SHA: ${{ github.event.pull_request.head.sha }} - run: node scripts/check-app-version-bump.cjs + run: node scripts/check-app-version-bump.js job_migration_integrity_check: name: Check migration integrity @@ -2127,7 +2140,7 @@ jobs: run: | PREV_TAG="${{ steps.prev_tag.outputs.tag }}" if [ -n "$PREV_TAG" ]; then - node scripts/lib/release-notes.cjs "$PREV_TAG" "${GITHUB_REF_NAME}" > /tmp/release-notes.md + node scripts/lib/release-notes.js "$PREV_TAG" "${GITHUB_REF_NAME}" > /tmp/release-notes.md else echo "This release contains fixes for minor bugs and issues reported by Ghost users." > /tmp/release-notes.md fi diff --git a/apps/admin-toolbar/package.json b/apps/admin-toolbar/package.json index 0df859caf82..4f143af8950 100644 --- a/apps/admin-toolbar/package.json +++ b/apps/admin-toolbar/package.json @@ -24,7 +24,7 @@ "test": "pnpm test:unit", "test:unit": "pnpm run build && vitest run", "preship": "pnpm lint", - "ship": "node ../../scripts/release-apps.cjs", + "ship": "node ../../scripts/release-apps.js", "prepublishOnly": "pnpm build" }, "devDependencies": { diff --git a/apps/announcement-bar/package.json b/apps/announcement-bar/package.json index 7dcc80e343c..32395e4ae72 100644 --- a/apps/announcement-bar/package.json +++ b/apps/announcement-bar/package.json @@ -26,7 +26,7 @@ "test:unit": "pnpm test:ci", "lint": "eslint src test --cache", "preship": "pnpm lint", - "ship": "node ../../scripts/release-apps.cjs", + "ship": "node ../../scripts/release-apps.js", "prepublishOnly": "pnpm build" }, "browserslist": { diff --git a/apps/comments-ui/package.json b/apps/comments-ui/package.json index c264dcd14f4..269d1e04d78 100644 --- a/apps/comments-ui/package.json +++ b/apps/comments-ui/package.json @@ -29,7 +29,7 @@ "lint:code": "eslint src --cache", "lint:types": "pnpm test:types", "preship": "pnpm lint", - "ship": "node ../../scripts/release-apps.cjs", + "ship": "node ../../scripts/release-apps.js", "prepublishOnly": "pnpm build" }, "browserslist": { diff --git a/apps/portal/package.json b/apps/portal/package.json index ebe2b8173ba..b39d69bc5b5 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -25,7 +25,7 @@ "lint:types": "tsc --noEmit", "lint": "pnpm run '/^lint:/'", "preship": "pnpm lint", - "ship": "node ../../scripts/release-apps.cjs", + "ship": "node ../../scripts/release-apps.js", "prepublishOnly": "pnpm build" }, "browserslist": { diff --git a/apps/signup-form/package.json b/apps/signup-form/package.json index 30f15c62431..617708481b0 100644 --- a/apps/signup-form/package.json +++ b/apps/signup-form/package.json @@ -27,7 +27,7 @@ "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "preship": "pnpm lint", - "ship": "node ../../scripts/release-apps.cjs", + "ship": "node ../../scripts/release-apps.js", "prepublishOnly": "pnpm build" }, "dependencies": { diff --git a/apps/sodo-search/package.json b/apps/sodo-search/package.json index 1d2001844e8..bc9d1ed4538 100644 --- a/apps/sodo-search/package.json +++ b/apps/sodo-search/package.json @@ -27,7 +27,7 @@ "test": "vitest run", "lint": "eslint src test --cache", "preship": "pnpm lint", - "ship": "node ../../scripts/release-apps.cjs", + "ship": "node ../../scripts/release-apps.js", "prepublishOnly": "pnpm build" }, "browserslist": { diff --git a/scripts/README.md b/scripts/README.md index 30aad60794d..eac35d201e8 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -16,9 +16,12 @@ Write it as ESM (`.js` — the package is `type: module`) and parse arguments wi `semver` is already there. Keep it cheap: everything here lands in every dev's `pnpm install`, so prefer `node:` built-ins. -Put testable logic in `lib/` and cover it in `test/` — tests are plain -`node --test`, discovered automatically. Note this package is *not* part of the -root Vitest watcher (`pnpm test:watch`), which only covers Vitest-based projects. +Put logic shared by several scripts in `lib/`. A script that is its own +entrypoint can just export the parts worth testing and guard the CLI path with +`import.meta.main` — see `build-public-apps-matrix.js`. Either way, cover it in +`test/`: tests are plain `node --test`, discovered automatically. Note this +package is *not* part of the root Vitest watcher (`pnpm test:watch`), which only +covers Vitest-based projects. ## The `.cjs` files diff --git a/scripts/build-public-apps-matrix.cjs b/scripts/build-public-apps-matrix.cjs deleted file mode 100644 index d267eb303fe..00000000000 --- a/scripts/build-public-apps-matrix.cjs +++ /dev/null @@ -1,60 +0,0 @@ -// Builds the publish matrix for public UMD apps, filtered to the apps affected -// in the current run. The output (stdout, compact JSON array) feeds the -// publish_public_apps job's `strategy.matrix.include` via fromJSON. -// -// Matrix context isn't available in a job-level `if:`, so the affected gate -// can't live on the publish job — instead we compute the set here in job_setup -// (mirroring the affected_playwright_projects dynamic matrix) and the job skips -// itself when the result is `[]`. -// -// The app list (and the CURRENT_MINOR placeholder the publish job substitutes -// with the released major.minor before purging jsDelivr) comes from the shared -// public-apps.json. - -const PUBLIC_APPS = require('./public-apps.json'); - -/** - * @param {string[]} affectedProjects - nx project names affected in this run - * @returns {Array<{package_name: string, package_path: string, cdn_paths: string}>} - * matrix entries with cdn_paths flattened to the newline-delimited string the - * publish job's purge step expects. - */ -function buildMatrix(affectedProjects) { - const affected = new Set(affectedProjects); - return PUBLIC_APPS - .filter(app => affected.has(app.packageName)) - .map(app => ({ - package_name: app.packageName, - package_path: app.path, - cdn_paths: app.cdnPaths.join('\n') - })); -} - -function main() { - const raw = process.argv[2] || '[]'; - - let affectedProjects; - try { - affectedProjects = JSON.parse(raw); - } catch (error) { - throw new Error(`Invalid affected-projects JSON: ${error.message}`); - } - - if (!Array.isArray(affectedProjects)) { - throw new Error('affected-projects argument must be a JSON array'); - } - - // Stdout is the contract — the workflow captures this into a job output. - process.stdout.write(JSON.stringify(buildMatrix(affectedProjects))); -} - -if (require.main === module) { - try { - main(); - } catch (error) { - console.error(error.message); - process.exit(1); - } -} - -module.exports = {buildMatrix, PUBLIC_APPS}; diff --git a/scripts/build-public-apps-matrix.js b/scripts/build-public-apps-matrix.js new file mode 100644 index 00000000000..49b690f3d92 --- /dev/null +++ b/scripts/build-public-apps-matrix.js @@ -0,0 +1,98 @@ +// Builds the publish matrix for public UMD apps, filtered to the apps affected +// in the current run. The output (stdout, compact JSON array) feeds the +// publish_public_apps job's `strategy.matrix.include` via fromJSON. +// +// Matrix context isn't available in a job-level `if:`, so the affected gate +// can't live on the publish job — instead we compute the set here in job_setup +// (mirroring the affected_playwright_projects dynamic matrix) and the job skips +// itself when the result is `[]`. +// +// public-apps.json says *which* apps are public (apps/ also holds admin, shade +// and friends, which are not) and maps each to its defaults.json key. The URLs +// to purge come from defaults.json itself — see cdnPathsFor. + +import {PUBLIC_APPS, readDefaults} from './lib/public-apps.js'; + +const DEFAULTS = await readDefaults(); + +// defaults.json pins each app to a major.minor and interpolates it into the +// URLs as {version}. The publish job knows the version it just released, so we +// hand back CURRENT_MINOR for it to substitute. +const VERSION_PLACEHOLDER = '{version}'; + +/** + * The jsDelivr URLs to purge for one app, read off its defaults.json entry. + * + * defaults.json is what Ghost core resolves at render time, so it is by + * definition the set of assets visitors fetch. Deriving from it means the purge + * list cannot silently fall behind: an app that starts shipping a stylesheet + * has to be declared here or the feature breaks visibly. Every string field + * holding a URL counts — `url` for all of them, plus `styles` for sodo-search. + * + * @param {Record} configEntry - a defaults.json app entry + * @returns {string[]} + */ +export function cdnPathsFor(configEntry) { + return Object.values(configEntry) + .filter(value => typeof value === 'string' && value.startsWith('https://')) + .map(url => url.replaceAll(VERSION_PLACEHOLDER, 'CURRENT_MINOR')); +} + +/** + * @param {string[]} affectedProjects - nx project names affected in this run + * @returns {Array<{package_name: string, package_path: string, cdn_paths: string}>} + * matrix entries with cdn_paths flattened to the newline-delimited string the + * publish job's purge step expects. + */ +export function buildMatrix(affectedProjects) { + const affected = new Set(affectedProjects); + return PUBLIC_APPS + .filter(app => affected.has(app.packageName)) + .map((app) => { + const configEntry = DEFAULTS[app.configKey]; + + // Both of these would otherwise degrade into an empty purge list, + // which reads as success and leaves jsDelivr serving the old bundle. + if (!configEntry) { + throw new Error(`public-apps.json maps ${app.packageName} to configKey "${app.configKey}", which defaults.json does not define`); + } + + const cdnPaths = cdnPathsFor(configEntry); + if (!cdnPaths.length) { + throw new Error(`defaults.json entry "${app.configKey}" (${app.packageName}) has no CDN URLs to purge`); + } + + return { + package_name: app.packageName, + package_path: app.path, + cdn_paths: cdnPaths.join('\n') + }; + }); +} + +function main() { + const raw = process.argv[2] || '[]'; + + let affectedProjects; + try { + affectedProjects = JSON.parse(raw); + } catch (error) { + throw new Error(`Invalid affected-projects JSON: ${error.message}`); + } + + if (!Array.isArray(affectedProjects)) { + throw new Error('affected-projects argument must be a JSON array'); + } + + // Stdout is the contract — the workflow captures this into a job output. + process.stdout.write(JSON.stringify(buildMatrix(affectedProjects))); +} + +if (import.meta.main) { + try { + main(); + } catch (error) { + console.error(error.message); + process.exit(1); + } +} diff --git a/scripts/check-app-version-bump.cjs b/scripts/check-app-version-bump.js similarity index 60% rename from scripts/check-app-version-bump.cjs rename to scripts/check-app-version-bump.js index 95d96ca1205..8768a3f6676 100644 --- a/scripts/check-app-version-bump.cjs +++ b/scripts/check-app-version-bump.js @@ -1,14 +1,14 @@ -const fs = require('fs'); -const path = require('path'); -const execFileSync = require('child_process').execFileSync; +// Verifies, on every PR touching a public UMD app, that the app's package.json +// major.minor still matches the version pinned in defaults.json. See lib for +// why that invariant matters. -// Keyed by defaults.json config key, derived from the shared public-apps list. -const MONITORED_APPS = Object.fromEntries( - require('./public-apps.json').map(app => [app.configKey, {packageName: app.packageName, path: app.path}]) -); +import {existsSync, readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {execFileSync} from 'node:child_process'; -const MONITORED_APP_ENTRIES = Object.entries(MONITORED_APPS); -const MONITORED_APP_PATHS = MONITORED_APP_ENTRIES.map(([, app]) => app.path); +import {PUBLIC_APPS, DEFAULTS_PATH, DEFAULTS_REPO_PATH, majorMinor} from './lib/public-apps.js'; + +const MONITORED_APP_PATHS = PUBLIC_APPS.map(app => app.path); function runGit(args) { try { @@ -37,34 +37,6 @@ function readVersionFromPackageJson(packageJsonContent, sourceLabel) { 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 getMajorMinorVersion(version) { - const parsedVersion = parseSemver(version); - return `${parsedVersion.major}.${parsedVersion.minor}`; -} - function getChangedFiles(baseSha, compareSha) { let mergeBaseSha; @@ -87,20 +59,18 @@ function getChangedAppFiles(app, changedFiles) { } function getChangedApps(changedFiles) { - return MONITORED_APP_ENTRIES - .filter(([, app]) => getChangedAppFiles(app, changedFiles).length > 0) - .map(([key, app]) => ({key, ...app})); + return PUBLIC_APPS.filter(app => getChangedAppFiles(app, changedFiles).length > 0); } function getPrVersion(app) { - const packageJsonPath = path.resolve(__dirname, `../${app.path}/package.json`); + const packageJsonPath = join(import.meta.dirname, '..', app.path, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { + if (!existsSync(packageJsonPath)) { throw new Error(`${app.path}/package.json does not exist in this PR`); } return readVersionFromPackageJson( - fs.readFileSync(packageJsonPath, 'utf8'), + readFileSync(packageJsonPath, 'utf8'), `${app.path}/package.json from PR` ); } @@ -114,29 +84,48 @@ function readVersionFromDefaults(defaultsContent, app, sourceLabel) { throw new Error(`Unable to parse ${sourceLabel}: ${error.message}`); } - const appDefaults = parsedDefaults[app.key]; + const appDefaults = parsedDefaults[app.configKey]; if (!appDefaults || typeof appDefaults.version !== 'string') { - throw new Error(`${sourceLabel} does not contain a valid "${app.key}.version" field`); + throw new Error(`${sourceLabel} does not contain a valid "${app.configKey}.version" field`); } return appDefaults.version; } function getPrDefaultsVersion(app) { - const defaultsPath = path.resolve(__dirname, '../ghost/core/core/shared/config/defaults.json'); - - if (!fs.existsSync(defaultsPath)) { - throw new Error('ghost/core/core/shared/config/defaults.json does not exist in this PR'); + if (!existsSync(DEFAULTS_PATH)) { + throw new Error(`${DEFAULTS_REPO_PATH} does not exist in this PR`); } return readVersionFromDefaults( - fs.readFileSync(defaultsPath, 'utf8'), + readFileSync(DEFAULTS_PATH, 'utf8'), app, - 'ghost/core/core/shared/config/defaults.json from PR' + `${DEFAULTS_REPO_PATH} from PR` ); } +/** + * Patch releases are published automatically on merge to main, with npm as the + * source of truth for the patch number — so PRs no longer need to bump the + * version at all. The only invariant we still enforce is that package.json's + * major.minor matches defaults.json, because Ghost core serves each app from + * `@~` on jsDelivr. A deliberate minor/major release (via + * "pnpm ship") must move both in lockstep, or live sites would point at a + * version line that never gets published. + * + * @returns {string|null} an error message when inconsistent, otherwise null + */ +export function checkAppConsistency(app, prVersion, prDefaultsVersion) { + const prMajorMinorVersion = majorMinor(prVersion); + + if (prDefaultsVersion !== prMajorMinorVersion) { + return `${app.configKey} (${app.packageName}): package.json is on ${prMajorMinorVersion} but defaults.json has ${app.configKey}.version set to ${prDefaultsVersion}. These must match — for a minor/major release run "pnpm ship" in ${app.path} (it updates both), otherwise align ${DEFAULTS_REPO_PATH} to ${prMajorMinorVersion}.`; + } + + return null; +} + function main() { const baseSha = process.env.PR_BASE_SHA; const compareSha = process.env.PR_COMPARE_SHA || process.env.GITHUB_SHA; @@ -157,7 +146,7 @@ function main() { return; } - console.log(`Checking app version consistency for: ${changedApps.map(app => app.key).join(', ')}`); + console.log(`Checking app version consistency for: ${changedApps.map(app => app.configKey).join(', ')}`); const failedApps = []; @@ -169,7 +158,7 @@ function main() { continue; } - console.log(`${app.key} version consistency check passed (package.json ${getMajorMinorVersion(getPrVersion(app))} = defaults.json ${getPrDefaultsVersion(app)})`); + console.log(`${app.configKey} version consistency check passed (package.json ${majorMinor(getPrVersion(app))} = defaults.json ${getPrDefaultsVersion(app)})`); } if (failedApps.length) { @@ -179,28 +168,7 @@ function main() { console.log('All monitored app version consistency checks passed.'); } -/** - * Patch releases are published automatically on merge to main, with npm as the - * source of truth for the patch number — so PRs no longer need to bump the - * version at all. The only invariant we still enforce is that package.json's - * major.minor matches defaults.json, because Ghost core serves each app from - * `@~` on jsDelivr. A deliberate minor/major release (via - * "pnpm ship") must move both in lockstep, or live sites would point at a - * version line that never gets published. - * - * @returns {string|null} an error message when inconsistent, otherwise null - */ -function checkAppConsistency(app, prVersion, prDefaultsVersion) { - const prMajorMinorVersion = getMajorMinorVersion(prVersion); - - if (prDefaultsVersion !== prMajorMinorVersion) { - return `${app.key} (${app.packageName}): package.json is on ${prMajorMinorVersion} but defaults.json has ${app.key}.version set to ${prDefaultsVersion}. These must match — for a minor/major release run "pnpm ship" in ${app.path} (it updates both), otherwise align ghost/core/core/shared/config/defaults.json to ${prMajorMinorVersion}.`; - } - - return null; -} - -if (require.main === module) { +if (import.meta.main) { try { main(); } catch (error) { @@ -208,5 +176,3 @@ if (require.main === module) { process.exit(1); } } - -module.exports = {checkAppConsistency, getMajorMinorVersion}; diff --git a/scripts/lib/constants.js b/scripts/lib/constants.js new file mode 100644 index 00000000000..fbdb850ce87 --- /dev/null +++ b/scripts/lib/constants.js @@ -0,0 +1,4 @@ +import {join} from 'node:path'; + +export const SCRIPTS_DIR = join(import.meta.dirname, '..'); +export const ROOT_DIR = join(SCRIPTS_DIR, '..'); diff --git a/scripts/lib/public-apps.js b/scripts/lib/public-apps.js new file mode 100644 index 00000000000..abbf0fc609b --- /dev/null +++ b/scripts/lib/public-apps.js @@ -0,0 +1,65 @@ +// Shared vocabulary for the public UMD apps (portal, comments-ui, ...) — the +// manifest, the config that pins their versions, and the major.minor rule that +// ties those together. +// +// The rule: Ghost core serves each app from `@~` on jsDelivr, +// so an app's package.json major.minor must equal its defaults.json version. +// `release-apps` writes that pair and `check-app-version-bump` verifies it on +// every PR — they have to agree on what major.minor means, hence majorMinor +// living here rather than one implementation each. + +import {writeFile} from 'node:fs/promises'; +import {join} from 'node:path'; +import semver from 'semver'; + +import {ROOT_DIR, SCRIPTS_DIR} from './constants.js'; +import {readJson} from './utils.js'; + +/** + * Which apps are public UMD apps, and the defaults.json key each maps to. + * + * Neither half is derivable. apps/ also holds admin, shade, activitypub and the + * admin-x packages, which are not published to the CDN — the filesystem can't + * tell you which six are. And comments-ui camel-cases to `commentsUi`, but its + * key is `comments`. + * + * @type {Array<{packageName: string, path: string, configKey: string}>} + */ +export const PUBLIC_APPS = await readJson(join(SCRIPTS_DIR, 'public-apps.json')); + +/** The Ghost core config pinning each app's major.minor and CDN URLs. */ +export const DEFAULTS_REPO_PATH = 'ghost/core/core/shared/config/defaults.json'; +export const DEFAULTS_PATH = join(ROOT_DIR, DEFAULTS_REPO_PATH); + +export const readDefaults = () => readJson(DEFAULTS_PATH); + +/** @param {object} defaults - the full defaults.json object */ +export const writeDefaults = defaults => writeFile(DEFAULTS_PATH, JSON.stringify(defaults, null, 4) + '\n'); + +/** @param {string} packageName */ +export function appForPackageName(packageName) { + const app = PUBLIC_APPS.find(a => a.packageName === packageName); + if (!app) { + throw new Error(`App ${packageName} not found in public-apps.json`); + } + + return app; +} + +/** + * The `major.minor` line a version belongs to — the form defaults.json pins and + * jsDelivr resolves. Rejects a malformed version loudly rather than letting it + * become a silently wrong CDN path. + * + * @param {string} version + * @returns {string} e.g. "2.69" + */ +export function majorMinor(version) { + const parsed = semver.parse(version); + + if (!parsed) { + throw new Error(`Invalid semver version "${version}"`); + } + + return `${parsed.major}.${parsed.minor}`; +} diff --git a/scripts/lib/release-notes.cjs b/scripts/lib/release-notes.js similarity index 88% rename from scripts/lib/release-notes.cjs rename to scripts/lib/release-notes.js index fdc4d9c6f9d..d13dce496cf 100644 --- a/scripts/lib/release-notes.cjs +++ b/scripts/lib/release-notes.js @@ -1,10 +1,7 @@ -#!/usr/bin/env node -'use strict'; +import {resolve} from 'node:path'; +import {execSync} from 'node:child_process'; -const {execSync} = require('node:child_process'); -const path = require('node:path'); - -const ROOT = path.resolve(__dirname, '../..'); +const ROOT = resolve(import.meta.dirname, '../..'); const REPO_URL = 'https://github.com/TryGhost/Ghost'; // Emoji priority order (lowest index = lowest priority, sorted descending) @@ -55,7 +52,7 @@ function filterAndSortByEmoji(lines) { return emojiLines; } -function generateReleaseNotes(fromTag, toTag) { +export function generateReleaseNotes(fromTag, toTag) { const lines = getCommitLog(fromTag, toTag); const filtered = filterAndSortByEmoji(lines); @@ -73,7 +70,7 @@ function generateReleaseNotes(fromTag, toTag) { } // CLI: node release-notes.js -if (require.main === module) { +if (import.meta.main) { const [fromTag, toTag] = process.argv.slice(2); if (!fromTag || !toTag) { @@ -83,5 +80,3 @@ if (require.main === module) { process.stdout.write(generateReleaseNotes(fromTag, toTag)); } - -module.exports = {generateReleaseNotes}; diff --git a/scripts/lib/resolve-base-tag.cjs b/scripts/lib/resolve-base-tag.js similarity index 84% rename from scripts/lib/resolve-base-tag.cjs rename to scripts/lib/resolve-base-tag.js index 7c81c60d9f9..19f5e23f182 100644 --- a/scripts/lib/resolve-base-tag.cjs +++ b/scripts/lib/resolve-base-tag.js @@ -1,5 +1,5 @@ -const semver = require('semver'); -const {execSync} = require('node:child_process'); +import {execSync} from 'node:child_process'; +import semver from 'semver'; /** * Resolve the base git tag for diff/log comparisons during release preparation. @@ -12,7 +12,7 @@ const {execSync} = require('node:child_process'); * @param {string} repoDir - Path to the Ghost repo checkout * @returns {{tag: string, isPrerelease: boolean}} */ -function resolveBaseTag(version, repoDir) { +export 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`, @@ -27,5 +27,3 @@ function resolveBaseTag(version, repoDir) { isPrerelease: false }; } - -module.exports = {resolveBaseTag}; diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js new file mode 100644 index 00000000000..b54241273f0 --- /dev/null +++ b/scripts/lib/utils.js @@ -0,0 +1,22 @@ +import {readFileSync, writeFileSync} from 'node:fs'; +import {readFile, writeFile} from 'node:fs/promises'; +import {exec as execCallback} from 'node:child_process'; +import {promisify} from 'node:util'; + +export const execAsync = promisify(execCallback); + +/** @param {string} filePath */ +export const readJson = async filePath => JSON.parse(await readFile(filePath, 'utf8')); +/** @param {string} filePath */ +export const readJsonSync = filePath => JSON.parse(readFileSync(filePath, 'utf8')); + +/** + * @param {string} filePath + * @param {object} json + */ +export const writeJson = async (filePath, json) => await writeFile(filePath, JSON.stringify(json, null, 4) + '\n', 'utf8'); +/** + * @param {string} filePath + * @param {object} json + */ +export const writeJsonSync = (filePath, json) => writeFileSync(filePath, JSON.stringify(json, null, 4) + '\n', 'utf8'); diff --git a/scripts/public-apps.json b/scripts/public-apps.json index 5a6335a834f..62cbbc7f03f 100644 --- a/scripts/public-apps.json +++ b/scripts/public-apps.json @@ -2,50 +2,31 @@ { "packageName": "@tryghost/portal", "path": "apps/portal", - "configKey": "portal", - "cdnPaths": [ - "https://cdn.jsdelivr.net/ghost/portal@~CURRENT_MINOR/umd/portal.min.js" - ] + "configKey": "portal" }, { "packageName": "@tryghost/sodo-search", "path": "apps/sodo-search", - "configKey": "sodoSearch", - "cdnPaths": [ - "https://cdn.jsdelivr.net/ghost/sodo-search@~CURRENT_MINOR/umd/sodo-search.min.js", - "https://cdn.jsdelivr.net/ghost/sodo-search@~CURRENT_MINOR/umd/main.css" - ] + "configKey": "sodoSearch" }, { "packageName": "@tryghost/comments-ui", "path": "apps/comments-ui", - "configKey": "comments", - "cdnPaths": [ - "https://cdn.jsdelivr.net/ghost/comments-ui@~CURRENT_MINOR/umd/comments-ui.min.js" - ] + "configKey": "comments" }, { "packageName": "@tryghost/signup-form", "path": "apps/signup-form", - "configKey": "signupForm", - "cdnPaths": [ - "https://cdn.jsdelivr.net/ghost/signup-form@~CURRENT_MINOR/umd/signup-form.min.js" - ] + "configKey": "signupForm" }, { "packageName": "@tryghost/announcement-bar", "path": "apps/announcement-bar", - "configKey": "announcementBar", - "cdnPaths": [ - "https://cdn.jsdelivr.net/ghost/announcement-bar@~CURRENT_MINOR/umd/announcement-bar.min.js" - ] + "configKey": "announcementBar" }, { "packageName": "@tryghost/admin-toolbar", "path": "apps/admin-toolbar", - "configKey": "adminToolbar", - "cdnPaths": [ - "https://cdn.jsdelivr.net/ghost/admin-toolbar@~CURRENT_MINOR/umd/admin-toolbar.min.js" - ] + "configKey": "adminToolbar" } ] diff --git a/scripts/release-apps.cjs b/scripts/release-apps.cjs deleted file mode 100755 index b32c0cb35c2..00000000000 --- a/scripts/release-apps.cjs +++ /dev/null @@ -1,218 +0,0 @@ -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, derived from the -// shared public-apps list. -const CONFIG_KEYS = Object.fromEntries( - require('./public-apps.json').map(app => [app.packageName, app.configKey]) -); - -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}); - // Patch releases are published automatically on every merge to main (CI - // computes the next patch from npm), so this script only drives intentional - // minor/major releases — the ones that also move the major.minor pinned in - // defaults.json. - console.log('Patch releases are published automatically on merge to main.'); - console.log('Use this only for an intentional minor or major release.\n'); - const bumpTypeInput = await rl.question('Is this a minor or major release (minor)? '); - rl.close(); - const bumpType = bumpTypeInput.trim().toLowerCase() || 'minor'; - if (!['minor', 'major'].includes(bumpType)) { - console.error(`Unknown bump type ${bumpTypeInput} - expected "minor" or "major" (patch releases are automated)`) - 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(); diff --git a/scripts/release-apps.js b/scripts/release-apps.js new file mode 100644 index 00000000000..abc83f6f877 --- /dev/null +++ b/scripts/release-apps.js @@ -0,0 +1,103 @@ +import {join} from 'node:path'; +import {createInterface} from 'node:readline/promises'; +import semver from 'semver'; + +import {execAsync, readJson, writeJson} from './lib/utils.js'; +import {appForPackageName, readDefaults, writeDefaults, DEFAULTS_PATH, majorMinor} from './lib/public-apps.js'; + +const CURRENT_DIR = process.cwd(); + +const packageJsonPath = join(CURRENT_DIR, 'package.json'); +const packageJson = await readJson(packageJsonPath); + +const APP_NAME = packageJson.name; +const APP_VERSION = packageJson.version; + +const app = appForPackageName(APP_NAME); + +async function ensureEnabledApp() { + if (!app) { + console.error(`${APP_NAME} is not a public app — add it to scripts/public-apps.json to release it here`); + process.exit(1); + } +} + +async function ensureNotOnMain() { + const {stdout} = await execAsync(`git branch --show-current`); + + if (stdout.trim() === 'main') { + console.error(`The release can not be done on the "main" branch`) + process.exit(1); + } +} + +async function ensureCleanGit() { + const {stdout} = await execAsync(`git status --porcelain`); + + if (stdout) { + console.error(`You have local git changes - are you sure you're ready to release?`) + console.error(`${stdout}`); + process.exit(1); + } +} + +async function getNewVersion() { + const rl = createInterface({input: process.stdin, output: process.stdout}); + // Patch releases are published automatically on every merge to main (CI + // computes the next patch from npm), so this script only drives intentional + // minor/major releases — the ones that also move the major.minor pinned in + // defaults.json. + console.log('Patch releases are published automatically on merge to main.'); + console.log('Use this only for an intentional minor or major release.\n'); + const bumpTypeInput = await rl.question('Is this a minor or major release (minor)? '); + rl.close(); + const bumpType = bumpTypeInput.trim().toLowerCase() || 'minor'; + if (!['minor', 'major'].includes(bumpType)) { + console.error(`Unknown bump type ${bumpTypeInput} - expected "minor" or "major" (patch releases are automated)`) + process.exit(1); + } + return semver.inc(APP_VERSION, bumpType); +} + +async function updateConfig(newVersion) { + const defaultConfig = await readDefaults(); + + // Must stay in lockstep with package.json — check-app-version-bump fails the + // PR otherwise, and live sites would point at an unpublished version line. + defaultConfig[app.configKey].version = majorMinor(newVersion); + + await writeDefaults(defaultConfig); +} + +async function updatePackageJson(newVersion) { + const newPkg = {...packageJson, version: newVersion}; + await writeJson(packageJsonPath, newPkg); +} + +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}`); + + await updatePackageJson(newVersion); + await execAsync(`git add package.json`); + + await updateConfig(newVersion); + await execAsync(`git add ${DEFAULTS_PATH}`); + + await execAsync(`git commit -m 'Released ${APP_NAME} v${newVersion}'`); + + 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().catch((err) => { + console.error(`\n✗ Release failed: ${err.message}`); + process.exit(1); +}); diff --git a/scripts/release.cjs b/scripts/release.cjs index 35809f71db4..43d156a5236 100644 --- a/scripts/release.cjs +++ b/scripts/release.cjs @@ -5,7 +5,7 @@ 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.cjs'); +const {resolveBaseTag} = require('./lib/resolve-base-tag.js'); const ROOT = path.resolve(__dirname, '..'); const GHOST_CORE_PKG = path.join(ROOT, 'ghost/core/package.json'); diff --git a/scripts/test/build-public-apps-matrix.test.js b/scripts/test/build-public-apps-matrix.test.js new file mode 100644 index 00000000000..b526d8fa518 --- /dev/null +++ b/scripts/test/build-public-apps-matrix.test.js @@ -0,0 +1,108 @@ +import {describe, it} from 'node:test'; +import assert from 'node:assert'; +import {readFile} from 'node:fs/promises'; +import {resolve} from 'node:path'; +import {buildMatrix, cdnPathsFor} from '../build-public-apps-matrix.js'; +import {PUBLIC_APPS} from '../lib/public-apps.js'; + +const DEFAULTS = JSON.parse(await readFile( + resolve(import.meta.dirname, '../../ghost/core/core/shared/config/defaults.json'), + 'utf8' +)); + +const allPackageNames = PUBLIC_APPS.map(app => app.packageName); + +describe('buildMatrix', () => { + it('returns an empty matrix when nothing is affected', () => { + assert.deepStrictEqual(buildMatrix([]), []); + }); + + it('ignores affected projects that are not public apps', () => { + assert.deepStrictEqual(buildMatrix(['ghost', '@tryghost/admin']), []); + }); + + it('filters to just the affected apps', () => { + const matrix = buildMatrix(['@tryghost/portal', 'ghost']); + assert.strictEqual(matrix.length, 1); + assert.strictEqual(matrix[0].package_name, '@tryghost/portal'); + assert.strictEqual(matrix[0].package_path, 'apps/portal'); + }); + + it('preserves public-apps.json order regardless of affected order', () => { + const matrix = buildMatrix([...allPackageNames].reverse()); + assert.deepStrictEqual(matrix.map(e => e.package_name), allPackageNames); + }); + + it('flattens cdn_paths to a newline-delimited string for the purge step', () => { + const [portal] = buildMatrix(['@tryghost/portal']); + assert.strictEqual( + portal.cdn_paths, + 'https://cdn.jsdelivr.net/ghost/portal@~CURRENT_MINOR/umd/portal.min.js' + ); + }); + + it('includes every asset for a multi-asset app, not just the bundle', () => { + // sodo-search ships a stylesheet alongside its JS. This is the case the + // old hand-maintained cdnPaths list had to remember and derivation gets + // for free. + const [sodoSearch] = buildMatrix(['@tryghost/sodo-search']); + assert.deepStrictEqual(sodoSearch.cdn_paths.split('\n'), [ + 'https://cdn.jsdelivr.net/ghost/sodo-search@~CURRENT_MINOR/umd/sodo-search.min.js', + 'https://cdn.jsdelivr.net/ghost/sodo-search@~CURRENT_MINOR/umd/main.css' + ]); + }); +}); + +describe('cdnPathsFor', () => { + it('substitutes the defaults.json version placeholder', () => { + assert.deepStrictEqual( + cdnPathsFor({url: 'https://cdn.jsdelivr.net/ghost/x@~{version}/umd/x.min.js', version: '1.2'}), + ['https://cdn.jsdelivr.net/ghost/x@~CURRENT_MINOR/umd/x.min.js'] + ); + }); + + it('picks up every URL field, so a newly added asset is purged automatically', () => { + const paths = cdnPathsFor({ + url: 'https://cdn.jsdelivr.net/ghost/x@~{version}/umd/x.min.js', + styles: 'https://cdn.jsdelivr.net/ghost/x@~{version}/umd/main.css', + somethingNew: 'https://cdn.jsdelivr.net/ghost/x@~{version}/umd/extra.js', + version: '1.2' + }); + assert.strictEqual(paths.length, 3); + assert.ok(paths.every(p => p.includes('CURRENT_MINOR'))); + }); + + it('never treats the version field itself as a URL', () => { + assert.deepStrictEqual(cdnPathsFor({version: '1.2'}), []); + }); +}); + +describe('public-apps.json <-> defaults.json contract', () => { + it('every listed app resolves to a defaults.json entry', () => { + for (const app of PUBLIC_APPS) { + assert.ok( + DEFAULTS[app.configKey], + `${app.packageName} has configKey "${app.configKey}" with no defaults.json entry` + ); + } + }); + + it('every listed app yields at least one URL to purge', () => { + for (const app of PUBLIC_APPS) { + assert.ok( + cdnPathsFor(DEFAULTS[app.configKey]).length > 0, + `${app.packageName} would publish with an empty purge list` + ); + } + }); + + it('purges exactly the URLs core serves, with only the version differing', () => { + for (const entry of buildMatrix(allPackageNames)) { + const app = PUBLIC_APPS.find(a => a.packageName === entry.package_name); + const served = Object.values(DEFAULTS[app.configKey]) + .filter(v => typeof v === 'string' && v.startsWith('https://')) + .map(v => v.replaceAll('{version}', 'CURRENT_MINOR')); + assert.deepStrictEqual(entry.cdn_paths.split('\n'), served, entry.package_name); + } + }); +}); diff --git a/scripts/test/public-apps.test.js b/scripts/test/public-apps.test.js new file mode 100644 index 00000000000..c8dc9198843 --- /dev/null +++ b/scripts/test/public-apps.test.js @@ -0,0 +1,76 @@ +import {describe, it} from 'node:test'; +import assert from 'node:assert'; +import {existsSync} from 'node:fs'; +import {join} from 'node:path'; +import { + PUBLIC_APPS, + DEFAULTS_PATH, + DEFAULTS_REPO_PATH, + appForPackageName, + majorMinor, + readDefaults, +} from '../lib/public-apps.js'; +import {readJson} from '../lib/utils.js'; +import { ROOT_DIR } from '../lib/constants.js'; + +describe('majorMinor', () => { + it('reduces a version to its major.minor line', () => { + assert.strictEqual(majorMinor('2.69.16'), '2.69'); + assert.strictEqual(majorMinor('0.1.11'), '0.1'); + assert.strictEqual(majorMinor('10.20.30'), '10.20'); + }); + + it('ignores prerelease and build metadata', () => { + assert.strictEqual(majorMinor('6.19.0-rc.0'), '6.19'); + assert.strictEqual(majorMinor('1.2.3+build.5'), '1.2'); + }); + + it('accepts a leading v', () => { + assert.strictEqual(majorMinor('v6.17.1'), '6.17'); + }); + + it('rejects anything that is not a full semver version', () => { + for (const bad of ['garbage', '1.2', '', '01.2.3', '1.2.3.4']) { + assert.throws(() => majorMinor(bad), /Invalid semver version/, `expected "${bad}" to throw`); + } + }); +}); + +describe('appForPackageName', () => { + it('finds a public app', () => { + assert.strictEqual(appForPackageName('@tryghost/comments-ui').configKey, 'comments'); + }); + + it('throws for a package that is not an app', () => { + assert.throws(() => appForPackageName('@tryghost/admin'), /not found in public-apps/); + }); +}); + +describe('paths', () => { + it('resolves defaults.json to a file that exists', () => { + assert.ok(existsSync(DEFAULTS_PATH)); + }); + + it('derives the absolute path from the repo-relative one', () => { + assert.ok(DEFAULTS_PATH.endsWith(`/${DEFAULTS_REPO_PATH}`)); + }); +}); + +describe('the major.minor invariant', () => { + // This is the rule release-apps writes and check-app-version-bump verifies. + // If the repo ever violates it, one of those two is broken — or someone + // hand-edited a version without running "pnpm ship". + it('holds for every public app in the repo right now', async () => { + const defaults = await readDefaults(); + + for (const app of PUBLIC_APPS) { + const {version} = await readJson(join(ROOT_DIR, app.path, 'package.json')); + + assert.strictEqual( + defaults[app.configKey].version, + majorMinor(version), + `${app.packageName}: package.json is ${version}, defaults.json says ${defaults[app.configKey].version}` + ); + } + }); +}); diff --git a/scripts/test/resolve-base-tag.test.cjs b/scripts/test/resolve-base-tag.test.cjs index a140df32bea..989f3c16452 100644 --- a/scripts/test/resolve-base-tag.test.cjs +++ b/scripts/test/resolve-base-tag.test.cjs @@ -5,7 +5,7 @@ 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.cjs'); +const {resolveBaseTag} = require('../lib/resolve-base-tag.js'); /** * Create a temporary git repo with semver tags for testing.