Skip to content

Commit

Permalink
Merge pull request #39 from zero88/feature/add-changelog-generator
Browse files Browse the repository at this point in the history
Feature/add changelog generator
  • Loading branch information
zero88 committed Aug 23, 2022
2 parents dc00eb7 + 2b1c758 commit b761115
Show file tree
Hide file tree
Showing 13 changed files with 365 additions and 112 deletions.
25 changes: 22 additions & 3 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,15 @@ inputs:
required: false
default: <ci-auto-commit>
correctVerMsg:
description: 'CI: Correct version message template'
description: 'CI: Correct version commit message template'
required: false
default: Correct version
releaseVerMsg:
description: 'CI: Release version message template'
description: 'CI: Release version commit message template'
required: false
default: Release version
nextVerMsg:
description: 'CI: Next version message template'
description: 'CI: Next version commit message template'
required: false
default: Next version
nextVerMode:
Expand All @@ -77,6 +77,25 @@ inputs:
description: 'CI: Dry run. If `true`, action will run without do modify files or git commit/tag'
required: true
default: 'false'
changelog:
description: 'Enable generate CHANGELOG'
required: false
default: 'false'
changelogImageTag:
description: 'CHANGELOG docker image tag: https://github.com/github-changelog-generator/docker-github-changelog-generator'
required: false
default: '1.16.2'
changelogConfigFile:
description: 'CHANGELOG config file: https://github.com/github-changelog-generator/github-changelog-generator#params-file'
required: false
default: '.github_changelog_generator'
changelogToken:
description: 'CHANGELOG token to query GitHub API: https://github.com/github-changelog-generator/github-changelog-generator#github-token'
required: false
changelogMsg:
description: 'CI: Changelog generator commit message template'
required: false
default: Generated CHANGELOG
outputs:
branch:
description: 'Current branch name or tag name'
Expand Down
2 changes: 1 addition & 1 deletion dist/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/index.js.map

Large diffs are not rendered by default.

109 changes: 109 additions & 0 deletions src/changelog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import path from 'path';
import { replaceInFile } from 'replace-in-file';
import { DockerRunCLI } from './docker';
import { readEnv } from './exec';
import { CommitStatus } from './gitOps';
import { isEmpty, RegexUtils, removeEmptyProperties } from './utils';

export interface ChangelogConfig {
/**
* Check whether changelog generator is active or not
* <p>
* Default value is `false`
* @type {boolean}
*/
readonly active: boolean;
/**
* Returns changelog-generator docker image
* {@link https://hub.docker.com/r/githubchangeloggenerator/github-changelog-generator/}
*/
readonly image: string;
/**
* Returns changelog-generator config file
* {@link https://github.com/github-changelog-generator/github-changelog-generator#params-file}
*/
readonly configFile: string;
/**
* Returns changelog-generator token to query GitHub API:
* {@link https://github.com/github-changelog-generator/github-changelog-generator#github-token}
*/
readonly commitMsg: string;
/**
* Returns changelog-generator token to query GitHub API:
* {@link https://github.com/github-changelog-generator/github-changelog-generator#github-token}
*/
readonly token?: string;
}

export type ChangelogResult = {
readonly generated: boolean;
readonly latestTag: string;
readonly releaseTag: string;
} & Omit<CommitStatus, 'isPushed'>

const getTag = (tag?: string) => isEmpty(tag) ? '1.16.2' : tag;
const getImage = (tag?: string) => `githubchangeloggenerator/github-changelog-generator:${getTag(tag)}`;

const defaultConfig: ChangelogConfig = {
active: false,
image: getImage(),
configFile: '.github_changelog_generator',
commitMsg: 'Generated CHANGELOG',
};

export const createChangelogConfig = (active?: boolean, imageTag?: string, configFile?: string, token?: string,
commitMsg?: string): ChangelogConfig => {
return {
...defaultConfig, ...removeEmptyProperties({ active, configFile, token, commitMsg, image: getImage(imageTag) }),
};
};

export type GenerateResult = Required<Omit<ChangelogResult, 'commitId' | 'isCommitted'>>;

