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

feat: add --push-tag and --github-release to automatically push a Git tag and create a release on GitHub #176

Merged
merged 14 commits into from
Jun 3, 2024
2 changes: 1 addition & 1 deletion .cliff-jumperrc
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://raw.githubusercontent.com/favware/cliff-jumper/main/assets/cliff-jumper.schema.json",
"$schema": "./assets/cliff-jumper.schema.json",
"name": "cliff-jumper",
"org": "favware",
"packagePath": ".",
Expand Down
125 changes: 76 additions & 49 deletions README.md

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions assets/cliff-jumper.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,35 @@
"type": "boolean",
"default": false
},
"pushTag": {
"description": "Whether to push the tag to the remote repository.\nThis will simply execute \"git push && git push --tags\" so make sure you have configured git for pushing properly beforehand.",
"type": "boolean",
"default": false
},
"githubRelease": {
"description": "Whether to create a release on GitHub, requires \"pushTag\" to be enabled, otherwise there will be no tag to create a release from\nFor the repository the release is created on the value from \"githubRepo\" will be used\nIf the changelog section from git-cliff is empty, the release notes will be auto-generated by GitHub.",
"type": "boolean",
"default": false
},
"githubReleaseDraft": {
"description": "Whether the release should be a draft",
"type": "boolean",
"default": false
},
"githubReleasePrerelease": {
"description": "Whether the release should be a pre-release",
"type": "boolean",
"default": false
},
"githubReleaseLatest": {
"description": "Whether the release should be marked as the latest release, will try to read this value, then the value of --github-release, and then default to false. Please note that when setting --github-release-pre-release to `true` GitHub will prevent the release to be marked as latest an this option will essentially be ignored..",
"type": "boolean",
"default": false
},
"githubReleaseNameTemplate": {
"description": "A custom release name template to use.\n\nYou can use \"{{new-version}}\" in your template which will be dynamically replaced with whatever the new version is that will be published.\n\nYou can use \"{{name}}\" in your template, this will be replaced with the name provided through \"-n\", \"--name\" or the same value set in your config file.\n\nYou can use \"{{full-name}}\" in your template, this will be replaced \"{{name}}\" (when \"org\" is not provided), or \"@{{org}}/{{name}}\" (when \"org\" is provided).",
"type": "string"
},
"verbose": {
"description": "Whether to print verbose information",
"type": "boolean",
Expand Down
10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,20 @@
},
"dependencies": {
"@favware/colorette-spinner": "^1.0.1",
"@octokit/auth-token": "^5.1.1",
"@octokit/core": "^6.1.2",
"@octokit/plugin-retry": "^7.1.1",
"@sapphire/result": "^2.6.6",
"@sapphire/utilities": "3.16.2",
"colorette": "^2.0.20",
"commander": "^12.1.0",
"conventional-changelog-angular": "^8.0.0",
"conventional-recommended-bump": "^10.0.0",
"execa": "^9.1.0",
"git-cliff": "^2.2.2",
"git-cliff": "^2.3.0",
"js-yaml": "^4.1.0",
"semver": "^7.6.2"
"semver": "^7.6.2",
"smol-toml": "^1.2.1"
},
"devDependencies": {
"@commitlint/cli": "^19.3.0",
Expand All @@ -53,7 +57,7 @@
"@sapphire/prettier-config": "^2.0.0",
"@sapphire/ts-config": "^5.0.1",
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.13.0",
"@types/node": "^20.14.0",
"@types/semver": "^7.5.8",
"@typescript-eslint/eslint-plugin": "^7.11.0",
"@typescript-eslint/parser": "^7.11.0",
Expand Down
48 changes: 45 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@

import { bumpVersion } from '#commands/bump-version';
import { commitRelease } from '#commands/commit-release';
import { createGitHubRelease } from '#commands/create-github-release';
import { createTag } from '#commands/create-tag';
import { getConventionalBump } from '#commands/get-conventional-bump';
import { getNewVersion } from '#commands/get-new-version';
import { installDependencies } from '#commands/install-dependencies';
import { pushTag } from '#commands/push-tag';
import { stageFiles } from '#commands/stage-files';
import { updateChangelog } from '#commands/update-changelog';
import { cliRootDir, indent, isCi } from '#lib/constants';
import { logVerboseError, logVerboseInfo } from '#lib/logger';
import { parseOptionsFile } from '#lib/optionsParser';
import { parseOptionsFile } from '#lib/options-parser';
import { preflightChecks } from '#lib/preflight-checks';
import {
doActionAndLog,
Expand Down Expand Up @@ -114,6 +116,30 @@ const command = new Command()
'The multiple options for the name of the environment are to aim to not conflict with other tooling that use similar tokens in case you want to use a unique token for release management.'
].join('\n')
)
.option(
'-pt, --push-tag',
'Whether to push the tag to the remote repository.\nThis will simply execute "git push && git push --tags" so make sure you have configured git for pushing properly beforehand.'
)
.option(
'-ghr, --github-release',
'Whether to create a release on GitHub, requires "--push-tag" to be enabled, otherwise there will be no tag to create a release from\nFor the repository the release is created on the value from "--github-repo" will be used\nIf the changelog section from git-cliff is empty, the release notes will be auto-generated by GitHub.'
)
.option('-ghrd, --github-release-draft', 'Whether the release should be a draft')
.option('-ghrpr, --github-release-pre-release', 'Whether the release should be a pre-release')
.option(
'-ghrl, --github-release-latest',
'Whether the release should be marked as the latest release, will try to read this value, then the value of --github-release, and then default to false. Please note that when setting --github-release-pre-release to `true` GitHub will prevent the release to be marked as latest an this option will essentially be ignored.'
)
.option(
'-ghrnt, --github-release-name-template [string]',
[
'A GitHub release name template to use. Defaults to an empty string, which means GitHub will use the tag name as the release name.',
'You can use "{{new-version}}" in your template which will be dynamically replaced with whatever the new version is that will be published.',
'You can use "{{org}}" in your template, this will be replaced with the org provided through "-o", "--org" or the same value set in your config file.',
'You can use "{{name}}" in your template, this will be replaced with the name provided through "-n", "--name" or the same value set in your config file.',
'You can use "{{full-name}}" in your template, this will be replaced "{{name}}" (when "org" is not provided), or "@{{org}}/{{name}}" (when "org" is provided).'
].join('\n')
)
.option('-v, --verbose', 'Whether to print verbose information', false);

