1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Various improvements to release-tool (#7232)

* Various improvements to release-tool

- Pass more IO from script to user to provide better UX
- Interactive versioning using lerna directly
- Remove all CMD args in favour of interactive

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Remove some more unnecessary console logs

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Resolve comments

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix repoRoot issue

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* De-spagetti-ify release-tool

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix bugs related to picking PRs

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix name

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Improve display after picking PRs

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Rename pickWhichPRsToUse

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Add line describing what to do

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix not displaying output after cherry-pick fails

Signed-off-by: Sebastian Malton <sebastian@malton.name>

---------

Signed-off-by: Sebastian Malton <sebastian@malton.name>
Co-authored-by: Roman <ixrock@gmail.com>
Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2023-02-24 13:47:53 -08:00
parent 9959d6d4db
commit f75a81f498
2 changed files with 273 additions and 246 deletions

View File

@ -16,15 +16,14 @@
"devDependencies": { "devDependencies": {
"@swc/cli": "^0.1.61", "@swc/cli": "^0.1.61",
"@swc/core": "^1.3.35", "@swc/core": "^1.3.35",
"@types/command-line-args": "^5.2.0", "@types/inquirer": "^9.0.3",
"@types/fs-extra": "^11.0.1",
"@types/node": "^16.18.11", "@types/node": "^16.18.11",
"@types/semver": "^7.3.13", "@types/semver": "^7.3.13",
"command-line-args": "^5.2.1", "rimraf": "^4.1.2"
"fs-extra": "^11.1.0",
"semver": "^7.3.8"
}, },
"dependencies": { "dependencies": {
"rimraf": "^4.1.2" "chalk": "^5.2.0",
"inquirer": "^9.1.4",
"semver": "^7.3.8"
} }
} }

View File

