mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
De-spagetti-ify release-tool
Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
1 parent
b10383fba6
commit
671fc3052a
3 files changed
+552
-192
No files matched your search
@@ -16,11 +16,13 @@
|
||||
"devDependencies": {
|
||||
"@swc/cli": "^0.1.61",
|
||||
"@swc/core": "^1.3.35",
|
||||
"@types/inquirer": "^9.0.3",
|
||||
"@types/node": "^16.18.11",
|
||||
"@types/semver": "^7.3.13",
|
||||
"rimraf": "^4.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"inquirer": "^9.1.4",
|
||||
"semver": "^7.3.8"
|
||||
}
|
||||
}
|
||||
+233
-178
@@ -3,12 +3,16 @@
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import assert from "assert";
|
||||
import child_process from "child_process";
|
||||
import { readFile } from "fs/promises";
|
||||
import { createInterface } from "readline";
|
||||
import inquirer from "inquirer";
|
||||
import { createInterface, ReadLine } from "readline";
|
||||
import semver from "semver";
|
||||
import { promisify } from "util";
|
||||
|
||||
type SemVer = semver.SemVer;
|
||||
|
||||
const {
|
||||
SemVer,
|
||||
valid: semverValid,
|
||||
@@ -18,53 +22,6 @@ const {
|
||||
const exec = promisify(child_process.exec);
|
||||
const spawn = promisify(child_process.spawn);
|
||||
|
||||
const repoRoot = (await exec("git rev-parse --show-toplevel")).stdout.trim();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const currentVersion = new SemVer(JSON.parse((await readFile("./lerna.json", "utf-8"))).version);
|
||||
|
||||
await spawn("npm", ["run", "bump-version"], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
const newVersion = new SemVer(JSON.parse((await readFile("./lerna.json", "utf-8"))).version);
|
||||
const newVersionMilestone = `${newVersion.major}.${newVersion.minor}.${newVersion.patch}`;
|
||||
const prBranch = `release/v${newVersion.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 ${newVersion.format()}"`], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
await spawn("git", ["fetch", "--tags", "--force"], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
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(semverRcompare)
|
||||
.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 {
|
||||
author: {
|
||||
login: string;
|
||||
@@ -93,156 +50,254 @@ interface ExtendedGithubPrData extends Omit<GithubPrData, "mergedAt"> {
|
||||
mergedAt: Date;
|
||||
}
|
||||
|
||||
console.log("retreiving last 500 PRs to create release PR body...");
|
||||
const mergedPrs = JSON.parse((await exec(getMergedPrsArgs.join(" "), { encoding: "utf-8" })).stdout) as GithubPrData[];
|
||||
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();
|
||||
async function getCurrentBranch(): Promise<string> {
|
||||
return (await exec("git branch --show-current")).stdout.trim();
|
||||
}
|
||||
|
||||
if (leftAge === rightAge) {
|
||||
return 0;
|
||||
}
|
||||
async function getAbsolutePathToRepoRoot(): Promise<string> {
|
||||
return (await exec("git rev-parse --show-toplevel")).stdout.trim();
|
||||
}
|
||||
|
||||
if (leftAge > rightAge) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return -1;
|
||||
async function fetchAllGitTags(): Promise<string[]> {
|
||||
await spawn("git", ["fetch", "--tags", "--force"], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
const enhancementPrLabelName = "enhancement";
|
||||
const bugfixPrLabelName = "bug";
|
||||
return (await exec("git tag --list", { encoding: "utf-8" }))
|
||||
.stdout
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim());
|
||||
}
|
||||
|
||||
const isEnhancementPr = (pr: ExtendedGithubPrData) => pr.labels.some(label => label.name === enhancementPrLabelName);
|
||||
const isBugfixPr = (pr: ExtendedGithubPrData) => pr.labels.some(label => label.name === bugfixPrLabelName);
|
||||
async function bumpPackageVersions(): Promise<void> {
|
||||
await spawn("npm", ["run", "bump-version"], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
}
|
||||
|
||||
const prLines = {
|
||||
enhancement: [] as string[],
|
||||
bugfix: [] as string[],
|
||||
maintenence: [] as string[],
|
||||
};
|
||||
function findClosestVersionTagLessThanVersion(tags: string[], version: SemVer): string {
|
||||
const lessThanTags = tags
|
||||
.map((value) => semverValid(value))
|
||||
.filter((v): v is string => typeof v === "string")
|
||||
.sort(semverRcompare)
|
||||
.filter(version => semverLte(version, version));
|
||||
|
||||
function getPrEntry(pr: ExtendedGithubPrData) {
|
||||
assert(lessThanTags.length > 0, `Cannot find version tag less than ${version.format()}`);
|
||||
|
||||
return lessThanTags[0];
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
await spawn("gh", [
|
||||
"pr",
|
||||
"create",
|
||||
"--base", prBase,
|
||||
"--title", `Release ${version.format()}`,
|
||||
"--label", "skip-changelog",
|
||||
"--label", "release",
|
||||
"--milestone", formatSemverForMilestone(version),
|
||||
"--body-file", prBody,
|
||||
], {
|
||||
stdio: "inherit"
|
||||
});
|
||||
}
|
||||
|
||||
function sortExtendedGithubPrData(left: ExtendedGithubPrData, right: ExtendedGithubPrData): number {
|
||||
const leftAge = left.mergedAt.valueOf();
|
||||
const rightAge = right.mergedAt.valueOf();
|
||||
|
||||
if (leftAge === rightAge) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (leftAge > rightAge) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
async function getRelaventPRs(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}`;
|
||||
}
|
||||
|
||||
const rl = createInterface(process.stdin);
|
||||
const prBase = (await exec("git branch --show-current")).stdout.trim();
|
||||
const isEnhancementPr = (pr: ExtendedGithubPrData) => pr.labels.some(label => label.name === "enhancement");
|
||||
const isBugfixPr = (pr: ExtendedGithubPrData) => pr.labels.some(label => label.name === "bug");
|
||||
|
||||
function askQuestion(question: string): Promise<boolean> {
|
||||
return new Promise<boolean>(resolve => {
|
||||
function _askQuestion() {
|
||||
console.log(question);
|
||||
|
||||
rl.once("line", (answer) => {
|
||||
const cleaned = answer.trim().toLowerCase();
|
||||
|
||||
if (cleaned === "y") {
|
||||
resolve(true);
|
||||
} else if (cleaned === "n") {
|
||||
resolve(false);
|
||||
} else {
|
||||
_askQuestion();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_askQuestion();
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRelaventPr(pr: ExtendedGithubPrData) {
|
||||
if (prBase !== "master" && !(await askQuestion(`Would you like to use #${pr.number}: ${pr.title}? - Y/N`))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (prBase !== "master") {
|
||||
const cherrypickCommitWith = (rl: ReadLine) => async (commit: string) => {
|
||||
try {
|
||||
await spawn("git", ["cherry-pick", pr.mergeCommit.oid], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
} 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()));
|
||||
await spawn("git", ["cherry-pick", commit], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
} catch {
|
||||
console.error(`Please resolve conflicts and then press enter here...`);
|
||||
await new Promise<void>(resolve => rl.once("line", () => resolve()));
|
||||
}
|
||||
};
|
||||
|
||||
async function pickWhichCommitsToUse(prs: ExtendedGithubPrData[]): Promise<ExtendedGithubPrData[]> {
|
||||
console.log("Pick which commits to use...");
|
||||
const answers = await inquirer.prompt<Partial<Record<number, boolean>>>(prs.map(pr => ({
|
||||
message: `#${pr.number}: ${pr.title}`,
|
||||
type: "checkbox",
|
||||
default: true,
|
||||
name: pr.number,
|
||||
})));
|
||||
|
||||
return prs.filter(pr => answers[pr.number]);
|
||||
}
|
||||
|
||||
function formatChangelog(previousReleasedVersion: string, prs: ExtendedGithubPrData[]): string {
|
||||
const enhancementPrLines: string[] = [];
|
||||
const bugPrLines: string[] = [];
|
||||
const maintenencePrLines: string[] = [];
|
||||
|
||||
for (const pr of prs) {
|
||||
if (isEnhancementPr(pr)) {
|
||||
enhancementPrLines.push(formatPrEntry(pr));
|
||||
} else if (isBugfixPr(pr)) {
|
||||
bugPrLines.push(formatPrEntry(pr));
|
||||
} else {
|
||||
maintenencePrLines.push(formatPrEntry(pr));
|
||||
}
|
||||
}
|
||||
|
||||
if (isEnhancementPr(pr)) {
|
||||
prLines.enhancement.push(getPrEntry(pr));
|
||||
} else if (isBugfixPr(pr)) {
|
||||
prLines.bugfix.push(getPrEntry(pr));
|
||||
} else {
|
||||
prLines.maintenence.push(getPrEntry(pr));
|
||||
if (enhancementPrLines.length > 0) {
|
||||
enhancementPrLines.unshift("## 🚀 Features", "");
|
||||
enhancementPrLines.push("");
|
||||
}
|
||||
|
||||
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) {
|
||||
await handleRelaventPr(pr);
|
||||
async function cherrypickCommits(prs: ExtendedGithubPrData[]): Promise<void> {
|
||||
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 = [
|
||||
`## 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 selectedPrs = await pickWhichCommitsToUse(prs);
|
||||
|
||||
await spawn("git", ["push", "--set-upstream", "origin", prBranch], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
await cherrypickCommits(selectedPrs);
|
||||
|
||||
await spawn("gh", [
|
||||
"pr",
|
||||
"create",
|
||||
"--base", prBase,
|
||||
"--title", `Release ${newVersion.format()}`,
|
||||
"--label", "skip-changelog",
|
||||
"--label", "release",
|
||||
"--milestone", `${newVersion.major}.${newVersion.minor}.${newVersion.patch}`,
|
||||
"--body-file", prBody,
|
||||
], {
|
||||
stdio: "inherit"
|
||||
});
|
||||
return selectedPrs;
|
||||
}
|
||||
|
||||
async function createRelease(): Promise<void> {
|
||||
await checkCurrentWorkingDirectory();
|
||||
|
||||
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 getRelaventPRs(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();
|
||||
Reference in new issue
Block a user