From 5b14fdbfe021c66d540bef849942212e1dcf37b0 Mon Sep 17 00:00:00 2001 From: Aleksei Potsetsuev Date: Mon, 16 Sep 2024 03:22:06 +0800 Subject: [PATCH] feat: automated releases --- .github/workflows/release.yml | 32 +- packages/_aggregate/tsconfig.json | 3 - packages/ci-github-release/package.json | 29 - .../ci-github-release/src/IAuthOptions.ts | 4 - .../src/git-semver-tags.d.ts | 6 - .../ci-github-release/src/github-release.ts | 126 -- packages/ci-github-release/src/semverRegex.ts | 6 - packages/ci-github-release/src/transform.ts | 30 - packages/ci/package.json | 50 +- packages/ci/src/cli.ts | 118 ++ ...ntional-changelog-conventionalcommits.d.ts | 15 - packages/ci/src/createReleaseTag.ts | 47 + packages/ci/src/defaultChangelogFile.ts | 1 + packages/ci/src/gulp/IPackageJson.ts | 5 - packages/ci/src/gulp/combineStreams.ts | 26 - packages/ci/src/gulp/index.ts | 1 - packages/ci/src/gulp/prepublish.ts | 124 -- packages/ci/src/makeRelease.ts | 186 +++ packages/ci/src/markdownMarkers.ts | 5 + packages/ci/src/publishGithubRelease.ts | 87 + packages/ci/src/readChangelogForVersion.ts | 34 + packages/ci/src/readPackageJson.ts | 12 + packages/ci/tsconfig.json | 13 +- .../conventional-commits-bump/package.json | 34 + .../src/getConventionalCommitsBump.test.ts | 79 + .../src/getConventionalCommitsBump.ts | 21 + .../conventional-commits-bump/src/index.ts | 1 + .../tsconfig.json | 6 +- .../package.json | 10 +- .../src/createChangelogHeader.ts | 4 +- .../src/createConventionalChangelogHeader.ts | 4 +- .../src/index.ts | 2 + .../conventional-commits-parser/package.json | 10 +- packages/di-react/gulpfile.js | 1 - packages/di-react/package.json | 4 +- packages/di-tools-analyzer/gulpfile.js | 1 - packages/di-tools-analyzer/package.json | 4 +- packages/di/gulpfile.js | 1 - packages/di/package.json | 5 +- packages/git/package.json | 4 +- packages/git/src/defaultTagPrefix.ts | 1 + packages/git/src/getGitCommits.ts | 96 +- packages/git/src/getGitLastSemverTag.ts | 3 +- packages/git/src/getGitPrefixedTag.ts | 5 + packages/git/src/index.ts | 1 + packages/github/package.json | 6 +- packages/tests-runner/vitest.config.ts | 9 +- yarn.lock | 1456 ++--------------- 48 files changed, 892 insertions(+), 1836 deletions(-) delete mode 100644 packages/ci-github-release/package.json delete mode 100644 packages/ci-github-release/src/IAuthOptions.ts delete mode 100644 packages/ci-github-release/src/git-semver-tags.d.ts delete mode 100644 packages/ci-github-release/src/github-release.ts delete mode 100644 packages/ci-github-release/src/semverRegex.ts delete mode 100644 packages/ci-github-release/src/transform.ts create mode 100644 packages/ci/src/cli.ts delete mode 100644 packages/ci/src/conventional-changelog-conventionalcommits.d.ts create mode 100644 packages/ci/src/createReleaseTag.ts create mode 100644 packages/ci/src/defaultChangelogFile.ts delete mode 100644 packages/ci/src/gulp/IPackageJson.ts delete mode 100644 packages/ci/src/gulp/combineStreams.ts delete mode 100644 packages/ci/src/gulp/index.ts delete mode 100644 packages/ci/src/gulp/prepublish.ts create mode 100644 packages/ci/src/makeRelease.ts create mode 100644 packages/ci/src/markdownMarkers.ts create mode 100644 packages/ci/src/publishGithubRelease.ts create mode 100644 packages/ci/src/readChangelogForVersion.ts create mode 100644 packages/ci/src/readPackageJson.ts create mode 100644 packages/conventional-commits-bump/package.json create mode 100644 packages/conventional-commits-bump/src/getConventionalCommitsBump.test.ts create mode 100644 packages/conventional-commits-bump/src/getConventionalCommitsBump.ts create mode 100644 packages/conventional-commits-bump/src/index.ts rename packages/{ci-github-release => conventional-commits-bump}/tsconfig.json (77%) delete mode 100644 packages/di-react/gulpfile.js delete mode 100644 packages/di-tools-analyzer/gulpfile.js delete mode 100644 packages/di/gulpfile.js create mode 100644 packages/git/src/defaultTagPrefix.ts create mode 100644 packages/git/src/getGitPrefixedTag.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e552be..74a69ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,10 +45,40 @@ jobs: git config user.email 'github-actions[bot]@users.noreply.github.com' - name: Release + run: | + yarn workspaces foreach -A --topological-dev --no-private run ci:release + git commit -m "chore: release" + + - name: Tag + run: | + yarn workspaces foreach -A --topological-dev --no-private run ci:git-tag + git push --follow-tags + + publish-github: + needs: release + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - run: corepack enable + - uses: actions/setup-node@v4 + with: + node-version: "lts/*" + cache: "yarn" + + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + name: build-artifacts + run-id: ${{ inputs.build-run-id }} + + - run: yarn install --immutable + + - name: Release github env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} - run: yarn workspaces foreach -A --topological-dev --no-private run ci:prepublish + run: yarn workspaces foreach -A --topological-dev --no-private run ci:release-github publish-npm: needs: release diff --git a/packages/_aggregate/tsconfig.json b/packages/_aggregate/tsconfig.json index ba07bcf..9d00b56 100644 --- a/packages/_aggregate/tsconfig.json +++ b/packages/_aggregate/tsconfig.json @@ -34,9 +34,6 @@ { "path": "../semver-bump" }, - { - "path": "../ci-github-release" - }, { "path": "../conventional-commits-changelog" }, diff --git a/packages/ci-github-release/package.json b/packages/ci-github-release/package.json deleted file mode 100644 index 2f46a34..0000000 --- a/packages/ci-github-release/package.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "@wroud/ci-github-release", - "private": true, - "version": "0.0.1", - "type": "module", - "packageManager": "yarn@4.4.1", - "devDependencies": { - "@tsconfig/node20": "^20", - "@types/conventional-changelog": "^3", - "@types/conventional-changelog-core": "^4", - "@types/conventional-changelog-writer": "^4", - "@types/conventional-commits-parser": "^5", - "@types/git-semver-tags": "^7", - "@types/node": "^20", - "@types/semver": "^7", - "@wroud/tsconfig": "workspace:*", - "typescript": "^5" - }, - "exports": { - ".": "./lib/github-release.js" - }, - "dependencies": { - "@octokit/rest": "^21", - "conventional-changelog": "^6", - "conventional-changelog-conventionalcommits": "^8", - "git-semver-tags": "^8", - "semver": "^7" - } -} diff --git a/packages/ci-github-release/src/IAuthOptions.ts b/packages/ci-github-release/src/IAuthOptions.ts deleted file mode 100644 index 41903dc..0000000 --- a/packages/ci-github-release/src/IAuthOptions.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface IAuthOptions { - type: "token" | "oauth"; - token: string; -} diff --git a/packages/ci-github-release/src/git-semver-tags.d.ts b/packages/ci-github-release/src/git-semver-tags.d.ts deleted file mode 100644 index 253e132..0000000 --- a/packages/ci-github-release/src/git-semver-tags.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -import * as gitSemverTags from "git-semver-tags"; - -declare module "git-semver-tags" { - const p = gitSemverTags.default; - export { p as getSemverTags }; -} diff --git a/packages/ci-github-release/src/github-release.ts b/packages/ci-github-release/src/github-release.ts deleted file mode 100644 index ae8824a..0000000 --- a/packages/ci-github-release/src/github-release.ts +++ /dev/null @@ -1,126 +0,0 @@ -import conventionalChangelog from "conventional-changelog"; -import { getSemverTags } from "git-semver-tags"; -import { Octokit } from "@octokit/rest"; -import semver from "semver"; -import { Transform } from "stream"; -import { transform } from "./transform.js"; - -import type { - Context, - GitRawCommitsOptions, - ParserOptions, - WriterOptions, -} from "conventional-changelog-core"; -import type { Context as WriterContext } from "conventional-changelog-writer"; -import type { Commit } from "conventional-commits-parser"; -import type { IAuthOptions } from "./IAuthOptions.js"; - -function log(message: string, ...args: any[]) { - console.log(`[ci-github-releaser] ${message}`, ...args); -} - -type Options< - TCommit extends Commit = Commit, - TContext extends WriterContext = WriterContext, -> = conventionalChangelog.Options; - -/** - * Create a GitHub release for the latest version in the repository. - * - * @param packageName - The name of the package. - * @param auth - The authentication options for the GitHub API. - * @param changelogOpts - Options for the changelog generation. - * @param context - The context object with owner and repository. - * @param gitRawCommitsOpts - Options for git-raw-commits. - * @param parserOpts - Options for the parser. - * @param writerOpts - Options for the writer. - */ -export async function githubRelease< - TCommit extends Commit = Commit, - TContext extends WriterContext = Context, ->( - packageName: string, - auth: IAuthOptions, - changelogOpts: Options = {}, - context: Partial = {}, - gitRawCommitsOpts: GitRawCommitsOptions = {}, - parserOpts: ParserOptions = {}, - writerOpts: WriterOptions = {}, -) { - const { owner, repository } = context; - if (!owner || !repository) { - throw new Error("Expected context object with 'owner' and 'repository'"); - } - - log(`Creating GitHub release for ${owner}/${repository}`); - // Initialize Octokit with authentication - const octokit = new Octokit({ auth: auth.token }); - - changelogOpts = { - transform: transform.bind(null, changelogOpts.tagPrefix ?? "v") as any, - releaseCount: 1, - ...changelogOpts, - }; - - writerOpts.includeDetails = true; - writerOpts.headerPartial = writerOpts.headerPartial || ""; - - const tags = await getSemverTags({ - tagPrefix: changelogOpts.tagPrefix, - }); - - if (!tags?.length) { - throw new Error("No semver tags found"); - } - - if (changelogOpts.releaseCount) { - gitRawCommitsOpts = { - from: tags[changelogOpts.releaseCount], - ...gitRawCommitsOpts, - }; - } - - gitRawCommitsOpts.to = gitRawCommitsOpts.to || tags[0]; - - await new Promise((resolve, reject) => { - conventionalChangelog( - changelogOpts, - context, - gitRawCommitsOpts, - parserOpts, - writerOpts, - ) - .pipe( - new Transform({ - objectMode: true, - transform(chunk, enc, callback) { - if (!chunk.keyCommit) { - log("Skipping chunk without keyCommit"); - } - const version = chunk.keyCommit?.version; - if (!version) { - callback(); - return; - } - - const options = { - owner, - repo: repository, - tag_name: chunk.keyCommit["tag"], - name: `${packageName}@${version}`, - body: chunk.log, - draft: false, - prerelease: (semver.parse(version)?.prerelease || []).length > 0, - }; - - octokit.repos - .createRelease(options) - .then(() => callback()) - .catch(callback); - }, - }), - ) - .on("error", reject) - .on("end", resolve); - }); -} diff --git a/packages/ci-github-release/src/semverRegex.ts b/packages/ci-github-release/src/semverRegex.ts deleted file mode 100644 index b97ad7c..0000000 --- a/packages/ci-github-release/src/semverRegex.ts +++ /dev/null @@ -1,6 +0,0 @@ -export function semverRegex(prefix = "v") { - return new RegExp( - `(?<=^${prefix}?|\\s${prefix}?)(?:(?:0|[1-9]\\d{0,9}?)\\.){2}(?:0|[1-9]\\d{0,9})(?:-(?:--+)?(?:0|[1-9]\\d*|\\d*[a-z]+\\d*)){0,100}(?=$| |\\+|\\.)(?:(?<=-\\S+)(?:\\.(?:--?|[\\da-z-]*[a-z]\\d*|0|[1-9]\\d*)){1,100}?)?(?!\\.)(?:\\+(?:[\\da-z]\\.?-?){1,100}?(?!\\w))?(?!\\+)`, - "gi", - ); -} diff --git a/packages/ci-github-release/src/transform.ts b/packages/ci-github-release/src/transform.ts deleted file mode 100644 index e9f1768..0000000 --- a/packages/ci-github-release/src/transform.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Commit } from "conventional-commits-parser"; -import type { Options } from "conventional-changelog-core"; -import { semverRegex } from "./semverRegex.js"; - -export function transform( - prefix: string, - chunk: Commit, - cb: Options.Transform.Callback, -) { - chunk = { ...chunk }; - - const gitTags = chunk["gitTags"]; - if (typeof gitTags === "string") { - const tag = /tag:\s([^,)]+)/gi.exec(gitTags)?.[1]; - - if (tag) { - chunk["tag"] = tag; - chunk["version"] = (tag.match(semverRegex(prefix)) || [])[0]; - } - } - - if (chunk["committerDate"]) { - // Format the date using toISOString and extract the date part in 'yyyy-mm-dd' format - chunk["committerDate"] = new Date(chunk["committerDate"]) - .toISOString() - .split("T")[0]; - } - - cb(null, chunk as T); -} diff --git a/packages/ci/package.json b/packages/ci/package.json index 34de460..efd0c70 100644 --- a/packages/ci/package.json +++ b/packages/ci/package.json @@ -3,28 +3,40 @@ "private": true, "type": "module", "packageManager": "yarn@4.4.1", + "bin": "./lib/cli.js", + "scripts": { + "ci:prepublish": "", + "test": "tests-runner", + "test:ci": "CI=true yarn run test", + "build": "tsc -b", + "watch:tsc": "tsc -b -w", + "dev": "concurrently \"npm:watch:*\"", + "clear": "rimraf ./lib" + }, + "dependencies": { + "@octokit/auth-action": "^5", + "@octokit/rest": "^21", + "@wroud/conventional-commits-bump": "workspace:*", + "@wroud/conventional-commits-changelog": "workspace:*", + "@wroud/conventional-commits-parser": "workspace:*", + "@wroud/git": "workspace:*", + "@wroud/github": "workspace:*", + "@wroud/tests-runner": "workspace:*", + "execa": "^9", + "semver": "^7", + "tempy": "^3", + "yargs": "^17" + }, "devDependencies": { - "@conventional-changelog/git-client": "^1", "@tsconfig/node20": "^20", - "@types/conventional-changelog": "^3", - "@types/conventional-changelog-core": "^4", - "@types/conventional-changelog-writer": "^4", - "@types/conventional-commits-parser": "^5", - "@types/gulp": "^4", "@types/node": "^20", + "@types/semver": "^7", + "@types/yargs": "^17", + "@vitest/coverage-v8": "^2", "@wroud/tsconfig": "workspace:*", - "typescript": "^5" - }, - "exports": { - "./gulp": "./lib/gulp/index.js" - }, - "dependencies": { - "@wroud/ci-github-release": "workspace:^0", - "@wroud/semver-bump": "workspace:^0", - "conventional-changelog": "^6", - "conventional-changelog-conventionalcommits": "^8", - "execa": "^9", - "gulp": "^5", - "tempy": "^3" + "concurrently": "^8", + "rimraf": "^6", + "typescript": "^5", + "vitest": "^2" } } diff --git a/packages/ci/src/cli.ts b/packages/ci/src/cli.ts new file mode 100644 index 0000000..12cb8cb --- /dev/null +++ b/packages/ci/src/cli.ts @@ -0,0 +1,118 @@ +#!/usr/bin/env node +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { makeRelease } from "./makeRelease.js"; +import { createReleaseTag } from "./createReleaseTag.js"; +import { publishGithubRelease } from "./publishGithubRelease.js"; +import { createActionAuth } from "@octokit/auth-action"; + +await yargs(hideBin(process.argv)) + .command( + "release [path]", + "bump version and update changelog", + (yargs) => { + return yargs + .options({ + prefix: { + type: "string", + description: "tag prefix", + }, + changeLogFile: { + type: "string", + description: "path to changelog file", + }, + dryRun: { + type: "boolean", + description: "run without making changes", + }, + }) + .positional("path", { + type: "string", + description: "path to git repository", + default: undefined, + }); + }, + async (argv) => { + await makeRelease({ + prefix: argv.prefix, + changeLogFile: argv.changeLogFile, + dryRun: argv.dryRun, + path: argv.path, + }); + }, + ) + .command( + "git-tag", + "create release tag", + (yargs) => { + return yargs + .options({ + prefix: { + type: "string", + description: "tag prefix", + }, + dryRun: { + type: "boolean", + description: "run without making changes", + }, + }) + .positional("path", { + type: "string", + description: "path to git repository", + default: undefined, + }); + }, + async (argv) => { + await createReleaseTag({ + prefix: argv.prefix, + dryRun: argv.dryRun, + }); + }, + ) + .command( + "release-github", + "create github release", + (yargs) => { + return yargs.options({ + prefix: { + type: "string", + description: "tag prefix", + }, + dryRun: { + type: "boolean", + description: "run without making changes", + }, + }); + }, + async (argv) => { + const dryRun = argv.dryRun; + + const [owner, repository] = + process.env["GITHUB_REPOSITORY"]?.split("/") || dryRun + ? ["owner", "repo"] + : []; + + if (!owner || !repository) { + throw new Error("GITHUB_REPOSITORY environment variable is required"); + } + + let auth; + + if (!dryRun) { + const actionAuthStrategy = createActionAuth(); + auth = await actionAuthStrategy(); + } else { + auth = undefined; + } + + await publishGithubRelease({ + auth, + owner, + repository, + prefix: argv.prefix, + dryRun, + }); + }, + ) + .demandCommand(1) + .parse(); diff --git a/packages/ci/src/conventional-changelog-conventionalcommits.d.ts b/packages/ci/src/conventional-changelog-conventionalcommits.d.ts deleted file mode 100644 index 1698ec6..0000000 --- a/packages/ci/src/conventional-changelog-conventionalcommits.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -declare module "conventional-changelog-conventionalcommits" { - import type { ParserStreamOptions } from "conventional-commits-parser"; - import type { - GetCommitsParams, - Params, - } from "@conventional-changelog/git-client"; - import type { Options } from "conventional-changelog-core"; - - export type Preset = Options.Config & { - commits?: GetCommitsParams & Params; - parser?: ParserStreamOptions; - }; - - export default function createPreset(options?: any): Promise; -} diff --git a/packages/ci/src/createReleaseTag.ts b/packages/ci/src/createReleaseTag.ts new file mode 100644 index 0000000..24e08a0 --- /dev/null +++ b/packages/ci/src/createReleaseTag.ts @@ -0,0 +1,47 @@ +import { getGitLastSemverTag, getGitPrefixedTag } from "@wroud/git"; +import { execa } from "execa"; +import { readPackageJson } from "./readPackageJson.js"; + +export interface IPushTagOptions { + prefix?: string; + dryRun?: boolean; +} + +export async function createReleaseTag({ + prefix, + dryRun, +}: IPushTagOptions = {}): Promise { + const lastRelease = await getGitLastSemverTag({ + prefix, + }); + + if (dryRun) { + console.log("Last release: ", lastRelease); + } + + const { version, name } = await readPackageJson(); + + if (!version) { + throw new Error("Version not found in package.json"); + } + + const tag = getGitPrefixedTag(version, prefix); + if (lastRelease?.endsWith(version)) { + console.log("Tag already exists: ", `${tag}`); + return; + } + + if (dryRun) { + console.log("Create git tag: ", `${tag}`); + } else { + await execa( + "git", + ["tag", "-a", `${tag}`, "-m", `Release ${name}@${version}`], + { + stdout: "inherit", + }, + ); + } + + console.log("Tag created: ", `${tag}`); +} diff --git a/packages/ci/src/defaultChangelogFile.ts b/packages/ci/src/defaultChangelogFile.ts new file mode 100644 index 0000000..24e686e --- /dev/null +++ b/packages/ci/src/defaultChangelogFile.ts @@ -0,0 +1 @@ +export const defaultChangelogFile = "CHANGELOG.md"; diff --git a/packages/ci/src/gulp/IPackageJson.ts b/packages/ci/src/gulp/IPackageJson.ts deleted file mode 100644 index 62529cb..0000000 --- a/packages/ci/src/gulp/IPackageJson.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface IPackageJson { - name: string; - version?: string; - description?: string; -} diff --git a/packages/ci/src/gulp/combineStreams.ts b/packages/ci/src/gulp/combineStreams.ts deleted file mode 100644 index d18e2e2..0000000 --- a/packages/ci/src/gulp/combineStreams.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { PassThrough } from "stream"; - -// Function to create a combined readable stream from two streams -export function combineStreams(...streams: NodeJS.ReadableStream[]) { - const passThrough = new PassThrough(); - let currentStreamIndex = 0; - - function pipeNext() { - if (currentStreamIndex < streams.length) { - const currentStream = streams[currentStreamIndex]!; - currentStream.pipe(passThrough, { end: false }); - currentStream.on("end", () => { - currentStreamIndex++; - pipeNext(); - }); - currentStream.on("error", (error) => { - passThrough.emit("error", error); - }); - } else { - passThrough.end(); - } - } - - pipeNext(); - return passThrough; -} diff --git a/packages/ci/src/gulp/index.ts b/packages/ci/src/gulp/index.ts deleted file mode 100644 index 8492272..0000000 --- a/packages/ci/src/gulp/index.ts +++ /dev/null @@ -1 +0,0 @@ -import "./prepublish.js"; diff --git a/packages/ci/src/gulp/prepublish.ts b/packages/ci/src/gulp/prepublish.ts deleted file mode 100644 index d37c448..0000000 --- a/packages/ci/src/gulp/prepublish.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { task } from "gulp"; -import { execa } from "execa"; -import { temporaryFile } from "tempy"; -import { rename, readFile, writeFile } from "fs/promises"; -import conventionalChangelog from "conventional-changelog"; -import createPreset, { - type Preset, -} from "conventional-changelog-conventionalcommits"; -import { pipeline } from "stream/promises"; -import { combineStreams } from "./combineStreams.js"; -import { createReadStream, createWriteStream, existsSync } from "fs"; -import { githubRelease } from "@wroud/ci-github-release"; -import type { IPackageJson } from "./IPackageJson.js"; -import { getBump } from "@wroud/semver-bump"; -import type { GetCommitsParams } from "@conventional-changelog/git-client"; - -const tagPrefix = "di-v"; -const commitPath = "."; -const changeLogFile = "CHANGELOG.md"; -// print output of commands into the terminal -const stdio = "inherit"; -const commitsConfig: GetCommitsParams = { - path: commitPath, -}; - -async function readPackageJson(): Promise { - return await readFile("package.json", "utf8").then((data) => - JSON.parse(data), - ); -} - -async function bumpVersion(preset: Preset): Promise { - const bump = await getBump( - { prefix: tagPrefix }, - { ...preset.commits, ...commitsConfig }, - ); - - if (!bump) { - return null; - } - - await execa("yarn", ["version", bump], { - stdio, - }); - - return await readPackageJson().then((content) => content.version || null); -} - -async function changelog(preset: Preset, version: string) { - if (!existsSync(changeLogFile)) { - await writeFile(changeLogFile, "", "utf8"); - } - - const changelogStream = conventionalChangelog( - { - config: preset, - tagPrefix, - }, - undefined, - { path: commitPath }, - ); - - const combinedStream = combineStreams( - changelogStream, - createReadStream(changeLogFile), - ); - - const tmp = temporaryFile(); - - await pipeline(combinedStream, createWriteStream(tmp, { flags: "w" })); - await rename(tmp, changeLogFile); - - return version; -} - -async function commitTagPush(version: string, packageName: string) { - const commitMsg = `chore: release ${packageName}@${version}`; - await execa("git", ["add", "package.json", "CHANGELOG.md"], { stdio }); - await execa("git", ["commit", "--message", commitMsg], { stdio }); - await execa("git", ["tag", "-a", `${tagPrefix}${version}`, "-m", commitMsg], { - stdio, - }); - await execa("git", ["push", "--follow-tags"], { stdio }); -} - -async function publishGithubRelease(preset: Preset, packageName: string) { - const token = process.env["GITHUB_TOKEN"]; - const [owner, repository] = - process.env["GITHUB_REPOSITORY"]?.split("/") || []; - - if (!token) { - throw new Error("Expected GITHUB_TOKEN environment variable"); - } - - await githubRelease( - packageName, - { type: "oauth", token }, - { config: preset, tagPrefix }, - { owner, repository }, - { path: commitPath }, - ); -} - -task("ci:prepublish", async (done) => { - try { - const packageJson = await readPackageJson(); - const preset = await createPreset(); - const version = await bumpVersion(preset); - - if (version === null) { - console.log("No new version to release"); - done(); - return; - } - - await changelog(preset, version); - await commitTagPush(version, packageJson.name); - await publishGithubRelease(preset, packageJson.name); - - done(); - } catch (error: any) { - done(error); - } -}); diff --git a/packages/ci/src/makeRelease.ts b/packages/ci/src/makeRelease.ts new file mode 100644 index 0000000..42b199a --- /dev/null +++ b/packages/ci/src/makeRelease.ts @@ -0,0 +1,186 @@ +import { getConventionalCommitsBump } from "@wroud/conventional-commits-bump"; +import { + gitTrailersConventionalCommits, + parseConventionalCommit, + type IConventionalCommit, +} from "@wroud/conventional-commits-parser"; +import { getGitCommits, getGitLastSemverTag } from "@wroud/git"; +import { execa } from "execa"; +import { + createReadStream, + createWriteStream, + existsSync, + WriteStream, +} from "fs"; +import { rename, writeFile } from "fs/promises"; +import { + createChangelogHeader, + createConventionalChangelog, + createConventionalChangelogHeader, +} from "@wroud/conventional-commits-changelog"; +import { pipeline } from "stream/promises"; +import { Readable, Transform, Writable } from "stream"; +import { temporaryFile } from "tempy"; +import { createInterface } from "readline"; +import { markdownMarkers } from "./markdownMarkers.js"; +import semver from "semver"; +import { stdout } from "process"; +import { readPackageJson } from "./readPackageJson.js"; +import { defaultChangelogFile } from "./defaultChangelogFile.js"; + +export interface IMakeReleaseOptions { + changeLogFile?: string; + prefix?: string; + path?: string; + dryRun?: boolean; +} + +export async function makeRelease({ + changeLogFile = defaultChangelogFile, + prefix, + path, + dryRun, +}: IMakeReleaseOptions = {}) { + const lastRelease = await getGitLastSemverTag({ + prefix, + }); + + if (dryRun) { + console.log("Last release: ", lastRelease); + } + + const commits: IConventionalCommit[] = []; + + for await (const commit of getGitCommits({ + path, + from: lastRelease, + customTrailers: [...gitTrailersConventionalCommits], + })) { + const conventionalCommit = parseConventionalCommit(commit); + + if (conventionalCommit) { + commits.push(conventionalCommit); + } else if (dryRun) { + console.warn("Skipping not conventional commit: ", commit.subject); + } + } + + const bump = getConventionalCommitsBump(commits); + + if (!bump) { + console.log("No release needed"); + return; + } + + if (dryRun) { + console.log("Bump type: ", bump); + } else { + await execa("yarn", ["version", bump], { + stdout: "inherit", + }); + } + + let { version } = await readPackageJson(); + + if (!version) { + throw new Error("Version not found in package.json"); + } + + if (dryRun) { + version = semver.inc(version, bump)!; + } + + if (!existsSync(changeLogFile)) { + if (dryRun) { + console.log("Creating changelog file: ", changeLogFile); + } else { + await writeFile(changeLogFile, ""); + } + } + + const tmpFile = temporaryFile(); + + if (dryRun) { + console.log("Writing data to: ", tmpFile); + } + + await pipeline( + Readable.from(changelogHead(version, commits)).map((line) => line + "\n"), + mockWritableStream(dryRun, () => + createWriteStream(tmpFile, { flags: "w" }), + ), + ); + + let skipping = false; + await pipeline( + createInterface( + createReadStream(changeLogFile, { + flags: "r", + }), + ), + new Transform({ + transform(chunk, encoding, callback) { + const line = chunk.toString(); + if (line === markdownMarkers.header) { + skipping = true; + } + + if ( + skipping && + (!markdownMarkers.isVersionMarker(line.toString()) || + markdownMarkers.version(version) === line) + ) { + callback(null, ""); + } else { + skipping = false; + callback(null, line + "\n"); + } + }, + }), + mockWritableStream(dryRun, () => + createWriteStream(tmpFile, { flags: "a" }), + ), + ); + + if (dryRun) { + console.log(`Renaming "${tmpFile}" to "${changeLogFile}"`); + } else { + await rename(tmpFile, changeLogFile); + } + + if (dryRun) { + console.log(`Adding "${changeLogFile}" and "package.json" to git`); + } else { + await execa("git", ["add", changeLogFile, "package.json"], { + stdout: "inherit", + }); + } + + console.log("Release done"); +} + +async function* changelogHead( + version: string, + commits: IConventionalCommit[], +): AsyncGenerator { + yield markdownMarkers.header; + yield* createChangelogHeader(); + yield markdownMarkers.version(version); + yield* createConventionalChangelogHeader(version); + yield* createConventionalChangelog(commits); +} + +function mockWritableStream( + dryRun: boolean | undefined, + createWritableStream: () => T, +): T { + if (dryRun) { + return new Writable({ + write(chunk, encoding, callback) { + stdout.write(chunk, encoding, callback); // Write to stdout without closing it + }, + }) as unknown as T; + } else { + return createWritableStream(); + } +} diff --git a/packages/ci/src/markdownMarkers.ts b/packages/ci/src/markdownMarkers.ts new file mode 100644 index 0000000..197a20d --- /dev/null +++ b/packages/ci/src/markdownMarkers.ts @@ -0,0 +1,5 @@ +export const markdownMarkers = { + header: "", + version: (version: string) => ``, + isVersionMarker: (line: string) => line.startsWith("