Skip to content

Commit

Permalink
chore: automate release (#120)
Browse files Browse the repository at this point in the history
  • Loading branch information
eunjae-lee authored Feb 17, 2022
1 parent bd13ce7 commit 8a38fe6
Show file tree
Hide file tree
Showing 13 changed files with 644 additions and 6 deletions.
23 changes: 23 additions & 0 deletions .github/workflows/process-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Process release
on:
issues:
types:
- closed
jobs:
build:
name: Release
runs-on: ubuntu-20.04
if: "startsWith(github.event.issue.title, 'chore: release')"
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0

- name: Setup
id: setup
uses: ./.github/actions/setup

- run: ./scripts/release/process-release.js
env:
EVENT_NUMBER: ${{ github.event.issue.number }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Empty file added doc/changelogs/java.md
Empty file.
Empty file added doc/changelogs/javascript.md
Empty file.
Empty file added doc/changelogs/php.md
Empty file.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"playground": "./scripts/multiplexer.sh ${2:-nonverbose} ./scripts/playground.sh ${0:-javascript} ${1:-search}",
"specs:fix": "eslint --ext=yml specs/ --fix",
"specs:lint": "eslint --ext=yml specs/$0",
"github-actions:lint": "eslint --ext=yml .github/"
"github-actions:lint": "eslint --ext=yml .github/",
"release": "yarn workspace scripts release"
},
"devDependencies": {
"@openapitools/openapi-generator-cli": "2.4.26",
Expand Down
6 changes: 6 additions & 0 deletions release.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"releasedTag": "released",
"mainBranch": "main",
"owner": "algolia",
"repo": "api-clients-automation"
}
7 changes: 6 additions & 1 deletion scripts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@
"version": "1.0.0",
"scripts": {
"build": "tsc",
"setHostsOptions": "yarn build && node dist/setHostsOptions.js"
"release": "yarn build && node dist/scripts/release/create-release-issue.js",
"setHostsOptions": "yarn build && node dist/scripts/pre-gen/setHostsOptions.js"
},
"devDependencies": {
"@octokit/rest": "18.12.0",
"@types/js-yaml": "4.0.5",
"@types/node": "16.11.11",
"dotenv": "16.0.0",
"execa": "5.1.1",
"js-yaml": "4.1.0",
"semver": "7.3.5",
"typescript": "4.5.4"
}
}
38 changes: 38 additions & 0 deletions scripts/release/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import execa from 'execa'; // https://github.com/sindresorhus/execa/tree/v5.1.1

import openapitools from '../../openapitools.json';
import config from '../../release.config.json';

export const RELEASED_TAG = config.releasedTag;
export const MAIN_BRANCH = config.mainBranch;
export const OWNER = config.owner;
export const REPO = config.repo;

type Run = (
command: string,
options?: Partial<{
errorMessage: string;
}>
) => execa.ExecaReturnBase<string>['stdout'];

export const run: Run = (command, { errorMessage = undefined } = {}) => {
let result: execa.ExecaSyncReturnValue<string>;
try {
result = execa.commandSync(command);
} catch (err) {
if (errorMessage) {
throw new Error(`[ERROR] ${errorMessage}`);
} else {
throw err;
}
}
return result.stdout;
};

export const LANGS = [
...new Set(
Object.keys(openapitools['generator-cli'].generators).map(
(key) => key.split('-')[0]
)
),
];
225 changes: 225 additions & 0 deletions scripts/release/create-release-issue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
/* eslint-disable no-console */
import { Octokit } from '@octokit/rest';
import dotenv from 'dotenv';
import semver from 'semver';

import openapitools from '../../openapitools.json';

import { RELEASED_TAG, MAIN_BRANCH, OWNER, REPO, LANGS, run } from './common';
import TEXT from './text';

dotenv.config();

type Version = {
current: string;
langName: string;
next?: string;
noCommit?: boolean;
skipRelease?: boolean;
};

type Versions = {
[lang: string]: Version;
};

function readVersions(): Versions {
const versions = {};

const generators = openapitools['generator-cli'].generators;

Object.keys(generators).forEach((generator) => {
const lang = generator.split('-')[0];
if (!versions[lang]) {
versions[lang] = {
current: generators[generator].additionalProperties.packageVersion,
langName: lang,
next: undefined,
};
}
});
return versions;
}

if (!process.env.GITHUB_TOKEN) {
throw new Error('Environment variable `GITHUB_TOKEN` does not exist.');
}

if (run('git rev-parse --abbrev-ref HEAD') !== MAIN_BRANCH) {
throw new Error(
`You can run this script only from \`${MAIN_BRANCH}\` branch.`
);
}

