Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/add changelog generator #39

Merged
merged 5 commits into from
Aug 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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;
};
}