const program = command.parse(process.argv);
Expand All @@ -138,6 +164,12 @@ logVerboseInfo(
`${indent}verbose: ${JSON.stringify(options.verbose)}`,
`${indent}github repo: ${JSON.stringify(getGitHubRepo(options))}`,
`${indent}github token: ${getGitHubToken(options) ? 'Unset' : 'SECRET([REDACTED])'}`,
`${indent}push tag: ${JSON.stringify(options.pushTag)}`,
`${indent}github release: ${JSON.stringify(options.githubRelease)}`,
`${indent}github release draft: ${JSON.stringify(options.githubReleaseDraft)}`,
`${indent}github release pre-release: ${JSON.stringify(options.githubReleasePrerelease)}`,
`${indent}github release latest: ${JSON.stringify(options.githubReleaseLatest)}`,
`${indent}github release name template: ${JSON.stringify(options.githubReleaseNameTemplate)}`,
''
],
options.verbose
Expand Down Expand Up @@ -175,7 +207,7 @@ if (!options.firstRelease) {
if (!options.skipChangelog) {
newVersion = isNullishOrEmpty(newVersion) ? await getNewVersion() : newVersion;

await updateChangelog(options, newVersion);
const changelogSection = await updateChangelog(options, newVersion);

if (!options.skipTag) {
if (options.install) {
Expand All @@ -190,6 +222,16 @@ if (!options.skipChangelog) {

const publishText = resolvePublishCommand(packageManagerUsed);

console.info(infoIcon + green(` Run \`git push && git push --tags && ${publishText}\` to publish`));
if (options.pushTag) {
await pushTag(options);

if (options.githubRelease) {
await createGitHubRelease(options, newVersion, changelogSection);
}

console.info(infoIcon + green(` Run \`${publishText}\` to publish to your package registry`));
} else {
console.info(infoIcon + green(` Run \`git push && git push --tags && ${publishText}\` to publish to your package registry`));
}
}
}
44 changes: 44 additions & 0 deletions src/commands/create-github-release.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { OctokitRequestHeaders } from '#lib/constants';
import { removeHeaderFromChangelogSection } from '#lib/parse-cliff-toml';
import { doActionAndLog, getGitHubRepo, getGitHubToken, resolveGitHubReleaseNameTemplate } from '#lib/utils';
import { createTokenAuth } from '@octokit/auth-token';
import { Octokit } from '@octokit/core';
import { retry } from '@octokit/plugin-retry';
import { isNullishOrEmpty } from '@sapphire/utilities';
import type { Options } from 'commander';

export function createGitHubRelease(options: Options, newVersion: string, changelogSection: string | undefined) {
const HydratedOctokit = Octokit.plugin(retry).defaults({
userAgent: 'Cliff Jumper CLI/ (@favware/cliff-jumper) (https://github.com/favware/cliff-jumper/tree/main)'
});

return doActionAndLog('Creating release', async () => {
if (!options.dryRun) {
const githubToken = getGitHubToken(options);
const githubRepo = getGitHubRepo(options);

if (!isNullishOrEmpty(githubRepo) && !isNullishOrEmpty(githubToken)) {
const octokitAuth = createTokenAuth(githubToken);
const authentication = await octokitAuth();

const octokit = new HydratedOctokit({ auth: authentication.token });

const [repoOwner, repoName] = githubRepo.split('/');
const releaseBody = await removeHeaderFromChangelogSection(changelogSection);

await octokit.request('POST /repos/{owner}/{repo}/releases', {
owner: repoOwner,
repo: repoName,
tag_name: newVersion,
body: releaseBody,
draft: options.githubReleaseDraft,
generate_release_notes: typeof changelogSection === 'undefined',
headers: OctokitRequestHeaders,
make_latest: options.githubReleaseLatest ? 'true' : 'false',
name: resolveGitHubReleaseNameTemplate(options, newVersion),
prerelease: options.githubReleasePrerelease
});
}
}
});
}
12 changes: 12 additions & 0 deletions src/commands/push-tag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { doActionAndLog } from '#lib/utils';
import type { Options } from 'commander';
import { execa } from 'execa';

export function pushTag(options: Options) {
return doActionAndLog('Pushing tag', async () => {
if (!options.dryRun) {
await execa('git', ['push']);
await execa('git', ['push', '--tags']);
}
});
}
2 changes: 1 addition & 1 deletion src/commands/stage-files.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { packageCwd } from '#lib/constants';
import { fileExists } from '#lib/fileExists';
import { fileExists } from '#lib/file-exists';
import { doActionAndLog, getGitRootDirection, type resolveUsedPackageManager } from '#lib/utils';
import { filterNullish, isNullishOrEmpty } from '@sapphire/utilities';
import type { Options } from 'commander';
Expand Down
8 changes: 6 additions & 2 deletions src/commands/update-changelog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ export async function updateChangelog(options: Options, newVersion: string) {
tag: options.tagTemplate,
prepend: './CHANGELOG.md',
unreleased: true,
config: './cliff.toml'
config: './cliff.toml',
output: '-'
};

if (!isNullishOrEmpty(repositoryRootDirectory)) {
Expand All @@ -31,7 +32,10 @@ export async function updateChangelog(options: Options, newVersion: string) {
gitCliffOptions.githubToken = githubToken;
}

await runGitCliff(gitCliffOptions, { stdio: 'ignore' });
const result = await runGitCliff(gitCliffOptions, { stdio: options.githubRelease ? 'pipe' : 'ignore' });
return result.stdout;
}

return undefined;
});
}
11 changes: 11 additions & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,15 @@ export const cliRootDir = new URL('../../', import.meta.url);
/** Current working directory from which the script is called */
export const packageCwd = process.cwd();