export class ChangelogOps {
private readonly config: ChangelogConfig;

constructor(config: ChangelogConfig) {
this.config = config;
}

async generate(latestTag: string, releaseTag: string, dryRun: boolean): Promise<GenerateResult> {
const commitMsg = `${this.config.commitMsg} ${releaseTag}`;
const isExisted = await this.verifyExists(releaseTag);
if (isExisted) {
return { latestTag, releaseTag, commitMsg, generated: false };
}
const workspace = readEnv('GITHUB_WORKSPACE');
const owner = readEnv('GITHUB_REPOSITORY_OWNER');
const repo = readEnv('GITHUB_REPOSITORY');
const ghApi = readEnv('GITHUB_API_URL');
const ghSite = readEnv('GITHUB_SERVER_URL');
const project = repo.replace(owner + '/', '');
const cmd = [
`--user`, owner, `--project`, project, `--config-file`, this.config.configFile,
`--since-tag`, latestTag, `--future-release`, releaseTag,
`--github-api`, ghApi, `--github-site`, ghSite,
];
const envs = { 'CHANGELOG_GITHUB_TOKEN': this.config.token };
const volumes = { [workspace]: DockerRunCLI.DEFAULT_WORKDIR };
const dockerRun = await DockerRunCLI.execute(this.config.image, cmd, envs, volumes);
return { latestTag, releaseTag, commitMsg, generated: dockerRun.success };
}

async verifyExists(releaseTag: string): Promise<boolean> {
const re = /((base|output)\s?=\s?)(.+)/;
const result: string[] = [];
await replaceInFile({
files: this.config.configFile, from: new RegExp(re.source, 'gm'), dry: true, allowEmptyPaths: true,
to: match => {
result.push(RegexUtils.searchMatch(match, re, 2));
return match;
},
});
const dir = path.dirname(this.config.configFile);
const files = (isEmpty(result) ? ['CHANGELOG.md'] : result).map(f => path.resolve(dir, f));
return await replaceInFile(
{ files, from: releaseTag, dry: true, countMatches: true, allowEmptyPaths: true, to: match => match })
.then(rr => rr.some(r => r?.numMatches! > 0));
}
}
33 changes: 33 additions & 0 deletions src/docker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import * as core from '@actions/core';
import { ExecResult, strictExec } from './exec';
import { arrayToObjectWithVal, isNull } from './utils';

export class DockerRunCLI {
static DEFAULT_WORKDIR = '/github/workspace';

static envs = (envs?: Record<string, any>): string[] => {
const def = ['CI', 'GITHUB_API_URL', 'GITHUB_SERVER_URL', 'GITHUB_WORKSPACE'];
const environments = { ...arrayToObjectWithVal(def, () => null), ...(envs ?? {}) };
const toEnv = kv => isNull(kv[1]) ? kv[0] : `${kv[0]}=${kv[1]}`;
return Object.entries(environments).map(toEnv).map(e => ['-e', e]).flat();
};

static volumes = (volumes?: Record<string, any>): string[] => {
const toVolume = kv => isNull(kv[1]) ? kv[0] : `${kv[0]}:${kv[1]}`;
return Object.entries(volumes ?? {}).map(toVolume).map(v => ['-v', v]).flat();
};

static execute = (image: string, containerCmd?: Array<string>, envs?: Record<string, any>,
volumes?: Record<string, any>): Promise<ExecResult> => {
const args = [
'run', '-t', '--rm',
'--workdir', DockerRunCLI.DEFAULT_WORKDIR,
...DockerRunCLI.envs(envs),
...DockerRunCLI.volumes(volumes),
image,
...(containerCmd ?? []),
];
core.debug(`Docker run args: ${args}`);
return strictExec('docker', args, 'Unable to run docker', false);
};
}
2 changes: 2 additions & 0 deletions src/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,5 @@ export const strictExec = async (command: string, args: string[], msgIfError: st
return r;
});
};

export const readEnv = (envVar: string): string => process.env[envVar] ?? '';
154 changes: 92 additions & 62 deletions src/gitOps.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as core from '@actions/core';
import { exec, strictExec } from './exec';
import { CIContext } from './projectContext';
import { isEmpty, removeEmptyProperties } from './utils';

