Files
backstage/scripts/prepare-release.js
Fredrik Adelöw 7455dae884 require the use of node prefix on native imports
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
2026-01-26 13:22:53 +01:00

411 lines
12 KiB
JavaScript
Executable File

#!/usr/bin/env node
/* eslint-disable @backstage/no-undeclared-imports */
/*
* Copyright 2022 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const fs = require('fs-extra');
const semver = require('semver');
const { getPackages } = require('@manypkg/get-packages');
const path = require('node:path');
const { execFile: execFileCb } = require('node:child_process');
const { promisify } = require('node:util');
const { default: parseChangeset } = require('@changesets/parse');
const execFile = promisify(execFileCb);
// All of these are considered to be main-line release branches
const MAIN_BRANCHES = ['master', 'origin/master', 'changeset-release/master'];
// This prefix is used for patch branches, followed by the release version
// For example, `patch/v1.2.0`
const PATCH_BRANCH_PREFIX = 'patch/v';
const DEPENDENCY_TYPES = [
'dependencies',
'devDependencies',
'optionalDependencies',
'peerDependencies',
];
/**
* Finds the current stable release version of the repo, looking at
* the current commit and backwards, finding the first commit were a
* stable version is present.
*/
async function findCurrentReleaseVersion(repo) {
const rootPkgPath = path.resolve(repo.root.dir, 'package.json');
const pkg = await fs.readJson(rootPkgPath);
if (!semver.prerelease(pkg.version)) {
return pkg.version;
}
const { stdout: revListStr } = await execFile('git', [
'rev-list',
'HEAD',
'--',
'package.json',
]);
const revList = revListStr.trim().split(/\r?\n/);
for (const rev of revList) {
const { stdout: pkgJsonStr } = await execFile('git', [
'show',
`${rev}:package.json`,
]);
if (pkgJsonStr) {
const pkgJson = JSON.parse(pkgJsonStr);
if (!semver.prerelease(pkgJson.version)) {
return pkgJson.version;
}
}
}
throw new Error('No stable release found');
}
/**
* Finds the tip of the patch branch of a given release version.
* Returns undefined if no patch branch exists.
*/
async function findTipOfPatchBranch(repo, release) {
try {
await execFile('git', ['fetch', 'origin', PATCH_BRANCH_PREFIX + release], {
shell: true,
cwd: repo.root.dir,
});
} catch (error) {
if (error.stderr?.match(/fatal: couldn't find remote ref/i)) {
return undefined;
}
throw error;
}
const { stdout: refStr } = await execFile('git', ['rev-parse', 'FETCH_HEAD']);
return refStr.trim();
}
/**
* Returns a map of packages to their versions for any package version
* in <ref> that does not match the current version in the working directory.
*/
async function detectPatchVersionsBetweenRefs(repo, baseRef, ref) {
const patchVersions = new Map();
for (const pkg of repo.packages) {
const pkgJsonPath = path.join(
path.relative(repo.root.dir, pkg.dir),
'package.json',
);
try {
const { stdout: basePkgJsonStr } = await execFile('git', [
'show',
`${baseRef}:${pkgJsonPath}`,
]);
const { stdout: pkgJsonStr } = await execFile('git', [
'show',
`${ref}:${pkgJsonPath}`,
]);
if (basePkgJsonStr && pkgJsonStr) {
const basePkgJson = JSON.parse(basePkgJsonStr);
const releasePkgJson = JSON.parse(pkgJsonStr);
if (releasePkgJson.private) {
continue;
}
if (releasePkgJson.name !== basePkgJson.name) {
throw new Error(
`Mismatched package name at ${pkg.dir}, ${releasePkgJson.name} !== ${basePkgJson.name}`,
);
}
if (releasePkgJson.version !== basePkgJson.version) {
patchVersions.set(basePkgJson.name, releasePkgJson.version);
}
}
} catch (error) {
if (
error.stderr?.match(/^fatal: Path .* exists on disk, but not in .*$/im)
) {
console.log(`Skipping new package ${pkg.packageJson.name}`);
continue;
}
throw error;
}
}
return patchVersions;
}
/**
* Bumps up the versions of packages to account for
* the base versions that are set in .changeset/patched.json.
* This may be needed when we have made emergency releases.
*/
async function applyPatchVersions(repo, patchVersions) {
const pendingVersionBumps = new Map();
for (const [name, patchVersion] of patchVersions) {
const pkg = repo.packages.find(p => p.packageJson.name === name);
if (!pkg) {
throw new Error(`Package ${name} not found`);
}
if (!semver.valid(patchVersion)) {
throw new Error(
`Invalid base version ${patchVersion} for package ${name}`,
);
}
if (semver.gte(pkg.packageJson.version, patchVersion)) {
console.log(
`No need to bump ${name} ${pkg.packageJson.version} is already ahead of ${patchVersion}`,
);
continue;
}
let targetVersion = patchVersion;
// If we're currently in a pre-release we need to manually execute the
// patch bump up to the next version. And we also need to make sure we
// resume the releases at the same pre-release tag.
const currentPrerelease = semver.prerelease(pkg.packageJson.version);
if (currentPrerelease) {
const parsed = semver.parse(targetVersion);
parsed.inc('patch');
parsed.prerelease = currentPrerelease;
targetVersion = parsed.format();
}
pendingVersionBumps.set(name, {
targetVersion,
targetRange: `^${targetVersion}`,
});
}
for (const { dir, packageJson } of [repo.root, ...repo.packages]) {
let hasChanges = false;
if (pendingVersionBumps.has(packageJson.name)) {
packageJson.version = pendingVersionBumps.get(
packageJson.name,
).targetVersion;
hasChanges = true;
}
for (const depType of DEPENDENCY_TYPES) {
const deps = packageJson[depType];
for (const depName of Object.keys(deps ?? {})) {
const currentRange = deps[depName];
if (
currentRange === '*' ||
currentRange === '' ||
currentRange.startsWith('workspace:')
) {
continue;
}
if (pendingVersionBumps.has(depName)) {
const pendingBump = pendingVersionBumps.get(depName);
console.log(
`Replacing ${depName} ${currentRange} with ${pendingBump.targetRange} in ${depType} of ${packageJson.name}`,
);
deps[depName] = pendingBump.targetRange;
hasChanges = true;
}
}
}
if (hasChanges) {
await fs.writeJson(path.resolve(dir, 'package.json'), packageJson, {
spaces: 2,
encoding: 'utf8',
});
}
}
}
/**
* Detects any patched packages version since the most recent release on
* the main branch, and then bumps all packages in the repo accordingly.
*/
async function updatePackageVersions(repo) {
const currentRelease = await findCurrentReleaseVersion(repo);
console.log(`Current release version: ${currentRelease}`);
const patchRef = await findTipOfPatchBranch(repo, currentRelease);
if (patchRef) {
console.log(`Tip of the patch branch: ${patchRef}`);
const patchVersions = await detectPatchVersionsBetweenRefs(
repo,
`v${currentRelease}`,
patchRef,
);
if (patchVersions.size > 0) {
console.log(
`Found ${patchVersions.size} packages that were patched since the last release`,
);
for (const [name, version] of patchVersions) {
console.log(` ${name}: ${version}`);
}
await applyPatchVersions(repo, patchVersions);
} else {
console.log('No packages were patched since the last release');
}
} else {
console.log('No patch branch found');
}
}
/**
* Returns the mode and tag that is currently set
* in the .changeset/pre.json file
*/
async function getPreInfo(repo) {
const pre = path.join(repo.root.dir, '.changeset', 'pre.json');
if (!(await fs.pathExists(pre))) {
return { mode: undefined, tag: undefined };
}
const { mode, tag } = await fs.readJson(pre);
return { mode, tag };
}
/**
* Returns the name of the current git branch
*/
async function getCurrentBranch(repo) {
const { stdout } = await execFile(
'git',
['rev-parse', '--abbrev-ref', 'HEAD'],
{ cwd: repo.root.dir, shell: true },
);
return stdout.trim();
}
/**
* Bumps the release version in the root package.json.
*
* This takes into account whether we're in pre-release mode or on a patch branch.
*/
async function updateBackstageReleaseVersion(repo, type) {
const { mode: preMode, tag: preTag } = await getPreInfo(repo);
const { version: currentVersion } = repo.root.packageJson;
let nextVersion;
if (type === 'minor') {
if (preMode === 'pre') {
if (semver.prerelease(currentVersion)) {
nextVersion = semver.inc(currentVersion, 'pre', preTag);
} else {
nextVersion = semver.inc(currentVersion, 'preminor', preTag);
}
} else if (preMode === 'exit') {
nextVersion = semver.inc(currentVersion, 'patch');
} else {
nextVersion = semver.inc(currentVersion, 'minor');
}
} else if (type === 'patch') {
if (preMode) {
throw new Error(`Unexpected pre mode ${preMode} on current branch`);
}
nextVersion = semver.inc(currentVersion, 'patch');
}
await fs.writeJson(
path.join(repo.root.dir, 'package.json'),
{
...repo.root.packageJson,
version: nextVersion,
},
{ spaces: 2, encoding: 'utf8' },
);
}
/**
* Ensures that the changesets include a version bump of create-app otherwise
* generates a new patch changeset for create-app.
*/
async function ensureCreateAppChangeset() {
const changesetPath = path.resolve(__dirname, '../.changeset');
const fileNames = await fs.readdir(changesetPath);
const changesetNames = fileNames.filter(
name => name.endsWith('.md') && name !== 'README.md',
);
const changesets = await Promise.all(
changesetNames.map(async name => {
const content = await fs.readFile(path.join(changesetPath, name), 'utf8');
return { name, ...parseChangeset(content) };
}),
);
const excludeList = [];
const prePath = path.resolve(changesetPath, 'pre.json');
if (await fs.pathExists(prePath)) {
const data = await fs.readJSON(prePath);
// Only exclude changesets in pre-release mode.
if (data.mode === 'pre') {
excludeList.push(...data.changesets.map(name => `${name}.md`));
}
}
const hasCreateAppChanges = changesets
.filter(({ name }) => !excludeList.includes(name))
.some(changeset =>
changeset.releases.some(
release => release.name === '@backstage/create-app',
),
);
if (hasCreateAppChanges) {
console.log(
'Contains create-app changeset, no need to create additional changeset',
);
return;
}
const ts = Math.round(new Date().getTime() / 1000);
const fileName = `create-app-${ts}.md`;
console.log(`Creating ${fileName}`);
const data = `---
'@backstage/create-app': patch
---\n
Bumped create-app version.\n`;
await fs.writeFile(path.join(changesetPath, fileName), data);
}
async function main() {
const repo = await getPackages(__dirname);
const branchName = await getCurrentBranch(repo);
const isMainBranch = MAIN_BRANCHES.includes(branchName);
console.log(`Current branch: ${branchName}`);
if (isMainBranch) {
console.log('Main release, updating package versions');
await updatePackageVersions(repo);
await ensureCreateAppChangeset();
}
await updateBackstageReleaseVersion(repo, isMainBranch ? 'minor' : 'patch');
}
main().catch(error => {
console.error(error.stack);
process.exit(1);
});