/** The path to the CHANGELOG file */
export const changelogPath = join(packageCwd, 'CHANGELOG.md');

/** Path to the config file in proprietary format */
export const cliffJumperRcPath = join(packageCwd, '.cliff-jumperrc');

/** The path to the cliff.toml file for git-cliff */
export const cliffTomlPath = join(packageCwd, 'cliff.toml');

/** Path to the config file in .json format */
export const cliffJumperRcJsonPath = `${cliffJumperRcPath}.json`;

Expand All @@ -24,3 +30,8 @@ export const cliffJumperRcYamlPath = `${cliffJumperRcPath}.yaml`;

/** 4 spaces indent for logging */
export const indent = ' '.repeat(4);

export const OctokitRequestHeaders = {
'X-GitHub-Api-Version': '2022-11-28',
Accept: 'application/vnd.github+json'
};
File renamed without changes.
File renamed without changes.
6 changes: 6 additions & 0 deletions src/lib/interfaces.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,11 @@ declare module 'commander' {
tagTemplate: string;
githubRepo: string;
githubToken: string;
pushTag: boolean;
githubRelease: boolean;
githubReleaseDraft: boolean;
githubReleasePrerelease: boolean;
githubReleaseLatest: boolean;
githubReleaseNameTemplate: string;
}
}
10 changes: 7 additions & 3 deletions src/lib/optionsParser.ts → src/lib/options-parser.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { cliffJumperRcJsonPath, cliffJumperRcPath, cliffJumperRcYamlPath, cliffJumperRcYmlPath } from '#lib/constants';
import { fileExists } from '#lib/fileExists';
import { fileExists } from '#lib/file-exists';
import { logVerboseError } from '#lib/logger';
import { readJson, readYaml } from '#lib/utils';
import type { Options } from 'commander';
Expand All @@ -24,7 +24,9 @@ export async function parseOptionsFile(cliOptions: Options) {
options = {
...fileOptions,
...options,
monoRepo: fileOptions.monoRepo ?? options.monoRepo ?? (fileOptions.org || options.org ? true : false)
monoRepo: fileOptions.monoRepo ?? options.monoRepo ?? (fileOptions.org || options.org),
githubReleaseLatest:
fileOptions.githubReleaseLatest ?? options.githubReleaseLatest ?? fileOptions.githubRelease ?? options.githubRelease ?? false
};
} catch (err) {
const typedError = err as Error;
Expand All @@ -49,7 +51,9 @@ export async function parseOptionsFile(cliOptions: Options) {
options = {
...fileOptions,
...options,
monoRepo: fileOptions.monoRepo ?? options.monoRepo ?? (fileOptions.org || options.org ? true : false)
monoRepo: fileOptions.monoRepo ?? options.monoRepo ?? (fileOptions.org || options.org),
githubReleaseLatest:
fileOptions.githubReleaseLatest ?? options.githubReleaseLatest ?? fileOptions.githubRelease ?? options.githubRelease ?? false
};
} catch (err) {
const typedError = err as Error;
Expand Down
62 changes: 62 additions & 0 deletions src/lib/parse-cliff-toml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { cliffTomlPath } from '#lib/constants';
import { Result, err, ok } from '@sapphire/result';
import { isObject } from '@sapphire/utilities';
import { readFile } from 'fs/promises';
import { parse, type TomlPrimitive } from 'smol-toml';

export async function removeHeaderFromChangelogSection(changelogSection: string | undefined): Promise<string | undefined> {
if (!changelogSection) return undefined;

const cliffToml = await parseCliffToml();

return cliffToml.match({
err: () => changelogSection,
ok: (tomlContent) => {
const header = tomlContent.changelog?.header;

if (header) {
return changelogSection.replace(header, '');
}

return changelogSection;
}
});
}

async function parseCliffToml(): Promise<Result<CliffTomlish, Error>> {
const tomlFile = await readFile(cliffTomlPath, { encoding: 'utf-8' });
const tomlParsed = parse(tomlFile);

if (!valueIsObject(tomlParsed)) return err(new Error('Invalid TOML file'));

return ok(tomlParsed);
}

function valueIsObject(value: TomlPrimitive | CliffTomlish): value is CliffTomlish {
return isObject(value);
}

type CliffTomlish = Partial<{
changelog: Partial<{
header: string;
body: string;
trim: boolean;
footer: string;
}>;
git: Partial<{
conventionalCommits: boolean;
filterUnconventional: boolean;
commitParsers: Partial<{
message: string;
body: string;
group: string;
skip: boolean;
}>[];
commitPreprocessors: Partial<{ pattern: string; replace: string }>[];
filterCommits: boolean;
tagPattern: string;
ignoreTags: string;
topoOrder: boolean;
sortCommits: string;
}>;
}>;
Loading