/**
* Represents for Git CI input
Expand Down Expand Up @@ -68,20 +68,29 @@ const defaultConfig: GitOpsConfig = {
};

export const createGitOpsConfig = (allowCommit: boolean, allowTag: boolean, prefixCiMsg: string, correctVerMsg: string,
releaseVerMsg: string, username: string, userEmail: string, isSign: boolean, nextVerMsg: string): GitOpsConfig => {
releaseVerMsg: string, userName: string, userEmail: string, mustSign: boolean, nextVerMsg: string): GitOpsConfig => {
return {
allowCommit: allowCommit ?? defaultConfig.allowCommit,
allowTag: allowTag ?? defaultConfig.allowTag,
mustSign: isSign ?? defaultConfig.mustSign,
prefixCiMsg: prefixCiMsg ?? defaultConfig.prefixCiMsg,
correctVerMsg: correctVerMsg ?? defaultConfig.correctVerMsg,
releaseVerMsg: releaseVerMsg ?? defaultConfig.releaseVerMsg,
nextVerMsg: nextVerMsg ?? defaultConfig.nextVerMsg,
userName: username ?? defaultConfig.userName,
userEmail: userEmail ?? defaultConfig.userEmail,
...defaultConfig, ...removeEmptyProperties({
allowCommit,
allowTag,
mustSign,
prefixCiMsg,
correctVerMsg,
releaseVerMsg,
nextVerMsg,
userName,
userEmail,
}),
};
};

export interface CommitStatus {
isCommitted: boolean;
isPushed: boolean;
commitId?: string;
commitMsg?: string;
}

/**
* Represents for Git CI interactor like: commit, push, tag
*/
Expand All @@ -93,76 +102,97 @@ export class GitOps {
this.config = config;
}

static getCommitMsg = async (sha: string) => GitOps.execSilent(['log', '--format=%B', '-n', '1', sha]);
static getCommitMsg = async (sha: string) => GitOps.exec(['log', '--format=%B', '-n', '1', sha]);

static removeRemoteBranch = async (branch: string) => GitOps.execSilent(['push', 'origin', `:${branch}`]);
static removeRemoteBranch = async (branch: string) => GitOps.exec(['push', 'origin', `:${branch}`]);

static checkoutBranch = async (branch: string) => {
await strictExec('git', ['fetch', '--depth=1'], 'Cannot fetch');
await strictExec('git', ['checkout', branch], 'Cannot checkout');
};
// git tag -l --sort=-creatordate 'v*' | head -n 1
// git describe --tags --abbrev=0 --match 'v*'
static getLatestTag = async (pattern?: string) =>
GitOps.exec(['fetch', '--tag'])
.then(() => GitOps.exec(['tag', '-l', '--sort=-creatordate', `${pattern}*`]))
.then(out => out.split('\n')[0]);

private static execSilent = async (args: string[], fallback: string = ''): Promise<string> => {
const r = await exec('git', args);
if (!r.success) {
core.warning(`Cannot exec GIT ${args[0]}: ${r.stderr}`);
}
return r.success ? r.stdout : fallback;
};
async commit(msg: string, branch?: string): Promise<CommitStatus> {
return this.doCommit(msg, msg, branch);
}

correctVersion = async (branch: string, version: string, dryRun: boolean): Promise<CIContext> => {
if (!this.config.allowCommit || dryRun) {
return { mustFixVersion: true, isPushed: false };
}
const commitMsg = `${this.config.prefixCiMsg} ${this.config.correctVerMsg} ${version}`;
return core.group(`[GIT Commit] Correct version in branch ${branch} => ${version}...`,
() => GitOps.checkoutBranch(branch)
.then(() => this.commitThenPush(commitMsg))
.then(commitId => ({ mustFixVersion: true, isPushed: true, commitMsg, commitId })));
};
async commitVersionCorrection(branch: string, version: string): Promise<CommitStatus> {
return this.doCommit(`${this.config.correctVerMsg} ${version}`,
`Correct version in branch ${branch} => ${version}...`, branch);
}