@ -3,122 +3,22 @@
* Copyright (c) OpenLens Authors. All rights reserved. * Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import assert from "assert";
import chalk from "chalk";
import child_process from "child_process"; import child_process from "child_process";
import commandLineArgs from "command-line-args"; import { readFile } from "fs/promises";
import fse from "fs-extra"; import inquirer from "inquirer";
import { basename } from "path"; import { createInterface, ReadLine } from "readline";
import { createInterface } from "readline";
import semver from "semver"; import semver from "semver";
import { promisify } from "util"; import { promisify } from "util";
const { type SemVer = semver.SemVer;
SemVer,
valid: semverValid, const { SemVer } = semver;
rcompare: semverRcompare,
lte: semverLte,
} = semver;
const exec = promisify(child_process.exec); const exec = promisify(child_process.exec);
const spawn = promisify(child_process.spawn);
const execFile = promisify(child_process.execFile); const execFile = promisify(child_process.execFile);
const options = commandLineArgs([
{
name: "type",
defaultOption: true,
},
{
name: "preid",
},
{
name: "check-commits",
type: Boolean,
},
]);
const validReleaseValues = [
"major",
"minor",
"patch",
];
const validPrereleaseValues = [
"premajor",
"preminor",
"prepatch",
"prerelease",
];
const validPreidValues = [
"alpha",
"beta",
];
const errorMessages = {
noReleaseType: `No release type provided. Valid options are: ${[...validReleaseValues, ...validPrereleaseValues].join(", ")}`,
invalidRelease: (invalid: string) => `Invalid release type was provided (value was "${invalid}"). Valid options are: ${[...validReleaseValues, ...validPrereleaseValues].join(", ")}`,
noPreid: `No preid was provided. Use '--preid' to specify. Valid options are: ${validPreidValues.join(", ")}`,
invalidPreid: (invalid: string) => `Invalid preid was provided (value was "${invalid}"). Valid options are: ${validPreidValues.join(", ")}`,
wrongCwd: "It looks like you are running this script from the 'scripts' directory. This script assumes it is run from the root of the git repo",
};
if (!options.type) {
console.error(errorMessages.noReleaseType);
process.exit(1);
}
if (validReleaseValues.includes(options.type)) {
// do nothing, is valid
} else if (validPrereleaseValues.includes(options.type)) {
if (!options.preid) {
console.error(errorMessages.noPreid);
process.exit(1);
}
if (!validPreidValues.includes(options.preid)) {
console.error(errorMessages.invalidPreid(options.preid));
process.exit(1);
}
} else {
console.error(errorMessages.invalidRelease(options.type));
process.exit(1);
}
if (basename(process.cwd()) === "scripts") {
console.error(errorMessages.wrongCwd);
}
const currentVersion = new SemVer((await fse.readJson("./lerna.json")).version);
console.log(`current version: ${currentVersion.format()}`);
const newVersion = currentVersion.inc(options.type, options.preid);
const newVersionMilestone = `${newVersion.major}.${newVersion.minor}.${newVersion.patch}`;
const prBranch = `release/v${newVersion.format()}`;
await exec(`yarn run bump-version --yes ${newVersion.format()} --force-publish`);
await exec(`git checkout -b ${prBranch}`);
await exec("git add lerna.json packages/*/package.json");
await exec(`git commit -sm "Release ${newVersion.format()}"`);
console.log(`new version: ${newVersion.format()}`);
console.log("fetching tags...");
await exec("git fetch --tags --force");
const actualTags = (await exec("git tag --list", { encoding: "utf-8" })).stdout.split(/\r?\n/).map(line => line.trim());
const [previousReleasedVersion] = actualTags
.map((value) => semverValid(value))
.filter((v): v is string => typeof v === "string")
.sort((l, r) => semverRcompare(l, r))
.filter(version => semverLte(version, currentVersion));
const getMergedPrsArgs = [
"gh",
"pr",
"list",
"--limit=500", // Should be big enough, if not we need to release more often ;)
"--state=merged",
"--base=master",
"--json mergeCommit,title,author,labels,number,milestone,mergedAt",
];
interface GithubPrData { interface GithubPrData {
author: { author: {
login: string; login: string;
@ -147,168 +47,296 @@ interface ExtendedGithubPrData extends Omit<GithubPrData, "mergedAt"> {
mergedAt: Date; mergedAt: Date;
} }
console.log("retreiving last 500 PRs to create release PR body..."); async function getCurrentBranch(): Promise<string> {
const mergedPrs = JSON.parse((await exec(getMergedPrsArgs.join(" "), { encoding: "utf-8" })).stdout) as GithubPrData[]; return (await exec("git branch --show-current")).stdout.trim();
const milestoneRelevantPrs = mergedPrs.filter(pr => pr.milestone?.title === newVersionMilestone); }
const relaventPrsQuery = await Promise.all(
milestoneRelevantPrs.map(async pr => ({
pr,
stdout: (await exec(`git tag v${previousReleasedVersion} --no-contains ${pr.mergeCommit.oid}`)).stdout,
})),
);
const relaventPrs = relaventPrsQuery
.filter(query => query.stdout)
.map(query => query.pr)
.filter(pr => pr.labels.every(label => label.name !== "skip-changelog"))
.map(pr => ({ ...pr, mergedAt: new Date(pr.mergedAt) } as ExtendedGithubPrData))
.sort((left, right) => {
const leftAge = left.mergedAt.valueOf();
const rightAge = right.mergedAt.valueOf();
if (leftAge === rightAge) { async function getAbsolutePathToRepoRoot(): Promise<string> {
return 0; return (await exec("git rev-parse --show-toplevel")).stdout.trim();
} }
if (leftAge > rightAge) { async function fetchAllGitTags(): Promise<string[]> {
return 1; await execFile("git", ["fetch", "--tags", "--force"]);
}
return -1; const { stdout } = await exec("git tag --list", { encoding: "utf-8" });
return stdout
.split(/\r?\n/)
.map(line => line.trim());
}
async function bumpPackageVersions(): Promise<void> {
await spawn("npm", ["run", "bump-version"], {
stdio: "inherit",
});
}
function isDefined<T>(value: T | null | undefined): value is T {
return value != null;
}
function findClosestVersionTagLessThanVersion(tags: string[], version: SemVer): string {
const lessThanTags = tags
.map((value) => semver.parse(value))
.filter(isDefined)
.filter(version => !version.prerelease.includes("cron"))
.sort(semver.rcompare)
.filter(version => semver.lte(version, version));
assert(lessThanTags.length > 0, `Cannot find version tag less than ${version.format()}`);
return lessThanTags[0].format();
}
async function getCurrentVersionOfSubPackage(packageName: string): Promise<SemVer> {
const packageJson = JSON.parse(await readFile(`./packages/${packageName}/package.json`, "utf-8"));
return new SemVer(packageJson.version);
}
async function checkCurrentWorkingDirectory(): Promise<void> {
const repoRoot = await getAbsolutePathToRepoRoot();
if (process.cwd() !== repoRoot) {
console.error("It looks like you are running this script from the 'scripts' directory. This script assumes it is run from the root of the git repo");
process.exit(1);
}
}
function formatSemverForMilestone(version: SemVer): string {
return `${version.major}.${version.minor}.${version.patch}`;
}
async function createReleaseBranchAndCommit(prBase: string, version: SemVer, prBody: string): Promise<void> {
const prBranch = `release/v${version.format()}`;
await spawn("git", ["checkout", "-b", prBranch], {
stdio: "inherit",
});
await spawn("git", ["add", "lerna.json", "packages/*/package.json"], {
stdio: "inherit",
});
await spawn("git", ["commit", "-sm", `"Release ${version.format()}"`], {
stdio: "inherit",
});
await spawn("git", ["push", "--set-upstream", "origin", prBranch], {
stdio: "inherit",
}); });
const enhancementPrLabelName = "enhancement"; await spawn("gh", [
const bugfixPrLabelName = "bug"; "pr",
"create",
"--base", prBase,
"--title", `Release ${version.format()}`,
"--label", "skip-changelog",
"--label", "release",
"--milestone", formatSemverForMilestone(version),
"--body-file", prBody,
], {
stdio: "inherit"
});
}
const isEnhancementPr = (pr: ExtendedGithubPrData) => pr.labels.some(label => label.name === enhancementPrLabelName); function sortExtendedGithubPrData(left: ExtendedGithubPrData, right: ExtendedGithubPrData): number {
const isBugfixPr = (pr: ExtendedGithubPrData) => pr.labels.some(label => label.name === bugfixPrLabelName); const leftAge = left.mergedAt.valueOf();
const rightAge = right.mergedAt.valueOf();
const prLines = { if (leftAge === rightAge) {
enhancement: [] as string[], return 0;
bugfix: [] as string[], }
maintenence: [] as string[],
};
function getPrEntry(pr: ExtendedGithubPrData) { if (leftAge > rightAge) {
return 1;
}
return -1;
}
async function getRelevantPRs(milestone: string, previousReleasedVersion: string): Promise<ExtendedGithubPrData[]> {
console.log("retreiving previous 500 PRs...");
const getMergedPrsArgs = [
"gh",
"pr",
"list",
"--limit=500", // Should be big enough, if not we need to release more often ;)
"--state=merged",
"--base=master",
"--json mergeCommit,title,author,labels,number,milestone,mergedAt",
];
const mergedPrs = JSON.parse((await exec(getMergedPrsArgs.join(" "), { encoding: "utf-8" })).stdout) as GithubPrData[];
const milestoneRelevantPrs = mergedPrs.filter(pr => pr.milestone?.title === milestone);
const relaventPrsQuery = await Promise.all(
milestoneRelevantPrs.map(async pr => ({
pr,
stdout: (await exec(`git tag v${previousReleasedVersion} --no-contains ${pr.mergeCommit.oid}`)).stdout,
})),
);
return relaventPrsQuery
.filter(query => query.stdout)
.map(query => query.pr)
.filter(pr => pr.labels.every(label => label.name !== "skip-changelog"))
.map(pr => ({ ...pr, mergedAt: new Date(pr.mergedAt) } as ExtendedGithubPrData))
.sort(sortExtendedGithubPrData);
}
function formatPrEntry(pr: ExtendedGithubPrData) {
return `- ${pr.title} (**[#${pr.number}](https://github.com/lensapp/lens/pull/${pr.number})**) https://github.com/${pr.author.login}`; return `- ${pr.title} (**[#${pr.number}](https://github.com/lensapp/lens/pull/${pr.number})**) https://github.com/${pr.author.login}`;
} }
const rl = createInterface(process.stdin); const isEnhancementPr = (pr: ExtendedGithubPrData) => pr.labels.some(label => label.name === "enhancement");
const prBase = newVersion.patch === 0 const isBugfixPr = (pr: ExtendedGithubPrData) => pr.labels.some(label => label.name === "bug");
? "master"
: `release/v${newVersion.major}.${newVersion.minor}`;
function askQuestion(question: string): Promise<boolean> { const cherrypickCommitWith = (rl: ReadLine) => async (commit: string) => {
return new Promise<boolean>(resolve => { try {
function _askQuestion() { const cherryPick = child_process.spawn("git", ["cherry-pick", commit]);
console.log(question);
rl.once("line", (answer) => { cherryPick.stdout.pipe(process.stdout);
const cleaned = answer.trim().toLowerCase(); cherryPick.stderr.pipe(process.stderr);
if (cleaned === "y") { await new Promise<void>((resolve, reject) => {
resolve(true); const cleaners: (() => void)[] = [];
} else if (cleaned === "n") { const cleanup = () => cleaners.forEach(cleaner => cleaner());
resolve(false);
} else { const onExit = (code: number | null) => {
_askQuestion(); if (code) {
reject(new Error(`git cherry-pick failed with exit code ${code}`));
cleanup();
} }
});
}
_askQuestion(); resolve();
cleanup();
};
cherryPick.once("exit", onExit);
cleaners.push(() => cherryPick.off("exit", onExit));
const onError = (error: Error) => {
cleanup();
reject(error);
};
cherryPick.once("error", onError);
cleaners.push(() => cherryPick.off("error", onError));
});
} catch {
console.error(chalk.bold("Please resolve conflicts in a seperate terminal and then press enter here..."));
await new Promise<void>(resolve => rl.once("line", () => resolve()));
}
};
async function pickWhichPRsToUse(prs: ExtendedGithubPrData[]): Promise<ExtendedGithubPrData[]> {
const answers = await inquirer.prompt<{ commits: number[] }>({
type: "checkbox",
name: `commits`,
message: "Pick which commits to use...",
default: [],
choices: prs.map(pr => ({
checked: true,
key: pr.number,
name: `#${pr.number}: ${pr.title} (https://github.com/lensapp/lens/pull/${pr.number})`,
value: pr.number,
short: `#${pr.number}`,
})),
loop: false,
}); });
return prs.filter(pr => answers.commits.includes(pr.number));
} }
async function handleRelaventPr(pr: ExtendedGithubPrData) { function formatChangelog(previousReleasedVersion: string, prs: ExtendedGithubPrData[]): string {
if (options["check-commits"] && !(await askQuestion(`Would you like to use #${pr.number}: ${pr.title}? - Y/N`))) { const enhancementPrLines: string[] = [];
return; const bugPrLines: string[] = [];
} const maintenencePrLines: string[] = [];
if (prBase !== "master") { for (const pr of prs) {
try { if (isEnhancementPr(pr)) {
const promise = exec(`git cherry-pick ${pr.mergeCommit.oid}`); enhancementPrLines.push(formatPrEntry(pr));
} else if (isBugfixPr(pr)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion bugPrLines.push(formatPrEntry(pr));
promise.child.stdout!.pipe(process.stdout); } else {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion maintenencePrLines.push(formatPrEntry(pr));
promise.child.stderr!.pipe(process.stderr);
await promise;
} catch {
console.error(`Failed to cherry-pick ${pr.mergeCommit.oid}, please resolve conflicts and then press enter here:`);
await new Promise<void>(resolve => rl.once("line", () => resolve()));
} }
} }
if (isEnhancementPr(pr)) { if (enhancementPrLines.length > 0) {
prLines.enhancement.push(getPrEntry(pr)); enhancementPrLines.unshift("## 🚀 Features", "");
} else if (isBugfixPr(pr)) { enhancementPrLines.push("");
prLines.bugfix.push(getPrEntry(pr));
} else {
prLines.maintenence.push(getPrEntry(pr));
} }
if (bugPrLines.length > 0) {
bugPrLines.unshift("## 🐛 Bug Fixes", "");
bugPrLines.push("");
}
if (maintenencePrLines.length > 0) {
maintenencePrLines.unshift("## 🧰 Maintenance", "");
maintenencePrLines.push("");
}
return [
`## Changes since ${previousReleasedVersion}`,
"",
...enhancementPrLines,
...bugPrLines,
...maintenencePrLines,
].join("\n");
} }
for (const pr of relaventPrs) { async function cherrypickCommits(prs: ExtendedGithubPrData[]): Promise<void> {
await handleRelaventPr(pr); const rl = createInterface(process.stdin);
const cherrypickCommit = cherrypickCommitWith(rl);
for (const pr of prs) {
await cherrypickCommit(pr.mergeCommit.oid);
}
rl.close();
} }
rl.close(); async function pickRelaventPrs(prs: ExtendedGithubPrData[], isMasterBranch: boolean): Promise<ExtendedGithubPrData[]> {
if (isMasterBranch) {
return prs;
}
const prBodyLines = [ let selectedPrs: ExtendedGithubPrData[];
`## Changes since ${previousReleasedVersion}`,
"",
...(
prLines.enhancement.length > 0
? [
"## 🚀 Features",
"",
...prLines.enhancement,
"",
]
: []
),
...(
prLines.bugfix.length > 0
? [
"## 🐛 Bug Fixes",
"",
...prLines.bugfix,
"",
]
: []
),
...(
prLines.maintenence.length > 0
? [
"## 🧰 Maintenance",
"",
...prLines.maintenence,
"",
]
: []
),
];
const prBody = prBodyLines.join("\n");
const createPrArgs = [
"pr",
"create",
"--base", prBase,
"--title", `Release ${newVersion.format()}`,
"--label", "skip-changelog",
"--label", "release",
"--milestone", `${newVersion.major}.${newVersion.minor}.${newVersion.patch}`,
"--body-file", "-",
];
await exec(`git push --set-upstream origin ${prBranch}`); do {
selectedPrs = await pickWhichPRsToUse(prs);
} while (selectedPrs.length === 0 && (console.warn("[WARNING]: must pick at least once commit"), true));
const createPrProcess = execFile("gh", createPrArgs); await cherrypickCommits(selectedPrs);
createPrProcess.child.stdout?.pipe(process.stdout); return selectedPrs;
createPrProcess.child.stderr?.pipe(process.stderr); }
createPrProcess.child.stdin?.write(prBody); async function createRelease(): Promise<void> {
createPrProcess.child.stdin?.end(); await checkCurrentWorkingDirectory();
await createPrProcess; const currentK8slensCoreVersion = await getCurrentVersionOfSubPackage("core");
const prBase = await getCurrentBranch();
const isMasterBranch = prBase === "master";
const tags = await fetchAllGitTags();
const previousReleasedVersion = findClosestVersionTagLessThanVersion(tags, currentK8slensCoreVersion);
if (isMasterBranch) {
await bumpPackageVersions();
}
const prMilestone = formatSemverForMilestone(await getCurrentVersionOfSubPackage("core"));
const relaventPrs = await getRelevantPRs(prMilestone, previousReleasedVersion);
const selectedPrs = await pickRelaventPrs(relaventPrs, isMasterBranch);
const prBody = formatChangelog(previousReleasedVersion, selectedPrs);
if (!isMasterBranch) {
await bumpPackageVersions();
}
const newK8slensCoreVersion = await getCurrentVersionOfSubPackage("core");
await createReleaseBranchAndCommit(prBase, newK8slensCoreVersion, prBody);
}
await createRelease();