Skip to content

Commit

Permalink
Improve release process (#422)
Browse files Browse the repository at this point in the history
  • Loading branch information
zoontek authored Nov 7, 2023
1 parent 04a3aec commit 84bf38e
Show file tree
Hide file tree
Showing 7 changed files with 233 additions and 86 deletions.
18 changes: 15 additions & 3 deletions .github/workflows/prerelease.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
name: Prerelease

on:
release:
types: [prereleased]
pull_request:
types: [closed]

jobs:
test-and-build:
prerelease:
name: Prerelease
runs-on: ubuntu-latest
if: ${{ github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release-v') }}
env:
CHANGELOG: ${{ github.event.pull_request.body }}
GH_TOKEN: ${{ github.token }}
REF_BRANCH: ${{ github.event.pull_request.head.ref }}
permissions:
contents: read # This is required for actions/checkout
id-token: write # This is required for requesting the JWT
Expand Down Expand Up @@ -77,3 +82,10 @@ jobs:
DEPLOY_GIT_EMAIL: ${{ secrets.DEPLOY_GIT_EMAIL }}
DEPLOY_ENVIRONMENT: preprod
DEPLOY_APP_NAME: partner-frontend

- name: Get version
id: version
run: echo version="$REF_BRANCH" | sed -e 's/release-//g' >> $GITHUB_OUTPUT

- name: Create GitHub prerelease
run: gh release create ${{ steps.version.outputs.version }} --title ${{ steps.version.outputs.version }} --notes "$CHANGELOG" --prerelease
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ on:
types: [released]

jobs:
test-and-build:
release:
name: Release
runs-on: ubuntu-latest
permissions:
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@
"type-env-vars": "tsx scripts/env/writeEnvInterface.ts",
"generate-cookie-key": "tsx scripts/cookie/generateCookieKey.ts",
"deploy-ci": "tsx scripts/deploy/deploy.ts",
"ai-translate": "tsx -r dotenv/config scripts/locales/ai-translate.ts",
"ai-translate": "tsx -r dotenv/config scripts/locales/aiTranslate.ts",
"sort-locales": "tsx scripts/locales/sort.ts",
"remove-unused-locales": "tsx scripts/locales/remove-unused.ts",
"bump": "tsx scripts/version/version.ts",
"remove-unused-locales": "tsx scripts/locales/removeUnused.ts",
"release": "tsx scripts/release/createPullRequest.ts",
"dev-e2e": "tsx -r dotenv/config server/src/index.ts --dev dotenv_config_path=.env.e2e",
"read-e2e-sms": "tsx scripts/twilio/getLastMessages.ts",
"test-e2e": "playwright test",
Expand Down
File renamed without changes.
File renamed without changes.
214 changes: 214 additions & 0 deletions scripts/release/createPullRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import chalk from "chalk";
import childProcess from "node:child_process";
import fs from "node:fs";
import util from "node:util";
import path from "pathe";
import prompts from "prompts";
import semver from "semver";
import { PackageJson } from "type-fest";

const REPOSITORY_URL = "https://github.com/swan-io/swan-partner-frontend";

const logError = (...error: string[]) =>
console.error(`${chalk.red("ERROR")} ${error.join("\n")}` + "\n");

const promisifiedExec = util.promisify(childProcess.exec);

const exec = (cmd: string): Promise<string> =>
promisifiedExec(cmd)
.then(({ stdout, stderr }) => ({
stdout: stdout === '""' ? "" : stdout.trim(),
stderr: stderr === '""' ? "" : stderr.trim(),
}))
.then(({ stdout, stderr }) => stdout || stderr);

const isOk = (promise: Promise<unknown>) => promise.then(() => true).catch(() => false);
const isKo = (promise: Promise<unknown>) => promise.then(() => false).catch(() => true);

const rootDir = path.resolve(__dirname, "../..");
const pkgPath = path.join(rootDir, "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as PackageJson;
const currentVersion = semver.parse(pkg.version);

if (currentVersion == null) {
logError("Invalid current package version");
process.exit(1);
}

const isProgramMissing = (program: string) => isKo(exec(`which ${program}`));

// https://github.com/nvie/git-toolbelt/blob/v1.9.0/git-repo
const isNotGitRepo = () => isKo(exec("git rev-parse --git-dir"));

// https://github.com/nvie/git-toolbelt/blob/v1.9.0/git-current-branch
const getGitBranch = () => exec("git rev-parse --abbrev-ref HEAD");

// https://github.com/nvie/git-toolbelt/blob/v1.9.0/git-is-clean
// https://github.com/nvie/git-toolbelt/blob/v1.9.0/git-show-skipped
const isGitRepoDirty = () =>
Promise.all([
isOk(exec("git diff-index --cached --quiet --ignore-submodules --exit-code HEAD --")),
isOk(exec("! git diff --no-ext-diff --ignore-submodules --quiet --exit-code")),
isOk(exec("nbr=$(git ls-files --other --exclude-standard | wc -l); [ $nbr -gt 0 ]")),
isOk(exec('nbr=$(git ls-files -v | grep "^S" | cut -c3- | wc -l); test $nbr -eq 0')),
]).then(([isIndexClean, hasUnstagedChanges, hasUntrackedFiles, isSkipped]) => {
const isWorktreeClean = !hasUnstagedChanges && !hasUntrackedFiles;
return !isIndexClean || !isWorktreeClean || !isSkipped;
});

const fetchGitRemote = (remote: string) =>
exec(`git fetch ${remote} --tags --prune --prune-tags --force`);

const getLastGitCommitHash = (branch: string) =>
exec(`git log -n 1 ${branch} --pretty=format:"%H"`);

const updateGhPagerConfig = () => exec('gh config set pager "less -F -X"');

const resetGitBranch = (branch: string, remote: string) =>
exec(`git switch -C ${branch} ${remote}/${branch}`);

const getGitChangelogEntries = (from: string | undefined, to: string) =>
exec(`git log ${from != null ? `${from}..${to}` : ""} --pretty="format:%s (%h)"`)
.then(_ => _.split("\n"))
.then(entries =>
entries
.filter(entry => !entry.startsWith("[release]"))
.map(entry => "- " + entry.trim().replace(/["]/g, "*"))
.toReversed(),
);

// https://github.com/nvie/git-toolbelt/blob/v1.9.0/git-local-branch-exists
const hasGitLocalBranch = (branch: string) =>
isOk(exec(`git show-ref --heads --quiet --verify -- "refs/heads/${branch}"`));

// https://github.com/nvie/git-toolbelt/blob/v1.9.0/git-remote-branch-exists
const hasGitRemoteBranch = (branch: string, remote: string) =>
isOk(exec(`git show-ref --quiet --verify -- "refs/remotes/${remote}/${branch}"`));

const getWorkspacePackages = () =>
exec("yarn --json workspaces info")
.then(_ => JSON.parse(_) as { data: string })
.then(_ => JSON.parse(_.data) as Record<string, { location: string }>);

const gitCheckoutNewBranch = (branch: string) => exec(`git checkout -b ${branch}`);
const gitAddAll = () => exec("git add . -u");
const gitCommit = (message: string) => exec(`git commit -m "${message}"`);
const gitPush = (branch: string, remote: string) => exec(`git push -u ${remote} ${branch}`);
const gitCheckout = (branch: string) => exec(`git checkout ${branch}`);
const gitDeleteLocalBranch = (branch: string) => exec(`git branch -D ${branch}`);

const createGhPullRequest = (title: string, notes: string) =>
exec(`gh pr create -t "${title}" -b "${notes}"`);

void (async () => {
if (await isProgramMissing("git")) {
logError("git needs to be installed", "https://git-scm.com");
process.exit(1);
}
if (await isProgramMissing("gh")) {
logError("gh needs to be installed", "https://cli.github.com");
process.exit(1);
}
if (await isProgramMissing("yarn")) {
logError("yarn needs to be installed", "https://classic.yarnpkg.com");
process.exit(1);
}

if (await isNotGitRepo()) {
logError("Must be in a git repo");
process.exit(1);
}
if ((await getGitBranch()) !== "main") {
logError("Must be on branch main");
process.exit(1);
}
if (await isGitRepoDirty()) {
logError("Working dir must be clean", "Please stage and commit your changes");
process.exit(1);
}

await fetchGitRemote("origin");

if ((await getLastGitCommitHash("main")) !== (await getLastGitCommitHash("origin/main"))) {
logError("main is not in sync with origin/main");
process.exit(1);
}

await resetGitBranch("main", "origin");

console.log(`🚀 Let's release ${pkg.name} (currently at ${currentVersion.raw})`);

const currentVersionTag = `v${currentVersion.raw}`;
const changelogEntries = await getGitChangelogEntries(currentVersionTag, "main");

if (changelogEntries.length > 0) {
console.log("\n" + chalk.bold("What's Changed"));
console.log(changelogEntries.join("\n") + "\n");
}

const patch = semver.inc(currentVersion, "patch");
const minor = semver.inc(currentVersion, "minor");
const major = semver.inc(currentVersion, "major");

const response = await prompts({
type: "select",
name: "value",
message: "Select increment (next version)",
initial: 0, // default is patch
choices: [
{ title: `patch (${patch})`, value: patch },
{ title: `minor (${minor})`, value: minor },
{ title: `major (${major})`, value: major },
],
});

const nextVersion = semver.parse(response.value as string);

if (nextVersion == null) {
process.exit(1); // user cancelled
}

const releaseTag = `v${nextVersion.raw}`;
const releaseBranch = `release-${releaseTag}`;

if (await hasGitLocalBranch(releaseBranch)) {
logError(`${releaseBranch} branch already exists`);
process.exit(1);
}
if (await hasGitRemoteBranch(releaseBranch, "origin")) {
logError(`origin/${releaseBranch} branch already exists`);
process.exit(1);
}

pkg["version"] = nextVersion.raw;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");

const packages = await getWorkspacePackages();

Object.entries(packages).forEach(([, { location }]) => {
const pkgPath = path.join(rootDir, location, "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as PackageJson;

pkg["version"] = nextVersion.raw;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf-8");
});

await gitCheckoutNewBranch(releaseBranch);
await gitAddAll();
await gitCommit(releaseTag);
await gitPush(releaseBranch, "origin");

const releaseNotes = [
...(changelogEntries.length > 0 ? ["## What's Changed", changelogEntries.join("\n")] : []),
`**Full Changelog**: ${REPOSITORY_URL}/compare/${currentVersionTag}...${releaseTag}`,
].join("\n\n");

await updateGhPagerConfig();
const url = await createGhPullRequest(releaseTag, releaseNotes);

console.log("\n" + chalk.bold("✨ Pull request created:"));
console.log(url + "\n");

await gitCheckout("main");
await gitDeleteLocalBranch(releaseBranch);
})();
79 changes: 0 additions & 79 deletions scripts/version/version.ts

This file was deleted.

0 comments on commit 84bf38e

Please sign in to comment.