upgradeVersion = async (nextVersion: string, dryRun: boolean): Promise<CIContext> => {
if (!this.config.allowCommit || dryRun) {
return { needUpgrade: true, isPushed: false };
}
const commitMsg = `${this.config.prefixCiMsg} ${this.config.nextVerMsg} ${nextVersion}`;
return core.group(`[GIT Commit] Upgrade to new version to ${nextVersion}...`,
() => this.commitThenPush(commitMsg)
.then(commitId => ({ needUpgrade: true, isPushed: true, commitMsg, commitId })));
async commitVersionUpgrade(nextVersion: string): Promise<CommitStatus> {
return this.doCommit(`${this.config.nextVerMsg} ${nextVersion}`, `Upgrade to new version to ${nextVersion}`);
};

tagThenPush = async (tag: string, version: string, dryRun: boolean): Promise<CIContext> => {
if (!this.config.allowTag || dryRun) {
return { needTag: true, isPushed: false };
async tag(tag: string): Promise<CommitStatus> {
if (!this.config.allowTag) {
return { isCommitted: false, isPushed: false };
}
const commitMsg = `${this.config.releaseVerMsg} ${tag}`;
const signArgs = this.config.mustSign ? ['-s'] : [];
return core.group(`[GIT Tag] Tag new version ${tag}...`, () =>
strictExec('git', ['fetch', '--depth=1'], 'Cannot fetch')
.then(ignore => strictExec('git', ['rev-parse', '--short', 'HEAD'], 'Cannot show last commit'))
.then(r => r.stdout)
.then(commitId => (<CIContext>{ needTag: true, isPushed: true, commitMsg, commitId }))
.then(ctx => this.configGitUser()
.then(g => [...g, 'tag', ...signArgs, '-a', '-m', ctx.commitMsg!, tag, ctx.commitId!])
.then(tagArgs => strictExec('git', tagArgs, `Cannot tag`))
.then(commitId => this.configGitUser()
.then(g => strictExec('git', [...g, 'tag', ...signArgs, '-a', '-m', commitMsg, tag, commitId], `Cannot tag`))
.then(() => strictExec('git', ['show', '--shortstat', '--show-signature', tag], `Cannot show tag`, false))
.then(() => strictExec('git', ['push', '-uf', 'origin', tag], `Cannot push`, false))
.then(() => ctx)));
.then(() => ({ isCommitted: true, isPushed: false, commitMsg, commitId }))));
};

private commitThenPush = async (commitMsg: string): Promise<string> => {
const commitArgs = ['commit', ...this.config.mustSign ? ['-S'] : [], '-a', '-m', commitMsg];
return this.configGitUser()
.then(gc => strictExec('git', [...gc, ...commitArgs], `Cannot commit`))
.then(() => strictExec('git', ['show', '--shortstat', '--show-signature'], `Cannot show recently commit`, false))
.then(() => strictExec('git', ['push'], `Cannot push`, false))
.then(() => strictExec('git', ['rev-parse', 'HEAD'], 'Cannot show last commit'))
.then(r => r.stdout);
async pushCommit(status: CommitStatus, dryRun: boolean): Promise<CommitStatus> {
if (dryRun || !status.isCommitted) {
return { ...status, isPushed: false };
}
return strictExec('git', ['push'], `Cannot push commits`, false).then(s => ({ ...status, isPushed: s.success }));
};

async pushTag(tag: string, status: CommitStatus, dryRun: boolean): Promise<CommitStatus> {
if (dryRun || !status.isCommitted) {
return { ...status, isPushed: false };
}
return strictExec('git', ['push', '-uf', 'origin', tag], `Cannot push tag`, false)
.then(s => ({ ...status, isPushed: s.success }));
};

private configGitUser = async (): Promise<string[]> => {
const userName = await GitOps.execSilent(['config', 'user.name'], this.config.userName);
const userEmail = await GitOps.execSilent(['config', 'user.email'], this.config.userEmail);
private doCommit(msg: string, groupMsg: string, branch?: string): Promise<CommitStatus> {
if (!this.config.allowCommit) {
return Promise.resolve({ isCommitted: false, isPushed: false });
}
const commitMsg = `${this.config.prefixCiMsg} ${msg}`;
const commitArgs = ['commit', ...this.config.mustSign ? ['-S'] : [], '-a', '-m', commitMsg];
return core.group(`[GIT Commit] ${groupMsg}...`,
() => GitOps.checkoutBranch(branch)
.then(() => this.configGitUser())
.then(gc => strictExec('git', [...gc, ...commitArgs], `Cannot commit`))
.then(
() => strictExec('git', ['show', '--shortstat', '--show-signature'], `Cannot show recently commit`, false))
.then(() => strictExec('git', ['rev-parse', 'HEAD'], 'Cannot get recently commit'))
.then(r => r.stdout)
.then(commitId => ({ isCommitted: true, isPushed: false, commitMsg, commitId })));
}

private async configGitUser(): Promise<string[]> {
const userName = await GitOps.exec(['config', 'user.name'], this.config.userName);
const userEmail = await GitOps.exec(['config', 'user.email'], this.config.userEmail);
return Promise.resolve(['-c', `user.name=${userName}`, '-c', `user.email=${userEmail}`]);
};

private static async checkoutBranch(branch?: string) {
if (isEmpty(branch)) {
return Promise.resolve();
}
await strictExec('git', ['fetch', '--depth=1'], 'Cannot fetch');
await strictExec('git', ['checkout', branch!], 'Cannot checkout');
};

private static async exec(args: string[], fallback: string = ''): Promise<string> {
const r = await exec('git', args);
if (!r.success) {
core.warning(`Cannot exec GIT ${args[0]}: ${r.stderr}`);
}
return r.success ? r.stdout : fallback;
};
}

0 comments on commit b761115

Please sign in to comment.