if (run('git status --porcelain')) {
throw new Error(
'Working directory is not clean. Commit all the changes first.'
);
}

run(`git rev-parse --verify refs/tags/${RELEASED_TAG}`, {
errorMessage: '`released` tag is missing in this repository.',
});

// Reading versions from `openapitools.json`
const versions = readVersions();

console.log('Pulling from origin...');
run(`git pull origin ${MAIN_BRANCH}`);

console.log('Pushing to origin...');
run(`git push origin ${MAIN_BRANCH}`);

const commitsWithoutScope: string[] = [];
const commitsWithNonLanguageScope: string[] = [];

// Reading commits since last release
type LatestCommit = {
hash: string;
type: string;
lang: string;
message: string;
raw: string;
};
const latestCommits = run(`git log --oneline ${RELEASED_TAG}..${MAIN_BRANCH}`)
.split('\n')
.filter(Boolean)
.map((commit) => {
const hash = commit.slice(0, 7);
let message = commit.slice(8);
let type = message.slice(0, message.indexOf(':'));
const matchResult = type.match(/(.+)\((.+)\)/);
if (!matchResult) {
commitsWithoutScope.push(commit);
return undefined;
}
message = message.slice(message.indexOf(':') + 1).trim();
type = matchResult[1];
const lang = matchResult[2];

if (!LANGS.includes(lang)) {
commitsWithNonLanguageScope.push(commit);
return undefined;
}

return {
hash,
type, // `fix` | `feat` | `chore` | ...
lang, // `javascript` | `php` | `java` | ...
message,
raw: commit,
};
})
.filter(Boolean) as LatestCommit[];

console.log('[INFO] Skipping these commits due to lack of language scope:');
console.log(commitsWithoutScope.map((commit) => ` ${commit}`).join('\n'));

console.log('');
console.log('[INFO] Skipping these commits due to wrong scopes:');
console.log(
commitsWithNonLanguageScope.map((commit) => ` ${commit}`).join('\n')
);

LANGS.forEach((lang) => {
const commits = latestCommits.filter(
(lastestCommit) => lastestCommit.lang === lang
);
const currentVersion = versions[lang].current;

if (commits.length === 0) {
versions[lang].next = currentVersion;
versions[lang].noCommit = true;
return;
}

if (semver.prerelease(currentVersion)) {
// if version is like 0.1.2-beta.1, it increases to 0.1.2-beta.2, even if there's a breaking change.
versions[lang].next = semver.inc(currentVersion, 'prerelease');
return;
}

if (commits.some((commit) => commit.message.includes('BREAKING CHANGE'))) {
versions[lang].next = semver.inc(currentVersion, 'major');
return;
}

const commitTypes = new Set(commits.map(({ type }) => type));
if (commitTypes.has('feat')) {
versions[lang].next = semver.inc(currentVersion, 'minor');
return;
}

versions[lang].next = semver.inc(currentVersion, 'patch');
if (!commitTypes.has('fix')) {
versions[lang].skipRelease = true;
}
});

const versionChanges = LANGS.map((lang) => {
const { current, next, noCommit, skipRelease, langName } = versions[lang];

if (noCommit) {
return `- ~${langName}: v${current} (${TEXT.noCommit})~`;
}

if (!current) {
return `- ~${langName}: (${TEXT.currentVersionNotFound})~`;
}

const checked = skipRelease ? ' ' : 'x';
return [
`- [${checked}] ${langName}: v${current} -> v${next}`,
skipRelease && TEXT.descriptionForSkippedLang(langName),
]
.filter(Boolean)
.join('\n');
}).join('\n');

const changelogs = LANGS.filter(
(lang) => !versions[lang].noCommit && versions[lang].current
)
.flatMap((lang) => {
if (versions[lang].noCommit) {
return [];
}

return [
`### ${versions[lang].langName}`,
...latestCommits
.filter((commit) => commit.lang === lang)
.map((commit) => `- ${commit.raw}`),
];
})
.join('\n');

const body = [
TEXT.header,
TEXT.versionChangeHeader,
versionChanges,
TEXT.changelogHeader,
TEXT.changelogDescription,
changelogs,
TEXT.approvalHeader,
TEXT.approval,
].join('\n\n');

const octokit = new Octokit({
auth: `token ${process.env.GITHUB_TOKEN}`,
});

octokit.rest.issues
.create({
owner: OWNER,
repo: REPO,
title: `chore: release ${new Date().toISOString().split('T')[0]}`,
body,
})
.then((result) => {
const {
data: { number, html_url: url },
} = result;

console.log('');
console.log(`Release issue #${number} is ready for review.`);
console.log(` > ${url}`);
});
Loading

0 comments on commit 8a38fe6

Please sign in to comment.