├── .czrc ├── .editorconfig ├── .github ├── dependabot.yml └── workflows │ └── build-and-release.yml ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── prepare-commit-msg ├── .npmrc ├── .nvmrc ├── LICENSE ├── README.md ├── commitlint.config.js ├── docs ├── command.md ├── exec-command.md ├── file-writer-command.md ├── git-client.md ├── git-commit-command.md ├── git-exec-client.md ├── git-push-branch-command.md ├── git-strategy.md ├── git-switch-branch-command.md ├── git-tag-based-release.md ├── git-tag-command.md ├── git-trunk-release.md ├── github-create-issue-comments-command.md ├── github-create-pull-request-command.md ├── github-create-release-command.md ├── github-http-command.md ├── github-npm-package-strategy.md ├── github-npm-strategy-fail-demo.gif ├── http-command.md ├── logger.md ├── npm-bump-package-version-command.md ├── npm-command.md ├── npm-publish-package-command.md ├── process-stdout-logger.md ├── release.md └── strategy.md ├── eslint.config.js ├── lint-staged.config.js ├── package.json ├── scripts └── release.js ├── src ├── commands │ ├── exec-command.ts │ ├── file-writer-command.ts │ ├── git │ │ ├── git-commit-command.ts │ │ ├── git-push-branch-command.ts │ │ ├── git-switch-branch-command.ts │ │ └── git-tag-command.ts │ ├── github │ │ ├── github-create-issue-comments-command.ts │ │ ├── github-create-pull-request-command.ts │ │ ├── github-create-release-command.ts │ │ └── github-http-command.ts │ ├── http-command.ts │ ├── index.ts │ └── npm │ │ ├── npm-bump-package-version-command.ts │ │ ├── npm-command.ts │ │ └── npm-publish-package-command.ts ├── index.ts ├── sdk │ ├── command.ts │ ├── git-client.d.ts │ ├── git-exec-client.ts │ ├── git-strategy.ts │ ├── git-tag-based-release.ts │ ├── git-trunk-release.ts │ ├── github-npm-package-strategy.ts │ ├── index.ts │ ├── logger.d.ts │ ├── process-stdout-logger.ts │ ├── release.d.ts │ └── strategy.ts └── utils │ ├── exec.ts │ └── timer.ts ├── tests ├── integration │ ├── __snapshots__ │ │ ├── git-tag-based-release.test.ts.snap │ │ ├── git-trunk-release.test.ts.snap │ │ └── github-npm-package-strategy.test.ts.snap │ ├── git-tag-based-release.test.ts │ ├── git-trunk-release.test.ts │ └── github-npm-package-strategy.test.ts ├── stubs.ts └── unit │ ├── __snapshots__ │ └── strategy.test.ts.snap │ ├── exec-command.test.ts │ ├── file-write-command.test.ts │ ├── git-commit-command.test.ts │ ├── git-exec-client.test.ts │ ├── git-push-branch-command.test.ts │ ├── git-switch-branch-command.test.ts │ ├── git-tag-command.test.ts │ ├── github-create-issue-comments-command.test.ts │ ├── github-create-pull-request-command.test.ts │ ├── github-create-release-command.test.ts │ ├── npm-bump-package-version-command.test.ts │ ├── npm-publish-package-command.test.ts │ └── strategy.test.ts ├── tsconfig.json └── vitest.config.js /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "@commitlint/cz-commitlint" 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | 9 | [*.{ts, js}] 10 | indent_size = 2 11 | indent_style = space 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Set update schedule for GitHub Actions 2 | # https://docs.github.com/en/github/administering-a-repository/keeping-your-actions-up-to-date-with-github-dependabot 3 | version: 2 4 | updates: 5 | - package-ecosystem: "github-actions" 6 | directory: "/" 7 | schedule: 8 | interval: "daily" 9 | -------------------------------------------------------------------------------- /.github/workflows/build-and-release.yml: -------------------------------------------------------------------------------- 1 | name: Build & Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | 8 | env: 9 | CI: true 10 | HUSKY: 0 11 | 12 | jobs: 13 | build_and_release: 14 | name: Build & Release 15 | 16 | # wait for a previous job to complete 17 | # https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#concurrency 18 | concurrency: 19 | group: ${{ github.workflow }}-${{ github.ref }} 20 | 21 | if: ${{ !contains(github.event.head_commit.message, '[skip-ci]') }} 22 | 23 | runs-on: ubuntu-latest 24 | 25 | steps: 26 | # https://github.com/actions/checkout 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | with: 30 | token: ${{ secrets.BOT_PAT }} 31 | fetch-depth: 0 32 | persist-credentials: true 33 | 34 | # https://github.com/actions/setup-node 35 | - name: Setup Node.js 36 | id: setup-node 37 | uses: actions/setup-node@v4 38 | with: 39 | node-version-file: '.nvmrc' 40 | 41 | # https://github.com/actions/cache 42 | - name: Cache node_modules 43 | uses: actions/cache@v4 44 | id: npm-cache 45 | with: 46 | path: node_modules 47 | # include the branch name here...? 48 | key: node-modules-${{ steps.setup-node.outputs.node-version }}-${{ hashFiles('package.json') }} 49 | 50 | - name: Install node_modules 51 | if: steps.npm-cache.outputs.cache-hit != 'true' 52 | run: npm install 53 | 54 | - name: Lint Code 55 | run: npm run lint 56 | 57 | # https://commitlint.js.org/guides/ci-setup.html#github-actions 58 | - name: Lint Commit Messages 59 | run: | 60 | if [[ '${{github.event_name}}' == 'push' ]]; then 61 | npx commitlint --last --verbose 62 | elif [[ '${{github.event_name}}' == 'pull_request' ]]; then 63 | npx commitlint --from ${{ github.event.pull_request.head.sha }}~${{ github.event.pull_request.commits }} --to ${{ github.event.pull_request.head.sha }} --verbose 64 | fi 65 | 66 | - name: Build 67 | run: npm run build 68 | 69 | - name: Test 70 | run: npm run test 71 | 72 | - name: Release 73 | run: npm run release 74 | env: 75 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 76 | RELEASE_ACTOR: abstracter-bot 77 | GITHUB_PAT_TOKEN: ${{ secrets.BOT_PAT }} 78 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | build 3 | node_modules 4 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx -- commitlint --edit $1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx --no-install lint-staged -c lint-staged.config.js 2 | -------------------------------------------------------------------------------- /.husky/prepare-commit-msg: -------------------------------------------------------------------------------- 1 | if [ "$2" != "message" ]; then 2 | exec < /dev/tty && npx cz --hook || true 3 | fi 4 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | //registry.npmjs.org/:_authToken=${NPM_TOKEN} 2 | package-lock = false 3 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18.18.0 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-present abstracter-io 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Atomic Release 2 | 3 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-blue.svg)](http://commitizen.github.io/cz-cli/) 4 | [![js-standard-style](https://img.shields.io/badge/code_style-standard-blue.svg?style=flat)](https://github.com/feross/standard) 5 | [![npm latest version](https://img.shields.io/npm/v/@abstracter/atomic-release/latest.svg?color=009688)](https://www.npmjs.com/package/@abstracter/atomic-release) 6 | 7 | Atomic Release is an SDK to help automate a software release process with the ability to "undo" steps taken when a release process fails. 8 | 9 | ## Highlights 10 | 11 | - TypeScript friendly. 12 | - A super simple SDK with loosely coupled APIs. Use just what you need. 13 | - Can be used with any project type (just need a node runtime). 14 | - A strategy for releasing npm packages: (bumping versions, generating changelogs, and much more) 15 | 16 | ![github-npm-strategy-demo](docs/github-npm-strategy-fail-demo.gif) 17 | 18 | A failure during a release undoes previous commands 19 | 20 | Find out more by reading the [docs](docs) 21 | 22 | > 💡   Fun fact: This library is released using githubNpmPackageStrategy. [See example](scripts/release.js) 23 | 24 | ## Install 25 | 26 | **Prerequisites**: [Node.js](https://nodejs.org/) 27 | 28 | > npm install --save-dev @abstracter/atomic-release 29 | 30 | ## Documentation 31 | 32 | - [Logger](docs/logger.md) 33 | - [processStdoutLogger](docs/process-stdout-logger.md) 34 | - [Strategy](docs/strategy.md) 35 | - [githubNpmPackageStrategy](docs/github-npm-package-strategy.md) 36 | - [Release](docs/release.md) 37 | - [gitTrunkRelease](docs/git-trunk-release.md) 38 | - [gitTagBasedRelease](docs/git-tag-based-release.md) 39 | - [GitClient](docs/git-client.md) 40 | - [GitExecClient](docs/git-exec-client.md) 41 | - [Command](docs/command.md) 42 | - [ExecCommand](docs/exec-command.md) 43 | - [HttpCommand](docs/http-command.md) 44 | - [FileWriterCommand](docs/file-writer-command.md) 45 | - [GitCommitCommand](docs/git-commit-command.md) 46 | - [GitSwitchBranchCommand](docs/git-switch-branch-command.md) 47 | - [GitPushBranchCommand](docs/git-push-branch-command.md) 48 | - [GitTagCommand](docs/git-tag-command.md) 49 | - [GithubHttpCommand](docs/github-http-command.md) 50 | - [GithubCreateIssueCommentsCommand](docs/github-create-issue-comments-command.md) 51 | - [GithubCreatePullRequestCommand](docs/github-create-pull-request-command.md) 52 | - [GithubCreateReleaseCommand](docs/github-create-release-command.md) 53 | - [NpmCommand](docs/npm-command.md) 54 | - [NpmBumpPackageVersionCommand](docs/npm-bump-package-version-command.md) 55 | - [NpmPublishPackageCommand](docs/npm-publish-package-command.md) 56 | 57 | ## FAQ 58 | * Where is package-lock.json? 59 | Read this [blog post](https://www.twilio.com/blog/lockfiles-nodejs) by Twilio learn more. 60 | 61 | * Do I have to use TypeScript to use this SDK? 62 | No you don't. 63 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/command.md: -------------------------------------------------------------------------------- 1 | # Command 2 | 3 | A command is an object with two key methods, "do" which performs an action, and "undo" which rolls back 4 | any actions taken by the "do" method. 5 | 6 | ### Config 7 | 8 | ###### Optional properties are denoted by * 9 | 10 | Type: `object literal` 11 | 12 | ##### logger* 13 | 14 | Type: [Logger](./logger.md) 15 | Default: [processStdoutLogger](./process-stdout-logger.md) 16 | 17 | ### Creating a Custom Command 18 | 19 | Here's an example showing how to create a command: 20 | 21 | ```js 22 | const fs = require("fs"); 23 | const { SDK } = require("@abstracter/atomic-release"); 24 | 25 | class CreateFolderCommand extends SDK.Command { 26 | constructor(options) { 27 | super(options); 28 | 29 | this.createdFolder = false; 30 | } 31 | 32 | /** 33 | * This method deletes the created dirPath 34 | * 35 | * return value is a promise for a void/undefined 36 | */ 37 | async undo() { 38 | if (this.createdFolder) { 39 | await fs.promises.rmdir(this.options.dirPath, { recursive: true }); 40 | 41 | this.logger.info(`Deleted created folder ${this.options.dirPath}`); 42 | } 43 | } 44 | 45 | /** 46 | * This method will create a directory path (like mkdir -p) 47 | * 48 | * return value is a promise for a void/undefined 49 | */ 50 | async do() { 51 | await fs.promises.mkdir(this.options.dirPath, { recursive: true }); 52 | 53 | this.logger.info(`Created ${this.options.dirPath}`); 54 | 55 | this.createdFolder = true; 56 | } 57 | } 58 | 59 | // Usage example: 60 | // ============== 61 | const command = new CreateFolderCommand({ dirPath: `${process.cwd()}/this/is/sparta` }); 62 | 63 | // This will create the 'dirPath' 64 | await command.do(); 65 | 66 | // This will delete the created 'dirPath' 67 | await command.undo(); 68 | ``` 69 | -------------------------------------------------------------------------------- /docs/exec-command.md: -------------------------------------------------------------------------------- 1 | # ExecCommand 2 | 3 | An abstract class with a method to create a child process using `node:child_process` module. 4 | 5 | ### Config 6 | 7 | Type: `object literal` 8 | 9 | ###### Optional properties are denoted by * 10 | 11 | ##### logStd* 12 | 13 | Type: `boolean` 14 | Default: `false` 15 | 16 | Whether to output the child process stdout / stderr (useful for debugging) 17 | 18 | ##### workingDirectory 19 | 20 | Type: `string` 21 | 22 | ### Example 23 | 24 | ```js 25 | const { Commands } = require("@abstracter/atomic-release"); 26 | 27 | class MoveCommand extends Commands.ExecCommand { 28 | async do() { 29 | await this.exec("mv", ["a", "b"]); 30 | } 31 | 32 | async undo() { 33 | await this.exec("mv", ["b", "a"]); 34 | } 35 | } 36 | ``` 37 | 38 | > TIP: To log stdout & stderr regardless of the used `logStd` config 39 | > an environment variable with CSV can be set: 40 | > process.env.EXEC_COMMAND_LOG_STD='GitTagCommand,GitPushBranchCommand' 41 | -------------------------------------------------------------------------------- /docs/file-writer-command.md: -------------------------------------------------------------------------------- 1 | # FileWriterCommand 2 | 3 | A command to update a text file content / create file 4 | 5 | ### Config 6 | 7 | Type: `object literal` 8 | 9 | ###### Optional properties are denoted by * 10 | 11 | ##### content 12 | 13 | Type: `string` 14 | 15 | ##### create* 16 | 17 | Type: `boolean` 18 | Default: `false` 19 | 20 | Whether to create the file in case it does not exist on disk. 21 | 22 | ##### absoluteFilePath 23 | 24 | Type: `string` 25 | 26 | ##### mode* 27 | 28 | Type: `string` 29 | Default: `append` 30 | Possible values: `replace | prepend | append` 31 | 32 | ### Examples 33 | 34 | ```js 35 | const { Commands } = require("@abstracter/atomic-release"); 36 | 37 | const createFileCommand = new Commands.FileWriterCommand({ 38 | create: true, 39 | content: "This is SPARTA", 40 | absoluteFilePath: "/home/dev/project/new-file.txt", 41 | }); 42 | 43 | const prependContentCommnd = new Commands.FileWriterCommand({ 44 | content: "42", 45 | mode: "prepend", 46 | absoluteFilePath: "/home/dev/project/existing.txt", 47 | }); 48 | ``` 49 | -------------------------------------------------------------------------------- /docs/git-client.md: -------------------------------------------------------------------------------- 1 | # GitClient 2 | 3 | An interface describing the API needed by the SDK. 4 | 5 | ```ts 6 | interface GitClient { 7 | /** 8 | * @param range - The range to log (see https://git-scm.com/docs/git-log) 9 | * @param format - A format of each log entry (see https://git-scm.com/docs/pretty-formats) 10 | * 11 | * @returns A promise for an array of log entries (log entry is a string) 12 | * 13 | * 14 | * @example 15 | * 16 | * console.log(await gitClient.log("HEAD", "%H")) -> ["eb7f9f87e1a08f0ddc0ea841225a6d7b64ebcf88"]; 17 | */ 18 | log(range: string, format: string): Promise; 19 | 20 | /** 21 | * @param ref - The name of the ref to retrieve the hash for 22 | * 23 | * @returns A promise for the ref hash (a string) 24 | * 25 | * @example 26 | * 27 | * console.log(await gitClient.refHash("HEAD")) -> "eb7f9f87e1a08f0ddc0ea841225a6d7b64ebcf88"; 28 | */ 29 | refHash(ref: string): Promise; 30 | 31 | /** 32 | * @param ref - The name of the ref to retrieve the abbreivated name for 33 | * 34 | * @returns A promise for the ref name (a string) 35 | * 36 | * @example 37 | * 38 | * console.log(await gitClient.refHash("HEAD")) -> "master"; 39 | */ 40 | refName(ref: string): Promise; 41 | 42 | /** 43 | * @param range - The range to log (see https://git-scm.com/docs/git-log) 44 | * 45 | * @returns A promise for an array of "commits objects" (see example) 46 | * 47 | * @example 48 | * 49 | * console.log(await gitClient.commits("HEAD")) -> [{ 50 | * hash: "eb7f9f87e1a08f0ddc0ea841225a6d7b64ebcf88", 51 | * 52 | * subject: "chore(scope): some text", 53 | * 54 | * body: "This is some additional description", 55 | * 56 | * notes: "123", 57 | * 58 | * tags: ["v0.1.0", "v2.1.0"], 59 | * 60 | * committedTimestamp: 1634136647266, 61 | * 62 | * author: { 63 | * name: "Rick Sanchez", 64 | * email: "rick.sanchez@show-me-what-got.com", 65 | * }, 66 | * 67 | * committer: { 68 | * name: "Rick Sanchez", 69 | * email: "rick.sanchez@show-me-what-got.com", 70 | * } 71 | * }]; 72 | */ 73 | commits(range: string): Promise; 74 | 75 | /** 76 | * @param ref - The ref to retrieve merged tags for (see https://git-scm.com/docs/git-tag#Documentation/git-tag.txt---mergedltcommitgt) 77 | * 78 | * @returns A promise for an array of "merged tags objects" (see example) 79 | * 80 | * @example 81 | * 82 | * console.log(await gitClient.commits("HEAD")) -> [{ 83 | * hash: "eb7f9f87e1a08f0ddc0ea841225a6d7b64ebcf88", 84 | * 85 | * name: "v1.0.0", 86 | * }]; 87 | */ 88 | listTags(): Promise; 89 | 90 | /** 91 | * @param tagName - The tag name to get the remote hash for 92 | * 93 | * @returns A promise for the remote tag hash (a string) or null if tag does not exist. 94 | * 95 | * @example 96 | * 97 | * console.log(await gitClient.commits("v1.0.0")) -> "eb7f9f87e1a08f0ddc0ea841225a6d7b64ebcf88"; 98 | * 99 | * console.log(await gitClient.commits("v3.0.0")) -> null; 100 | */ 101 | remoteTagHash(tagName: string): Promise; 102 | 103 | /** 104 | * @param branchName - The branch name to get the remote hash for 105 | * 106 | * @returns A promise for the remote branch hash (a string) or null if branch does not exist. 107 | * 108 | * @example 109 | * 110 | * console.log(await gitClient.commits("main")) -> "eb7f9f87e1a08f0ddc0ea841225a6d7b64ebcf88"; 111 | * 112 | * console.log(await gitClient.commits("does not exists")) -> null; 113 | */ 114 | remoteBranchHash(branchName?: string): Promise; 115 | } 116 | ``` 117 | 118 | See [GitExecClient](./git-exec-client.md) for a reference implementation 119 | -------------------------------------------------------------------------------- /docs/git-commit-command.md: -------------------------------------------------------------------------------- 1 | # GitCommitCommand 2 | 3 | A Git command that stages and commits files. 4 | 5 | ### Config 6 | 7 | Type: `object literal` 8 | 9 | ###### Optional properties are denoted by * 10 | 11 | ##### actor* 12 | 13 | Type: `string` 14 | Default: `undefined` 15 | 16 | A short-hand to perform the commit using a specific author & committer email and name. 17 | 18 | Example: 19 | 20 | ```js 21 | /* 22 | * The value must be in author format: "NAME " 23 | */ 24 | { gitActor: "bot " } 25 | ``` 26 | 27 | ``` 28 | It is also possible to explicitly use the equivalent environment variables: 29 | 30 | GIT_COMMITTER_NAME: bot 31 | GIT_COMMITTER_EMAIL: bot@email.com 32 | GIT_AUTHOR_NAME: bot 33 | GIT_AUTHOR_EMAIL: bot@email.com 34 | ``` 35 | 36 | ##### commitMessage 37 | 38 | Type: `string` 39 | 40 | ##### filePaths 41 | 42 | Type: `Set` 43 | 44 | > :information_source:   [ExecCommand](./exec-command.md) options are also applicable. 45 | 46 | ### Example 47 | 48 | ```js 49 | const { Commands } = require("@abstracter/atomic-release"); 50 | 51 | const command = new Commands.GitCommitCommand({ 52 | actor: "bot ", 53 | commitMessage: "ci: adding files generated during CI/CD", 54 | workingDirectory: "/home/rick.sanchez/my-awesome-node-project", 55 | filePaths: new Set(["path/relative/to/working/directory/file.txt"]), 56 | }); 57 | ``` 58 | -------------------------------------------------------------------------------- /docs/git-exec-client.md: -------------------------------------------------------------------------------- 1 | # GitExecClient 2 | 3 | An implementation of the SDK [GitClient](./git-client.md) interface using `node:child_process` module. 4 | 5 | ### Config 6 | 7 | ###### Optional properties are denoted by * 8 | 9 | Type: `object literal` 10 | 11 | ##### remote* 12 | 13 | Type: `string` 14 | Default: `origin` 15 | 16 | ##### workingDirectory* 17 | 18 | Type: `string` 19 | Default: `process.cwd()` 20 | 21 | ### Example 22 | 23 | ```js 24 | const { SDK } = require("@abstracter/atomic-release"); 25 | 26 | const gitClient = new SDK.GitExecClient({ remote: "origin2", workingDirectory: "/some/absolute/path" }); 27 | ``` 28 | -------------------------------------------------------------------------------- /docs/git-push-branch-command.md: -------------------------------------------------------------------------------- 1 | # GitPushBranchCommand 2 | 3 | A Git command to push a local branch to a git remote. 4 | 5 | ### Config 6 | 7 | Type: `object literal` 8 | 9 | ###### Optional properties are denoted by * 10 | 11 | ##### remote* 12 | 13 | Type: `string` 14 | Default: `origin` 15 | 16 | ##### branchName 17 | 18 | Type: `string` 19 | 20 | ##### failWhenRemoteBranchExists 21 | 22 | Type: `string` 23 | Default: `true` 24 | 25 | > :information_source:   [ExecCommand](./exec-command.md) options are also applicable. 26 | 27 | ### Example 28 | 29 | ```js 30 | const { Commands } = require("@abstracter/atomic-release"); 31 | 32 | const command = new Commands.GitPushBranchCommand({ 33 | remote: "custom-remote", 34 | branchName: "v123-generated-files", 35 | failWhenRemoteBranchExists: false, 36 | }); 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/git-strategy.md: -------------------------------------------------------------------------------- 1 | # GitStrategy 2 | 3 | An extension of [Strategy](./strategy.md) to be used with Git. 4 | 5 | This strategy enhances the run conditions and runs when 6 | a branch name is defined as a release branch, and, the local & remote hash match. 7 | 8 | ### Config 9 | 10 | Type: `object literal` 11 | 12 | ###### Optional properties are denoted by * 13 | 14 | #### releaseBranchNames* 15 | 16 | Type: `Set` 17 | Default: `new Set([main, beta, alpha])` 18 | 19 | Specifies the branches where the strategy will run. 20 | 21 | #### gitClient* 22 | 23 | Type: [GitClient](./git-client.md) 24 | Default: [GitExecClient](./git-exec-client.md) 25 | 26 | The default client uses the process current working directory and a git remote called `origin` 27 | 28 | [Strategy](./strategy.md) options are also applicable. 29 | 30 | --- 31 | 32 | See [githubNpmPackageStrategy](./github-npm-package-strategy.md) for a reference implementation 33 | -------------------------------------------------------------------------------- /docs/git-switch-branch-command.md: -------------------------------------------------------------------------------- 1 | # GitSwitchCommand 2 | 3 | A Git command that [switches](https://git-scm.com/docs/git-switch) to a desired branch. 4 | 5 | > This command will create a branch in case it does not exist. 6 | 7 | ### Config 8 | 9 | Type: `object literal` 10 | 11 | ###### Optional properties are denoted by * 12 | 13 | ##### branchName 14 | 15 | Type: `string` 16 | 17 | The branch name to switch to. 18 | 19 | > :information_source:   [ExecCommand](./exec-command.md) options are also applicable. 20 | 21 | ### Example 22 | 23 | ```js 24 | const { Commands } = require("@abstracter/atomic-release"); 25 | 26 | const command = new Commands.GitSwitchBranchCommand({ 27 | branchName: "some-branch-name", 28 | }); 29 | ``` 30 | -------------------------------------------------------------------------------- /docs/git-tag-based-release.md: -------------------------------------------------------------------------------- 1 | # gitTagBasedRelease 2 | 3 | An implementation of the SDK [Release](./release.md) interface which uses Git tags and [conventional-changelog packages](https://git.io/JKOLR) 4 | 5 | ### Config 6 | 7 | Type: `object literal` 8 | 9 | ###### Optional properties are denoted by * 10 | 11 | #### logger* 12 | 13 | Type: [Logger](../ports/logger.md) 14 | Default: [processStdoutLogger](../adapters/process-stdout-logger.md) 15 | 16 | #### gitClient* 17 | 18 | Type: [GitClient](./git-client.md) 19 | Default: [GitExecClient](./git-exec-client.md) 20 | 21 | #### filterPreviousVersion* 22 | 23 | Type: `function` 24 | 25 | A function that accepts a single parameter with the following properties: 26 | 27 | ```ts 28 | type FilterPreviousVersionContext = { 29 | version: string; // 1.0.1-beta.0 30 | preReleaseId: string | null; // The pre release id based on the currrent branch. See the 'preReleaseBranches' 31 | versionPreReleaseId: string | null; // The version pre release id: '1.0.1-beta.0' -> 'beta' 32 | } 33 | ``` 34 | 35 | The default function returns `true` when `preReleaseId` strictly match `versionPreReleaseId`. 36 | 37 | #### remote* 38 | 39 | Type: `string` 40 | Default: `origin` 41 | 42 | #### initialVersion* 43 | 44 | Type: `string` 45 | Default: `0.0.0` 46 | 47 | The initial version to use with the first release. (This must be a valid semantic version) 48 | 49 | #### preReleaseBranches* 50 | 51 | Type: `Set` 52 | Default `new Set(["beta", "alpha"])` 53 | 54 | A set of branch names that make up the next version [pre-release identifier](https://git.io/JKkoS). 55 | 56 | #### workingDirectory* 57 | 58 | Type: `string` 59 | Default: `process.cwd()` 60 | 61 | #### conventionalChangelogPreset* 62 | 63 | Type: `object literal` 64 | Default: [conventional-changelog-conventionalcommits](https://git.io/JrnKG) 65 | 66 | A preset exports configuration used by [conventional-changelog-writer](https://git.io/Jrn7b) and by [conventional-commits-parser](https://git.io/vdriu). 67 | Deciding the next version is done by the preset `whatBump` function. 68 | 69 | #### rawConventionalCommits* 70 | 71 | Type: `function` 72 | 73 | A function that accepts a git log range (a string) and returns a promise for array of object literals. Each object literal 74 | has two properties, "hash" which is the commit hash and "raw" which is a string. 75 | 76 | The "raw" value of each element in the array is then mapped to a "conventional commit" by using the conventional-changelog-parser package. 77 | 78 | The function by default is: 79 | 80 | ```js 81 | const rawConventionalCommits = async (range) => { 82 | const commits = await gitClient.commits(range); 83 | 84 | return commits.map((commit) => { 85 | const lines = [ 86 | // subject 87 | `${commit.subject}`, 88 | 89 | // body 90 | `${commit.body}`, 91 | 92 | // extra fields are denoted by hypens and will be available in the parsed object. 93 | "-hash-", 94 | `${commit.hash}`, 95 | 96 | "-gitTags-", 97 | `${commit.tags.join(",")}`, 98 | 99 | "-committerDate-", 100 | `${new Date(commit.committedTimestamp)}`, 101 | ]; 102 | 103 | return { 104 | hash: commit.hash, 105 | raw: lines.join("\n"), 106 | }; 107 | }); 108 | }; 109 | ``` 110 | 111 | #### isReleaseCommit* 112 | 113 | Type: `function` 114 | 115 | A callback that accepts a "conventional commit", and returns a boolean. 116 | When `false` is returned, the commit will **not** be taken into account when computing the next version. 117 | 118 | The default callback is: 119 | 120 | > ⚠️   If you are using a conventional-changelog preset other than "conventional-changelog-conventionalcommits" you need to provide a custom callback. 121 | 122 | ```js 123 | const isReleaseCommit = (commit) => { 124 | const type = commit.type; 125 | 126 | if (!Object.prototype.hasOwnProperty.call(commit, "type")) { 127 | throw new Error("Non supported conventional commit. Provide a custom filter."); 128 | } 129 | 130 | if (typeof type === "string") { 131 | return /feat|fix|perf/.test(type); 132 | } 133 | 134 | return false; 135 | }; 136 | ``` 137 | 138 | #### conventionalChangelogWriterContext* 139 | 140 | Type: `object literal` 141 | 142 | This is used by [conventional-changelog-writer](https://git.io/Jrn7b) when generating changelogs. 143 | 144 | Read about it here: [README.md#context](https://git.io/Jrnys) 145 | 146 | > ⚠️   An error will be thrown if trying to generate a changelog without providing this property. 147 | 148 | > ℹ   Please note that "version" property is automatically added to the object literal. 149 | 150 | ### Example: 151 | 152 | ```js 153 | const { SDK } = require("@abstracter/atomic-release"); 154 | 155 | const release = await SDK.gitTagBasedRelease({ 156 | stableBranchName: "main", 157 | 158 | conventionalChangelogWriterContext: { 159 | host: "https://github.com", 160 | owner: "abstracter-io", 161 | repository: "atomic-release", 162 | repoUrl: "https://github.com/abstracter-io/atomic-release", 163 | }, 164 | }); 165 | 166 | // Print the next version (a string) 167 | release.getNextVersion().then(console.log); 168 | 169 | // Print the next version changelog (a string) 170 | release.getChangelog().then(console.log); 171 | 172 | // Print previous version (a string]) 173 | release.getVersion().then(console.log); 174 | 175 | // Print a set of issues mentiond in the commits to be released 176 | // 177 | // Note: To configure how issues are matched within commits logs 178 | // create a custom conventional-changelog preset using [parser options](https://git.io/JrWp3) 179 | // and make sure to include the preset in semanticRelease options (see the docs for "conventionalChangelogPreset") 180 | release.getMentionedIssues().then(console.log); 181 | 182 | // Print all the previous versions (an array of versions -> ["1.0.1", "0.8.1"], and then their changelogs 183 | release.getPreviousVersion().then((versions) => { 184 | console.log(versions); 185 | 186 | // Print each previous version changelog 187 | for (const version of versions) { 188 | semanticRelease.getChangelogByVersion(version).then((changelog) => { 189 | console.log(version); 190 | console.log(changelog); 191 | }); 192 | } 193 | }); 194 | ``` 195 | -------------------------------------------------------------------------------- /docs/git-tag-command.md: -------------------------------------------------------------------------------- 1 | # GitTagCommand 2 | 3 | A Git command to create a local/remote tag. 4 | 5 | ### Config 6 | 7 | Type: `object literal` 8 | 9 | ###### Optional properties are denoted by * 10 | 11 | ##### name 12 | 13 | Type: `string` 14 | 15 | ##### remote 16 | 17 | Type: `string` 18 | Default: `origin` 19 | 20 | > :information_source:   [ExecCommand](./exec-command.md) options are also applicable. 21 | 22 | ### Example 23 | 24 | ```js 25 | const { Commands } = require("@abstracter/atomic-release"); 26 | 27 | const command = new Commands.GitTagCommand({ 28 | name: "v1.0.0", 29 | remote: "custom-remote", 30 | }); 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/git-trunk-release.md: -------------------------------------------------------------------------------- 1 | ... 2 | -------------------------------------------------------------------------------- /docs/github-create-issue-comments-command.md: -------------------------------------------------------------------------------- 1 | # GithubCreateIssueCommentsCommand 2 | 3 | A github command that comments in github issues. 4 | 5 | ### Config 6 | 7 | Type: `object literal` 8 | 9 | ###### Optional properties are denoted by * 10 | 11 | ##### repo 12 | 13 | Type: `string` 14 | 15 | ##### owner 16 | 17 | Type: `string` 18 | 19 | ##### issueComments 20 | 21 | Type: `array` 22 | 23 | An array of object literals where each object is an issue number and the comment body. 24 | 25 | > :information_source:   [GithubHttpCommand](github-http-command.md) options are also applicable. 26 | 27 | ### Example 28 | 29 | ```js 30 | const { Commands } = require("@abstracter/atomic-release"); 31 | 32 | const command = new Commands.GithubCreateIssueCommentsCommand({ 33 | owner: "abstracter-io", 34 | 35 | repo: "atomic-release", 36 | 37 | issueComments: [ 38 | { issueNumber: 1, commentBody: "Commenting in issue number 1 🥳" }, 39 | { issueNumber: 1, commentBody: "**Another message in issue #1**" }, 40 | { issueNumber: 2, commentBody: "Some other issue" }, 41 | ], 42 | 43 | headers: { 44 | Authorization: "token PERSONAL-ACCESS-TOKEN", 45 | }, 46 | }); 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/github-create-pull-request-command.md: -------------------------------------------------------------------------------- 1 | # GithubCreatePullRequestCommand 2 | 3 | A command that created a pull request using GitHub API. 4 | 5 | ### Config 6 | 7 | Type: `object literal` 8 | 9 | ###### Optional properties are denoted by * 10 | 11 | ##### head 12 | 13 | Type: `string` 14 | 15 | ##### base 16 | 17 | Type: `string` 18 | 19 | ##### repo 20 | 21 | Type: `string` 22 | 23 | ##### owner 24 | 25 | Type: `string` 26 | 27 | ##### title 28 | 29 | Type: `string` 30 | 31 | ##### body* 32 | 33 | Type: `string` 34 | 35 | > :information_source:   [GithubHttpCommand](github-http-command.md) options are also applicable. 36 | 37 | ### Example 38 | 39 | ```js 40 | const { Commands } = require("@abstracter/atomic-release"); 41 | 42 | const command = new Commands.GithubCreatePullRequestCommand({ 43 | head: "v123-generated-files", 44 | base: "main", 45 | repo: "atomic-release", 46 | owner: "abstracter-io", 47 | title: "🤖 Adding v23 generated files", 48 | body: "**Take me to your leader**." 49 | headers: { 50 | Authorization: "token PERSONAL-ACCESS-TOKEN", 51 | }, 52 | }); 53 | ``` 54 | -------------------------------------------------------------------------------- /docs/github-create-release-command.md: -------------------------------------------------------------------------------- 1 | # GithubCreateReleaseCommand 2 | 3 | A command that creates a release using GitHub API. 4 | 5 | ### Config 6 | 7 | Type: `object literal` 8 | 9 | ###### Optional properties are denoted by * 10 | 11 | ##### repo 12 | 13 | Type: `string` 14 | 15 | ##### owner 16 | 17 | Type: `string` 18 | 19 | ##### tagName 20 | 21 | Type: `string` 22 | 23 | ##### isStable* 24 | 25 | Type: `boolean` 26 | Default: `false` 27 | 28 | Use `true` to mark the created release as stable. 29 | 30 | ##### name 31 | 32 | Type: `string` 33 | 34 | ##### body* 35 | 36 | Type: `string` 37 | 38 | ##### assets* 39 | 40 | Type: `array` 41 | 42 | An array of object literals where each object is an asset to upload to the release. 43 | 44 | > :information_source:   [GithubHttpCommand](github-http-command.md) options are also applicable. 45 | 46 | ### Example 47 | 48 | ```js 49 | const { Commands } = require("@abstracter/atomic-release"); 50 | 51 | const command = new Commands.GithubCreateReleaseCommand({ 52 | owner: "abstracter-io", 53 | repo: "atomic-release", 54 | tagName: "v2.0.1", 55 | isStable: true, 56 | name: "RELEASE v2.0.1", 57 | body: "***This is SPARTA***", 58 | headers: { 59 | Authorization: "token [PERSONAL ACCESS TOKEN]", 60 | }, 61 | assets: [ 62 | { absoluteFilePath: "/home/rick/dev/package.json" }, 63 | { absoluteFilePath: "/home/rick/dev/build.json", label: "..." }, 64 | { absoluteFilePath: "/home/rick/dev/package.json", name: "custom name" }, 65 | ], 66 | }); 67 | ``` 68 | -------------------------------------------------------------------------------- /docs/github-http-command.md: -------------------------------------------------------------------------------- 1 | # GithubHttpCommand 2 | 3 | An abstract class with a method to expand GitHub URLs and perform HTTP requests using the fetch API. 4 | 5 | ### Config 6 | 7 | This class accepts the same options as [HttpCommand](http-command.md) 8 | 9 | ### Example 10 | 11 | ```js 12 | const { Commands } = require("@abstracter/atomic-release"); 13 | 14 | class ExampleGithubHttpCommand extends Commands.GithubHttpCommand { 15 | async do() { 16 | const url = this.expendURL("https://api.github.com/{owner}/{repo}", { 17 | owner: "abstracter-io", 18 | repo: "atomic-release", 19 | }); 20 | 21 | await this.fetch(url, { method: "HEAD" }); 22 | } 23 | } 24 | ``` 25 | -------------------------------------------------------------------------------- /docs/github-npm-package-strategy.md: -------------------------------------------------------------------------------- 1 | # githubNpmPackageStrategy 2 | 3 | A [Strategy](./strategy.md) to publish NPM packages source controlled in GitHub. 4 | 5 | This strategy will: 6 | 7 | > Some of the below commands will not be used when config `syncRemote` is `false` 8 | 9 | * Generate a changelog and prepend it to the changelog file using [FileWriterCommand](./file-writer-command.md) 10 | * Create a GitHub release using [GithubCreateReleaseCommand](./github-create-release-command.md) 11 | * Comment on GitHub issues mentioned in the release commits using [GithubCreateIssueCommentsCommand](./github-create-issue-comments-command.md) 12 | * Bump the package.json version property to the next version using [NpmBumpPackageVersionCommand](./npm-bump-package-version-command.md) 13 | * Create a Git tag named after the next version using [GitTagCommand](./git-tag-command.md) 14 | * Commit the generated changelog & changed package.json using [GitCommitCommand](./git-commit-command.md) 15 | * Push the commit using [GitPushBranchCommand](./git-push-branch-command.md) 16 | * Publish the package to the npm registry using [NpmPublishPackageCommand](./npm-publish-package-command.md) 17 | 18 | ![demo](./github-npm-strategy-fail-demo.gif) 19 | 20 | ### Config 21 | 22 | Type: `object literal` 23 | 24 | ###### Optional properties are denoted by * 25 | 26 | #### syncRemote* 27 | Type: `boolean` 28 | Default: `false` 29 | 30 | This flag controls whether to commit and push the package.json 31 | and, in case `maintainChangelog` is true, the changelog file as well. 32 | 33 | Enabling this when the remote branch is protected will require a bypass. 34 | See [discussions/25305](https://github.com/orgs/community/discussions/25305) 35 | 36 | #### stableBranchName* 37 | Type: `string` 38 | Default: `main` 39 | 40 | When the branch name is equal to configured value the published npm dist tag is latest. 41 | Additionally. this determines the GitHub `prerelease` flag. See ["Create a release"](https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#create-a-release) 42 | 43 | #### logger* 44 | 45 | Type: [Logger](./logger.md) 46 | Default: [processStdoutLogger](./process-stdout-logger.md) 47 | 48 | #### release* 49 | 50 | Type: [Release](./release.md) 51 | Default: [git-tag-based-release](./git-tag-based-release.md) 52 | 53 | #### gitRemote* 54 | 55 | Type: `string` 56 | Default: `origin` 57 | 58 | #### maintainChangelog* 59 | Type: `boolean` 60 | Default: `false` 61 | 62 | #### gitActor* 63 | 64 | Type: `string` 65 | Default: `process.env.RELEASE_ACTOR` 66 | 67 | A shorthand to perform Git commits using a specific author & committer email and name. 68 | 69 | Example: 70 | 71 | ```js 72 | /* 73 | * The value must be in author format: "NAME " 74 | */ 75 | { gitActor: "bot " } 76 | 77 | // It is also possible to explicitly use the equivalent environment variables: 78 | // 79 | // GIT_COMMITTER_NAME: bot 80 | // GIT_COMMITTER_EMAIL: bot@email.com 81 | // GIT_AUTHOR_NAME: bot 82 | // GIT_AUTHOR_EMAIL: bot@email.com 83 | ``` 84 | 85 | #### workingDirectory* 86 | 87 | Type: `string` 88 | Default: `process.cwd()` 89 | 90 | #### changelogFilePath* 91 | 92 | Type: `string` 93 | Default: `${workingDirectory}/CHANGELOG.md` 94 | 95 | #### githubPersonalAccessToken* 96 | 97 | Type: `string` 98 | Default: `process.env.GITHUB_PAT_TOKEN` 99 | 100 | The token to use when interacting with GitHub REST API 101 | 102 | [GitStrategy](git-strategy.md) options are also applicable. 103 | 104 | ### Example 105 | 106 | ```js 107 | const { SDK } = require("@abstracter/atomic-release"); 108 | 109 | SDK.githubNpmPackageStrategy().then(strategy => strategy.run()); 110 | ``` 111 | 112 | 113 | -------------------------------------------------------------------------------- /docs/github-npm-strategy-fail-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/abstracter-io/atomic-release/a19e1b2731e52e9791e748d31eb0142cf6a90ce6/docs/github-npm-strategy-fail-demo.gif -------------------------------------------------------------------------------- /docs/http-command.md: -------------------------------------------------------------------------------- 1 | # HttpCommand 2 | 3 | An abstract class with a method to perform http requests with pre-defined headers using [fetchDefaults](https://github.com/moll/js-fetch-defaults). 4 | 5 | ### Config 6 | 7 | Type: `object literal` 8 | 9 | ###### Optional properties are denoted by * 10 | 11 | ##### headers* 12 | 13 | Type: `object literal` 14 | 15 | ##### fetch? 16 | 17 | Type: `function` 18 | Default: `node built-in fetch` 19 | 20 | ### Example 21 | 22 | ```js 23 | const { Commands } = require("@abstracter/atomic-release"); 24 | 25 | class ExampleHttpCommand extends Commands.HttpCommand { 26 | async do() { 27 | await this.fetch("https://api.github.com", { 28 | method: "HEAD", 29 | }); 30 | } 31 | } 32 | 33 | const command = new ExampleHttpCommand({ 34 | headers: { 35 | 'X-Custom-Header': "This header will be part of the request headers", 36 | }, 37 | }); 38 | ``` 39 | -------------------------------------------------------------------------------- /docs/logger.md: -------------------------------------------------------------------------------- 1 | # Logger 2 | 3 | An interface describing the logger API used by the SDK. 4 | 5 | ### Creating a Custom Logger 6 | 7 | Here is an example of creating a logger adhering to the logger interface using the global console object. 8 | 9 | ```js 10 | const consoleLogger = (logLevel) => { 11 | return { 12 | /** 13 | * error - a string or an instance of an error 14 | */ 15 | error(error) { 16 | if (logLevel >= 0) { 17 | console.error(error); 18 | } 19 | }, 20 | 21 | warn(message) { 22 | if (logLevel >= 1) { 23 | console.warn(message); 24 | } 25 | }, 26 | 27 | info(message) { 28 | if (logLevel >= 2) { 29 | console.info(message); 30 | } 31 | }, 32 | 33 | debug(message) { 34 | if (logLevel >= 3) { 35 | console.debug(message); 36 | } 37 | }, 38 | }; 39 | }; 40 | 41 | // A silent logger 42 | // const logger = consoleLogger(-1); 43 | 44 | // errors only 45 | // const logger = consoleLogger(0); 46 | 47 | // errors and warnings 48 | // const logger = consoleLogger(1); 49 | 50 | // errors, warnings, and info 51 | // const logger = consoleLogger(2); 52 | 53 | // errors, warnings, info, and debug 54 | // const logger = consoleLogger(3); 55 | 56 | logger.error(new Error("Oh snap")); 57 | 58 | logger.error("Oh snap"); 59 | 60 | logger.warn("this is a warning"); 61 | 62 | logger.info("this is informational"); 63 | 64 | logger.debug("this helps troubleshooting"); 65 | ``` 66 | 67 | See [processStdoutLogger](./process-stdout-logger.md) for a reference implementation 68 | -------------------------------------------------------------------------------- /docs/npm-bump-package-version-command.md: -------------------------------------------------------------------------------- 1 | # NpmBumpPackageVersionCommand 2 | 3 | A command that updates the version property in a `package.json` file. 4 | 5 | ### Config 6 | 7 | Type: `object literal` 8 | 9 | ###### Optional properties are denoted by * 10 | 11 | ##### version 12 | 13 | Type: `string` 14 | 15 | ##### preReleaseId 16 | 17 | Type: `string` 18 | 19 | > :information_source:   [ExecCommand](./exec-command.md) options are also applicable. 20 | 21 | ### Example 22 | 23 | ```js 24 | const { Commands } = require("@abstracter/atomic-release"); 25 | 26 | const command = new Commands.NpmBumpPackageVersionCommand({ 27 | version: "1.0.0", 28 | preReleaseId: "beta", 29 | workingDirectory: "/absolute/path", <-- package.json should be inside 30 | }); 31 | ``` 32 | -------------------------------------------------------------------------------- /docs/npm-command.md: -------------------------------------------------------------------------------- 1 | # NpmCommand 2 | 3 | An abstract class with a method to read a package.json. 4 | 5 | ### Config 6 | 7 | This class accepts the same options as [ExecCommand](./exec-command.md) 8 | 9 | ### Example 10 | 11 | ```js 12 | const { Commands } = require("@abstracter/atomic-release"); 13 | 14 | class ExampleNpmCommand extends Commands.NpmCommand { 15 | async do() { 16 | console.log(await this.getPackageJson()); 17 | } 18 | 19 | async undo() { 20 | // ... 21 | } 22 | } 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/npm-publish-package-command.md: -------------------------------------------------------------------------------- 1 | # NpmPublishPackageCommand 2 | 3 | A command that updates the version property in a `package.json` file. 4 | 5 | **NOTE**: THIS COMMAND SHOULD BE THE LAST COMMAND THAT EXECUTES DURING A RELEASE. 6 | 7 | ``` 8 | The commonly used registry (https://registry.npmjs.org) does 9 | allow removing a published package but does not allow publishing 10 | the same version even though it was unpublished... :facepalm: 11 | 12 | Assuming the registry in use does not impose 13 | such a limit, there is an option called "undoPublish" that will 14 | control whether this command undo should "unpublish" 15 | ``` 16 | 17 | ### Config 18 | 19 | Type: `object literal` 20 | 21 | ###### Optional properties are denoted by * 22 | 23 | ##### tag* 24 | 25 | Type: `string` 26 | Default: `latest` 27 | 28 | The npm dist-tag to publish to. 29 | 30 | ##### registry* 31 | 32 | Type: `string` 33 | 34 | This is optionl since NPM CLI fallbacks to using the publish config section in the package.json. 35 | 36 | ##### undoPublish 37 | 38 | Type: `boolean` 39 | Default: false 40 | 41 | Use `true` only when the registry allows publishing the same version again. 42 | 43 | > :information_source:   [ExecCommand](./exec-command.md) options are also applicable. 44 | 45 | ### Example 46 | 47 | ```js 48 | const { Commands } = require("@abstracter/atomic-release"); 49 | 50 | const command = new Commands.NpmPublishPackageCommand({ 51 | tag: "beta", // i.e. npm install @beta 52 | registry: "https://npm.evil-corp.com", 53 | undoPublish: false, 54 | workingDirectory: "/absolute/path", // package.json should be inside 55 | }); 56 | ``` 57 | -------------------------------------------------------------------------------- /docs/process-stdout-logger.md: -------------------------------------------------------------------------------- 1 | # processStdoutLogger 2 | 3 | An implementation of the SDK [Logger](./logger.md) interface using `process.stdout`. 4 | 5 | ### Config 6 | 7 | ###### Optional properties are denoted by * 8 | 9 | Type: `object literal` 10 | 11 | ##### name 12 | 13 | Type: `string` 14 | 15 | ##### logLevel* 16 | 17 | Type: `string` 18 | Default: `INFO` 19 | 20 | The log level value may be one of the following: "ERROR", "WARN", "INFO" or "DEBUG" 21 | 22 | > NOTE: The log level may also be configured using 'ATOMIC_RELEASE_LOG_LEVEL' environment variable. 23 | 24 | ### Example 25 | 26 | ```js 27 | const { SDK } = require("@abstracter/atomic-release"); 28 | 29 | const logger = SDK.processStdoutLogger({ name: "ExamplaryLogger", logLevel: "DEBUG" }); 30 | 31 | logger.debug("This message is printed because the log level is set to 'DEBUG'"); 32 | ``` 33 | -------------------------------------------------------------------------------- /docs/release.md: -------------------------------------------------------------------------------- 1 | # Release 2 | 3 | An interface describing the API needed by the SDK when perform a release. 4 | 5 | ```ts 6 | interface Release { 7 | /** 8 | * @returns A promise for a changelog of the commits included in the release 9 | */ 10 | getChangelog(): Promise; 11 | 12 | /** 13 | * @returns A promise for an array of all the previous released versions 14 | */ 15 | getVersions(): Promise; 16 | 17 | /** 18 | * @returns A promise for the next version 19 | */ 20 | getNextVersion(): Promise; 21 | 22 | /** 23 | * @returns A promise for the previous released version 24 | */ 25 | getPreviousVersion(): Promise; 26 | 27 | /** 28 | * @returns A promise for the issues mentiond in the commits included in the release 29 | */ 30 | getMentionedIssues(): Promise>; 31 | 32 | /** 33 | * @param version - A previously released version 34 | * 35 | * @returns A promise for a changelog of a previously released version 36 | */ 37 | getChangelogByVersion(version: string): Promise; 38 | } 39 | ``` 40 | 41 | See [gitTagBasedRelease](./git-tag-based-release.md) for a reference implementation 42 | -------------------------------------------------------------------------------- /docs/strategy.md: -------------------------------------------------------------------------------- 1 | # Strategy 2 | 3 | An abstract class that covers the condition in which a strategy runs 4 | and the execution of the commands `do`, `undo` & `cleanup` methods. 5 | 6 | This strategy runs only when the next & previous version differ. 7 | 8 | ### Config 9 | 10 | ###### Optional properties are denoted by * 11 | 12 | ##### logger 13 | 14 | Type: [Logger](./logger.md) 15 | 16 | ##### release 17 | 18 | Type: [Release](./release.md) 19 | 20 | --- 21 | 22 | See [github-npm-package-strategy](./github-npm-package-strategy.md) for a reference 23 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { typescript } from '@abstracter/eslint-config'; 2 | 3 | export default typescript; 4 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | import micromatch from 'micromatch'; 2 | 3 | // https://github.com/okonet/lint-staged 4 | export default (allStagedFiles) => { 5 | const commands = []; 6 | const lintables = micromatch(allStagedFiles, ['**/*.js', '**/*.ts'], {}); 7 | const testables = micromatch(allStagedFiles, ['src/**', 'tests/**'], {}); 8 | 9 | if (lintables.length) { 10 | commands.push(`npm run lint:fix -- ${lintables.join(' ')}`); 11 | } 12 | 13 | if (testables.length) { 14 | commands.push('npm test'); 15 | } 16 | 17 | return commands; 18 | }; 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@abstracter/atomic-release", 3 | "version": "0.0.0", 4 | "description": "Automated atomic release using the command pattern.", 5 | "private": false, 6 | "type": "module", 7 | "types": "./build/index.d.ts", 8 | "exports": { 9 | "types": "./build/index.d.ts", 10 | "default": "./build/index.js" 11 | }, 12 | "files": [ 13 | "build/**", 14 | "CHANGELOG.md", 15 | "README.md" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/abstracter-io/atomic-release.git" 20 | }, 21 | "scripts": { 22 | "outdated": "npx npm-check -u", 23 | "lint": "eslint --format pretty", 24 | "lint:fix": "npm run lint -- --fix .", 25 | "prepare": "husky", 26 | "build": "tsc --pretty", 27 | "postbuild": "copyfiles -u 1 src/**/*.d.ts build", 28 | "test": "vitest run", 29 | "release": "node scripts/release.js" 30 | }, 31 | "publishConfig": { 32 | "access": "public", 33 | "registry": "https://registry.npmjs.org" 34 | }, 35 | "engines": { 36 | "node": ">=18.16.0" 37 | }, 38 | "keywords": [ 39 | "release", 40 | "changelog", 41 | "atomic release", 42 | "command pattern", 43 | "semantic version", 44 | "version management", 45 | "conventional version", 46 | "conventional changelog" 47 | ], 48 | "license": "MIT", 49 | "devDependencies": { 50 | "@abstracter/atomic-release": "^2.2.0", 51 | "@abstracter/eslint-config": "^2.0.0", 52 | "@commitlint/cli": "^19.4.0", 53 | "@commitlint/config-conventional": "^19.2.2", 54 | "@commitlint/cz-commitlint": "^19.4.0", 55 | "@vitest/coverage-v8": "^2.0.5", 56 | "commitizen": "^4.3.0", 57 | "copyfiles": "^2.4.1", 58 | "cz-conventional-changelog": "^3.3.0", 59 | "eslint": "^9.9.0", 60 | "eslint-formatter-pretty": "^6.0.1", 61 | "git-cz": "^4.9.0", 62 | "husky": "^9.1.4", 63 | "lint-staged": "^15.2.9", 64 | "micromatch": "^4.0.7", 65 | "npm-check": "^6.0.1", 66 | "typescript": "^5.5.4", 67 | "vitest": "^2.0.5" 68 | }, 69 | "dependencies": { 70 | "await-to-js": "^3.0.0", 71 | "colors": "^1.4.0", 72 | "conventional-changelog-conventionalcommits": "^8.0.0", 73 | "conventional-changelog-preset-loader": "^5.0.0", 74 | "conventional-changelog-writer": "^8.0.0", 75 | "conventional-commits-parser": "^6.0.0", 76 | "fetch-defaults": "^1.0.0", 77 | "mime": "^4.0.4", 78 | "parse-author": "^2.0.0", 79 | "parse-github-url": "^1.0.3", 80 | "pretty-ms": "^9.1.0", 81 | "radash": "^12.1.0", 82 | "read-package-up": "^11.0.0", 83 | "semver": "^7.6.3", 84 | "uri-templates": "^0.2.0" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /scripts/release.js: -------------------------------------------------------------------------------- 1 | import { SDK } from '@abstracter/atomic-release'; 2 | 3 | const strategy = await SDK.githubNpmPackageStrategy(); 4 | 5 | await strategy.run(); 6 | -------------------------------------------------------------------------------- /src/commands/exec-command.ts: -------------------------------------------------------------------------------- 1 | import * as process from 'node:process'; 2 | 3 | import { exec } from '../utils/exec.js'; 4 | import { Command, CommandConfig } from '../sdk/command.js'; 5 | 6 | import type * as Exec from '../utils/exec.js'; 7 | 8 | type ExecCommandConfig = CommandConfig & { 9 | // when true stderr / stdout are logged 10 | logStd?: boolean; 11 | 12 | // -> /home/rick.sanchez/my-awesome-node-project 13 | workingDirectory: string; 14 | }; 15 | 16 | abstract class ExecCommand extends Command { 17 | private logStd(stdout: string, stderr: string) { 18 | const log = this.config.logStd || process.env.EXEC_COMMAND_LOG_STD?.split(',').includes(this.getName()); 19 | 20 | if (log) { 21 | if (stdout.length) { 22 | this.logger.info(stdout); 23 | } 24 | 25 | if (stderr.length) { 26 | this.logger.error(stderr); 27 | } 28 | } 29 | } 30 | 31 | protected createChildProcess = exec; 32 | 33 | protected async exec(command: Exec.ExecCommand, options?: Exec.ExecOptions): Promise { 34 | const result = await this.createChildProcess(command, { 35 | cwd: this.config.workingDirectory, 36 | ...options, 37 | }); 38 | 39 | this.logStd(result.stdout.trimEnd(), result.stderr.trimEnd()); 40 | 41 | return result; 42 | } 43 | } 44 | 45 | export { ExecCommand, ExecCommandConfig }; 46 | -------------------------------------------------------------------------------- /src/commands/file-writer-command.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { to } from 'await-to-js'; 3 | 4 | import { Command, CommandConfig } from '../sdk/command.js'; 5 | 6 | type FileWriterCommandConfig = CommandConfig & { 7 | content: string; 8 | create?: boolean; 9 | absoluteFilePath: string; 10 | mode?: 'append' | 'prepend' | 'replace'; 11 | }; 12 | 13 | /** 14 | @example Creating a file 15 | 16 | const command = new FileWriterCommand({ 17 | create: true, 18 | content: "This is SPARTA", 19 | absoluteFilePath: "/home/dev/project/file.txt", 20 | }); 21 | 22 |
23 | 24 | @example Modifying a file 25 | 26 | const command = new FileWriterCommand({ 27 | content: "42", 28 | mode: "append | prepend | replace", <-- Choose one ("append" is the default) 29 | absoluteFilePath: "/home/dev/project/existing.txt", 30 | }); 31 | */ 32 | class FileWriterCommand extends Command { 33 | private fileWasCreated: boolean; 34 | private originalFileContent: null | string = null; 35 | 36 | protected async readTextFile(): Promise { 37 | const absoluteFilePath = this.config.absoluteFilePath; 38 | 39 | this.logger.debug(`Reading file ${absoluteFilePath}`); 40 | 41 | return fs.promises.readFile(absoluteFilePath, { 42 | encoding: 'utf-8', 43 | flag: 'r', 44 | }); 45 | } 46 | 47 | protected async filePathExists(): Promise { 48 | const [error] = await to(fs.promises.access(this.config.absoluteFilePath, fs.constants.F_OK)); 49 | 50 | return error === null; 51 | } 52 | 53 | protected async writeFileContent(content: string): Promise { 54 | const { absoluteFilePath } = this.config; 55 | 56 | await fs.promises.writeFile(absoluteFilePath, content); 57 | } 58 | 59 | public async do(): Promise { 60 | const fileExists = await this.filePathExists(); 61 | const absoluteFilePath = this.config.absoluteFilePath; 62 | 63 | if (fileExists) { 64 | const mode = this.config.mode; 65 | 66 | this.originalFileContent = await this.readTextFile(); 67 | 68 | if (mode === 'replace') { 69 | await this.writeFileContent(`${this.config.content}`); 70 | 71 | this.logger.info(`Replaced ${absoluteFilePath} content`); 72 | } 73 | // 74 | else if (mode === 'prepend') { 75 | await this.writeFileContent(`${this.config.content}${this.originalFileContent}`); 76 | 77 | this.logger.info(`Prepended content to file ${absoluteFilePath}`); 78 | } 79 | // 80 | else if (!mode || mode === 'append') { 81 | await this.writeFileContent(`${this.originalFileContent}${this.config.content}`); 82 | 83 | this.logger.info(`Appended content to file ${absoluteFilePath}`); 84 | } 85 | // 86 | else { 87 | throw new Error(`Unknown mode '${mode}'`); 88 | } 89 | } 90 | // 91 | else if (this.config.create) { 92 | await this.writeFileContent(this.config.content); 93 | 94 | this.logger.info(`Created file ${absoluteFilePath}`); 95 | 96 | this.fileWasCreated = true; 97 | } 98 | } 99 | 100 | public async undo(): Promise { 101 | if (this.fileWasCreated) { 102 | const absoluteFilePath = this.config.absoluteFilePath; 103 | 104 | await fs.promises.unlink(absoluteFilePath); 105 | 106 | this.logger.info(`Deleted file ${absoluteFilePath}`); 107 | } 108 | // 109 | else if (this.originalFileContent) { 110 | await this.writeFileContent(this.originalFileContent); 111 | 112 | this.logger.info(`Reverted file ${this.config.absoluteFilePath}`); 113 | } 114 | } 115 | } 116 | 117 | export { FileWriterCommand, FileWriterCommandConfig }; 118 | -------------------------------------------------------------------------------- /src/commands/git/git-commit-command.ts: -------------------------------------------------------------------------------- 1 | import parseAuthor from 'parse-author'; 2 | import { ExecCommand, ExecCommandConfig } from '../exec-command.js'; 3 | 4 | type GitCommitCommandConfig = ExecCommandConfig & { 5 | actor?: string; 6 | 7 | commitMessage: string; 8 | 9 | filePaths: Set; 10 | }; 11 | 12 | /** 13 | @example 14 | 15 | const command = new GitCommitCommand({ 16 | actor: "bot " 17 | commitMessage: "ci: adding files generated during CI/CD", 18 | workingDirectory: "/home/rick.sanchez/my-awesome-node-project", 19 | filePaths: new Set(["path/relative/to/working/directory/file.txt"]), 20 | }); 21 | */ 22 | class GitCommitCommand extends ExecCommand { 23 | private commited: boolean; 24 | private filesStaged: boolean; 25 | 26 | private async stageFiles(): Promise { 27 | const { childProcess, stderr, stdout } = await this.exec(`git add ${Array.from(this.config.filePaths).join(' ')}`); 28 | 29 | if (childProcess.exitCode !== 0) { 30 | throw new Error(`Staging failed. exit code is ${childProcess.exitCode} ${stderr} ${stdout}`); 31 | } 32 | 33 | this.filesStaged = true; 34 | } 35 | 36 | private async commit(): Promise { 37 | const commitEnvVars = { 38 | ...process.env, 39 | }; 40 | 41 | if (this.config.actor) { 42 | const { name, email } = parseAuthor(this.config.actor); 43 | 44 | if (!name || !email) { 45 | throw new Error('actor must follow "name " format'); 46 | } 47 | 48 | Object.assign(commitEnvVars, { 49 | GIT_COMMITTER_NAME: name, 50 | GIT_COMMITTER_EMAIL: email, 51 | GIT_AUTHOR_NAME: name, 52 | GIT_AUTHOR_EMAIL: email, 53 | }); 54 | } 55 | 56 | const result = await this.exec({ command: 'git', args: ['commit', '-m', this.config.commitMessage] }, { 57 | env: commitEnvVars, 58 | }); 59 | 60 | if (result.childProcess.exitCode !== 0) { 61 | throw new Error(`Commit failed. exit code is ${result.childProcess.exitCode}`); 62 | } 63 | 64 | this.config.filePaths.forEach((filePath) => { 65 | this.logger.info(`Committed file ${filePath}`); 66 | }); 67 | 68 | this.commited = true; 69 | } 70 | 71 | public async undo(): Promise { 72 | if (this.filesStaged) { 73 | await this.exec(`git restore --staged ${Array.from(this.config.filePaths).join(' ')}`); 74 | } 75 | 76 | if (this.commited) { 77 | await this.exec('git reset HEAD~'); 78 | } 79 | } 80 | 81 | public async do(): Promise { 82 | await this.stageFiles(); 83 | 84 | await this.commit(); 85 | } 86 | } 87 | 88 | export { GitCommitCommand, GitCommitCommandConfig }; 89 | -------------------------------------------------------------------------------- /src/commands/git/git-push-branch-command.ts: -------------------------------------------------------------------------------- 1 | import { ExecCommand, ExecCommandConfig } from '../exec-command.js'; 2 | 3 | type GitPushBranchCommandConfig = ExecCommandConfig & { 4 | remote?: string; 5 | 6 | branchName: string; 7 | 8 | failWhenRemoteBranchExists?: boolean; 9 | }; 10 | 11 | /** 12 | @example 13 | const command = GitPushCommand({ 14 | remote: "custom-remote", <-- "origin" by default 15 | branchName: "v123-generated-files", 16 | failWhenRemoteBranchExists: false, <-- true by default 17 | }); 18 | */ 19 | class GitPushBranchCommand extends ExecCommand { 20 | private readonly remote: string; 21 | 22 | private pushed: boolean; 23 | 24 | public constructor(config: GitPushBranchCommandConfig) { 25 | super(config); 26 | 27 | this.remote = config.remote ?? 'origin'; 28 | } 29 | 30 | private async push(): Promise { 31 | await this.exec(`git push --set-upstream ${this.remote} ${this.config.branchName}`); 32 | } 33 | 34 | private async remoteBranchExists(): Promise { 35 | const { stdout } = await this.exec(`git ls-remote ${this.remote} ${this.config.branchName}`); 36 | 37 | return stdout.length > 0; 38 | } 39 | 40 | public async undo(): Promise { 41 | if (this.pushed) { 42 | this.logger.warn(`Cannot un-push remote branch '${this.config.branchName}'`); 43 | } 44 | } 45 | 46 | public async do(): Promise { 47 | const remoteBranchExists = await this.remoteBranchExists(); 48 | 49 | if (remoteBranchExists && (this.config.failWhenRemoteBranchExists ?? true)) { 50 | throw new Error(`Remote '${this.remote}' already has a branch named '${this.config.branchName}'`); 51 | } 52 | // 53 | else { 54 | await this.push(); 55 | 56 | this.pushed = true; 57 | 58 | this.logger.info(`Pushed branch '${this.config.branchName}'`); 59 | } 60 | } 61 | } 62 | 63 | export { GitPushBranchCommand, GitPushBranchCommandConfig }; 64 | -------------------------------------------------------------------------------- /src/commands/git/git-switch-branch-command.ts: -------------------------------------------------------------------------------- 1 | import { ExecCommand, ExecCommandConfig } from '../exec-command.js'; 2 | 3 | type GitSwitchCommandOptions = ExecCommandConfig & { 4 | branchName: string; 5 | }; 6 | 7 | /** 8 | @example 9 | 10 | const command = new GitSwitchBranchCommand({ 11 | branchName: "v1.2.3-generated-files"; 12 | }); 13 | */ 14 | class GitSwitchBranchCommand extends ExecCommand { 15 | private initialBranchName: string; 16 | private createdBranch: boolean; 17 | 18 | private async branchName() { 19 | const result = await this.exec('git rev-parse --abbrev-ref HEAD'); 20 | 21 | return result.stdout.trim(); 22 | } 23 | 24 | private async branchExists(branchName: string) { 25 | const subprocess = this.exec(`git rev-parse --verify refs/heads/${branchName}`); 26 | 27 | return subprocess 28 | .then(() => { 29 | return true; 30 | }) 31 | .catch(() => { 32 | return false; 33 | }); 34 | } 35 | 36 | private async switch(branchName: string, create: boolean): Promise { 37 | await this.exec(`git switch ${create ? '-c ' : ''}${branchName}`); 38 | 39 | this.logger.info(`Switched to branch '${branchName}'`); 40 | } 41 | 42 | public async undo(): Promise { 43 | if (this.initialBranchName) { 44 | await this.switch(this.initialBranchName, false); 45 | } 46 | 47 | if (this.createdBranch) { 48 | await this.exec(`git branch -D ${this.config.branchName}`); 49 | 50 | this.logger.info(`Deleted branch '${this.config.branchName}'`); 51 | } 52 | } 53 | 54 | public async do(): Promise { 55 | const branchName = this.config.branchName; 56 | const currentBranch = await this.branchName(); 57 | 58 | if (!branchName) { 59 | throw new Error('Missing branch name'); 60 | } 61 | // 62 | else if (branchName !== currentBranch) { 63 | const branchExists = await this.branchExists(branchName); 64 | 65 | await this.switch(branchName, !branchExists); 66 | 67 | if (!branchExists) { 68 | this.createdBranch = true; 69 | } 70 | 71 | this.initialBranchName = currentBranch; 72 | } 73 | } 74 | } 75 | 76 | export { GitSwitchBranchCommand, GitSwitchCommandOptions }; 77 | -------------------------------------------------------------------------------- /src/commands/git/git-tag-command.ts: -------------------------------------------------------------------------------- 1 | import { to } from 'await-to-js'; 2 | 3 | import { ExecCommand, ExecCommandConfig } from '../exec-command.js'; 4 | 5 | type GitTagCommandConfig = ExecCommandConfig & { 6 | name: string; 7 | remote?: string; 8 | }; 9 | 10 | /** 11 | @example 12 | 13 | const command = new GitTagCommand({ 14 | name: "v2", 15 | silent: true, <-- hides execution stderr/stdout 16 | workingDirectory: "/home/rick/dev/project", 17 | remote: "custom-remote", <-- "origin" by default 18 | }); 19 | */ 20 | class GitTagCommand extends ExecCommand { 21 | private readonly remote: string; 22 | private readonly tagRef: string; 23 | 24 | private localTagCreated = false; 25 | private remoteTagCreated = false; 26 | 27 | public constructor(config: GitTagCommandConfig) { 28 | super(config); 29 | 30 | this.remote = config.remote ?? 'origin'; 31 | this.tagRef = `refs/tags/${config.name}`; 32 | } 33 | 34 | private async deleteLocalTag(): Promise { 35 | if (this.localTagCreated) { 36 | const [error] = await to(this.exec(`git tag --delete ${this.config.name}`)); 37 | 38 | if (error) { 39 | this.logger.error(new Error(`Failed to delete local tag '${this.config.name}'`)); 40 | 41 | this.logger.error(error); 42 | } 43 | else { 44 | this.logger.info(`Deleted local tag '${this.config.name}'`); 45 | } 46 | } 47 | } 48 | 49 | private async deleteRemoteTag(): Promise { 50 | if (this.remoteTagCreated) { 51 | const [error] = await to(this.exec(`git push ${this.remote} --delete ${this.tagRef}`)); 52 | 53 | if (error) { 54 | this.logger.error(new Error(`Failed to delete remote tag '${this.config.name}'`)); 55 | 56 | this.logger.error(error); 57 | } 58 | else { 59 | this.logger.info(`Deleted remote tag '${this.config.name}'`); 60 | } 61 | } 62 | } 63 | 64 | private async localTagExists(): Promise { 65 | const { stdout } = await this.exec(`git tag --list ${this.config.name}`); 66 | 67 | return stdout.length > 0; 68 | } 69 | 70 | private async createLocalTag(): Promise { 71 | const localTagExists = await this.localTagExists(); 72 | 73 | if (!localTagExists) { 74 | const { childProcess } = await this.exec(`git tag ${this.config.name}`); 75 | 76 | if (childProcess.exitCode == 0) { 77 | this.localTagCreated = true; 78 | 79 | this.logger.info(`Created a local tag '${this.config.name}'`); 80 | 81 | return; 82 | } 83 | 84 | throw new Error(`Creating local tag failed. Exit code is: ${childProcess.exitCode}`); 85 | } 86 | 87 | throw new Error(`A local tag named '${this.config.name}' already exists`); 88 | } 89 | 90 | private async remoteTagExists(): Promise { 91 | const { stdout } = await this.exec(`git ls-remote ${this.remote} ${this.tagRef}`); 92 | 93 | return stdout.length > 0; 94 | } 95 | 96 | private async createRemoteTag(): Promise { 97 | const remoteTagExists = await this.remoteTagExists(); 98 | 99 | if (!remoteTagExists) { 100 | const { childProcess } = await this.exec(`git push ${this.remote} ${this.tagRef}`); 101 | 102 | if (childProcess.exitCode === 0) { 103 | this.remoteTagCreated = true; 104 | 105 | this.logger.info(`Pushed tag '${this.config.name}' to remote '${this.remote}'`); 106 | 107 | return; 108 | } 109 | 110 | throw new Error(`Failed to create remote tag. Exit code is: ${childProcess.exitCode}`); 111 | } 112 | 113 | throw new Error(`A tag named '${this.config.name}' already exists in remote '${this.remote}'`); 114 | } 115 | 116 | public async undo(): Promise { 117 | await this.deleteLocalTag(); 118 | 119 | await this.deleteRemoteTag(); 120 | } 121 | 122 | public async do(): Promise { 123 | await this.createLocalTag(); 124 | 125 | await this.createRemoteTag(); 126 | } 127 | } 128 | 129 | export { GitTagCommand, GitTagCommandConfig }; 130 | -------------------------------------------------------------------------------- /src/commands/github/github-create-issue-comments-command.ts: -------------------------------------------------------------------------------- 1 | import { GithubHttpCommand, GithubHttpCommandConfig } from './github-http-command.js'; 2 | 3 | type CommentRestResource = Record; 4 | 5 | type IssueComment = { 6 | commentBody: string; 7 | issueNumber: number; 8 | }; 9 | 10 | type GithubCreateIssueCommentsCommandConfig = GithubHttpCommandConfig & { 11 | repoName: string; 12 | repoOwner: string; 13 | issueComments: IssueComment[]; 14 | }; 15 | 16 | /** 17 | @example 18 | const command = new GithubCreateIssueCommentsCommand({ 19 | owner: "abstracter-io", 20 | 21 | repo: "atomic-release", 22 | 23 | issueComments: [ 24 | { issueNumber: 1, commentBody: "Commenting in issue number 1 🥳" }, 25 | { issueNumber: 1, commentBody: "**Another message in issue #1**" }, 26 | { issueNumber: 2, commentBody: "Some other issue" }, 27 | ], 28 | 29 | headers: { 30 | Authorization: "token PERSONAL-ACCESS-TOKEN", 31 | }, 32 | }); 33 | */ 34 | class GithubCreateIssueCommentsCommand extends GithubHttpCommand { 35 | private readonly createdCommentsResources: CommentRestResource[] = []; 36 | 37 | private async createComment(issueComment: IssueComment): Promise { 38 | const { repoOwner, repoName } = this.config; 39 | const { issueNumber, commentBody } = issueComment; 40 | const url = this.expendURL('https://api.github.com/repos/{owner}/{repo}/issues/{issueNumber}/comments', { 41 | owner: repoOwner, 42 | repo: repoName, 43 | issueNumber, 44 | }); 45 | const response = await this.fetch(url, { 46 | method: 'POST', 47 | 48 | headers: { 49 | 'Content-Type': 'application/json', 50 | }, 51 | 52 | body: JSON.stringify({ 53 | body: commentBody, 54 | }), 55 | }); 56 | const statusCode = response.status; 57 | 58 | if (statusCode === 404 || response.status === 410) { 59 | this.logger.info(`Could not find issue '${issueNumber}'. Comment was not created.`); 60 | 61 | return null; 62 | } 63 | 64 | if (response.status !== 201) { 65 | throw new Error(`Failed to create a comment in issue '${issueNumber}'. Status code is ${response.status}`); 66 | } 67 | 68 | return response; 69 | } 70 | 71 | private async deleteComment(commentResource: CommentRestResource): Promise { 72 | const { repoOwner, repoName } = this.config; 73 | const url = this.expendURL('https://api.github.com/repos/{owner}/{repo}/issues/comments/{commentId}', { 74 | repo: repoName, 75 | owner: repoOwner, 76 | commentId: commentResource.id as string, 77 | }); 78 | const response = await this.fetch(url, { 79 | method: 'DELETE', 80 | }); 81 | const statusCode = response.status; 82 | const commentURL = commentResource.html_url; 83 | 84 | if (statusCode === 204) { 85 | this.logger.info(`Deleted comment: ${commentURL}`); 86 | } 87 | // 88 | else { 89 | this.logger.warn(`Failed to delete comment '${commentURL}'. Status code is ${statusCode}`); 90 | } 91 | } 92 | 93 | public async do(): Promise { 94 | const comments: Promise[] = []; 95 | 96 | for (const issueComment of this.config.issueComments) { 97 | const createComment = async (): Promise => { 98 | const response = await this.createComment(issueComment); 99 | 100 | if (response) { 101 | const resource = await response.json() as Record; 102 | 103 | this.createdCommentsResources.push(resource); 104 | 105 | this.logger.info(`Created comment: ${resource.html_url} (id: ${resource.id})`); 106 | } 107 | }; 108 | 109 | comments.push(createComment()); 110 | } 111 | 112 | await Promise.all(comments); 113 | } 114 | 115 | public async undo(): Promise { 116 | const deletions = this.createdCommentsResources.map((resource) => { 117 | return this.deleteComment(resource); 118 | }); 119 | 120 | await Promise.all(deletions); 121 | } 122 | } 123 | 124 | export { GithubCreateIssueCommentsCommand, GithubCreateIssueCommentsCommandConfig }; 125 | -------------------------------------------------------------------------------- /src/commands/github/github-create-pull-request-command.ts: -------------------------------------------------------------------------------- 1 | import { MimeTypes, GithubHttpCommand, GithubHttpCommandConfig } from './github-http-command.js'; 2 | 3 | type PullRequestRestResource = Record; 4 | 5 | type GithubCreatePullRequestCommandConfig = GithubHttpCommandConfig & { 6 | // some-branch 7 | head: string; 8 | 9 | // main 10 | base: string; 11 | 12 | // name of the repo 13 | repo: string; 14 | 15 | // name of repo owner (the org name / user name) 16 | owner: string; 17 | 18 | // pull request title 19 | title: string; 20 | 21 | // pull request description "body" 22 | body?: string; 23 | }; 24 | 25 | /* 26 | * Consider creating a command to merge a pull request 27 | * vs a workflow with some dedicated action... 28 | * The former will most likely require a dedicated user 29 | * as a user that opened a pull request cannot approve / merge it 30 | * 31 | * https://docs.github.com/en/rest/reference/pulls#create-a-pull-request 32 | * https://docs.github.com/en/rest/reference/pulls#update-a-pull-request 33 | */ 34 | 35 | /** 36 | @example 37 | 38 | const command = new GithubCreatePullRequestCommand({ 39 | head: "v123-generated-files", 40 | base: "main", 41 | repo: "atomic-release", 42 | owner: "abstracter-io", 43 | title: "🤖 Adding v23 generated files", 44 | body: "**Take me to your leader**." 45 | headers: { 46 | Authorization: "token PERSONAL-ACCESS-TOKEN", 47 | }, 48 | }); 49 | */ 50 | class GithubCreatePullRequestCommand extends GithubHttpCommand { 51 | private pullRequestResource: PullRequestRestResource; 52 | 53 | private async createPullRequest(): Promise { 54 | const url = `https://api.github.com/repos/${this.config.owner}/${this.config.repo}/pulls`; 55 | const response = await this.fetch(url, { 56 | method: 'POST', 57 | 58 | headers: { 59 | Accept: MimeTypes.V3, 60 | }, 61 | 62 | body: JSON.stringify({ 63 | head: this.config.head, 64 | base: this.config.base, 65 | body: this.config.body, 66 | title: this.config.title, 67 | }), 68 | }); 69 | 70 | if (response.status === 201) { 71 | return (await response.json()) as PullRequestRestResource; 72 | } 73 | 74 | throw new Error(`Failed to create pull request. Status code is ${response.status}`); 75 | } 76 | 77 | public async undo(): Promise { 78 | if (this.pullRequestResource) { 79 | // Can't delete a pull-request, the next best thing is to close it 80 | const pr = this.pullRequestResource; 81 | const pullRequestURL = pr.html_url; 82 | const url = `https://api.github.com/repos/${this.config.owner}/${this.config.repo}/pulls/${pr.number}`; 83 | const response = await this.fetch(url, { 84 | method: 'PATCH', 85 | 86 | headers: { 87 | Accept: MimeTypes.V3, 88 | }, 89 | 90 | body: JSON.stringify({ 91 | state: 'closed', 92 | }), 93 | }); 94 | 95 | if (response.status === 200) { 96 | this.logger.info(`Closed pull request: ${pullRequestURL}`); 97 | } 98 | else { 99 | this.logger.warn(`Failed to close pull request ${pullRequestURL}. Status code is ${response.status}`); 100 | } 101 | } 102 | } 103 | 104 | public async do(): Promise { 105 | const pullRequestRestResource = await this.createPullRequest(); 106 | 107 | this.pullRequestResource = pullRequestRestResource; 108 | 109 | this.logger.info(`Created pull request: ${pullRequestRestResource.html_url} (id: ${pullRequestRestResource.id})`); 110 | } 111 | } 112 | 113 | export { GithubCreatePullRequestCommand, GithubCreatePullRequestCommandConfig }; 114 | -------------------------------------------------------------------------------- /src/commands/github/github-create-release-command.ts: -------------------------------------------------------------------------------- 1 | import mime from 'mime'; 2 | import fs from 'node:fs'; 3 | import path from 'node:path'; 4 | import { to } from 'await-to-js'; 5 | import { Readable } from 'node:stream'; 6 | 7 | import { timer } from '../../utils/timer.js'; 8 | import { GithubHttpCommand, GithubHttpCommandConfig } from './github-http-command.js'; 9 | 10 | type File = { 11 | mimeType: string; 12 | bytesSize: number; 13 | readStream: Readable; 14 | }; 15 | 16 | type Asset = { 17 | // An alternate short description of the asset. 18 | // Used in place of the filename. (per GitHub docs) 19 | label?: string; 20 | 21 | name: string; 22 | 23 | absoluteFilePath: string; 24 | }; 25 | 26 | type ReleaseRestResource = Record; 27 | 28 | type GithubCreateReleaseCommandConfig = GithubHttpCommandConfig & { 29 | // name of the repo 30 | repo: string; 31 | 32 | // name of repo owner (the org name / username) 33 | owner: string; 34 | 35 | // the name of a PRE existing tag (i.e. tag present in the repo's remote) 36 | tagName: string; 37 | 38 | // when falsy, release is marked as pre-release 39 | isStable?: boolean; 40 | 41 | name: string; 42 | 43 | body?: string; 44 | 45 | assets?: (Omit & { name?: string })[]; 46 | }; 47 | 48 | /** 49 | @example 50 | const command = new GithubCreateReleaseCommand({ 51 | owner: "abstracter-io", 52 | repo: "atomic-release", 53 | tagName: "v2.0.1", 54 | isStable: true, 55 | name: "RELEASE v2.0.1", 56 | body: "***This is SPARTA***", 57 | headers: { 58 | Authorization: "token [PERSONAL ACCESS TOKEN]", 59 | }, 60 | assets: [ 61 | { absoluteFilePath: "/home/rick/dev/package.json" }, 62 | { absoluteFilePath: "/home/rick/dev/build.json", label: "." }, 63 | { absoluteFilePath: "/home/rick/dev/package.json", name: "custom name" }, 64 | ], 65 | }); 66 | */ 67 | class GithubCreateReleaseCommand extends GithubHttpCommand { 68 | private createdRelease: ReleaseRestResource; 69 | 70 | private getAssets(): Asset[] { 71 | const assets = this.config.assets ?? []; 72 | const uniquelyNamedAssets = new Map(); 73 | 74 | for (const asset of assets) { 75 | const name = asset.name ?? path.basename(asset.absoluteFilePath); 76 | const previousAsset = uniquelyNamedAssets.get(name); 77 | 78 | if (previousAsset) { 79 | this.logger.warn(`An asset named '${name}' already exists`); 80 | this.logger.warn('Duplicate asset will be filtered out'); 81 | } 82 | // 83 | else { 84 | uniquelyNamedAssets.set(name, { 85 | ...asset, 86 | name, 87 | }); 88 | } 89 | } 90 | 91 | return Array.from(uniquelyNamedAssets.values()); 92 | } 93 | 94 | private async createRelease(): Promise { 95 | const { owner, repo, name, tagName, body, isStable } = this.config; 96 | const url = `https://api.github.com/repos/${owner}/${repo}/releases`; 97 | const hasAssets = Array.isArray(this.config.assets) && this.config.assets.length > 0; 98 | const response = await this.fetch(url, { 99 | method: 'POST', 100 | 101 | headers: { 102 | 'Content-Type': 'application/json', 103 | }, 104 | 105 | body: JSON.stringify({ 106 | tag_name: tagName, 107 | name, 108 | body, 109 | draft: hasAssets, 110 | prerelease: !isStable, 111 | }), 112 | }); 113 | 114 | if (response.status === 201) { 115 | return (await response.json()) as ReleaseRestResource; 116 | } 117 | 118 | throw new Error(`Failed to create release. Status code is ${response.status}`); 119 | } 120 | 121 | private async getFile(absoluteFilePath: string): Promise { 122 | const [error, stat] = await to(fs.promises.stat(absoluteFilePath)); 123 | 124 | if (stat) { 125 | if (stat.isFile()) { 126 | const mimeType = mime.getType(path.extname(absoluteFilePath)); 127 | 128 | if (mimeType === null) { 129 | throw new Error('unknown mime type'); 130 | } 131 | 132 | return { 133 | mimeType, 134 | bytesSize: stat.size, 135 | readStream: Readable.from(fs.createReadStream(absoluteFilePath, 'utf-8')), 136 | }; 137 | } 138 | } 139 | 140 | if (error) { 141 | this.logger.error(error); 142 | } 143 | 144 | throw new Error(`File '${absoluteFilePath}' does not exists or is not as file`); 145 | } 146 | 147 | private async deleteRelease(resource: ReleaseRestResource): Promise { 148 | const { owner, repo } = this.config; 149 | const releaseURL = resource.html_url; 150 | const url = `https://api.github.com/repos/${owner}/${repo}/releases/${resource.id}`; 151 | const response = await this.fetch(url, { 152 | method: 'DELETE', 153 | }); 154 | 155 | if (response.status === 204) { 156 | this.logger.info(`Deleted release: ${releaseURL}`); 157 | } 158 | // 159 | else { 160 | this.logger.warn(`Failed to delete release '${releaseURL}'. Status code is ${response.status}`); 161 | } 162 | } 163 | 164 | private async publishRelease(url: string): Promise { 165 | const response = await this.fetch(url, { 166 | method: 'POST', 167 | 168 | headers: { 169 | 'Content-Type': 'application/json', 170 | }, 171 | 172 | body: JSON.stringify({ 173 | draft: false, 174 | }), 175 | }); 176 | 177 | if (response.status !== 200) { 178 | throw new Error(`Failed to take release out of draft mode. Status code is ${response.status}`); 179 | } 180 | 181 | return await response.json() as Record; 182 | } 183 | 184 | private async uploadAsset(uploadURL: string, asset: Asset): Promise { 185 | const path = asset.absoluteFilePath; 186 | const file = await this.getFile(path); 187 | const url = this.expendURL(uploadURL, { 188 | name: asset.name, 189 | label: asset.label, 190 | }); 191 | 192 | return this.fetch(url, { 193 | method: 'POST', 194 | 195 | headers: { 196 | 'Content-Length': file.bytesSize.toString(), 197 | 'Content-Type': file.mimeType, 198 | }, 199 | 200 | body: file.readStream, 201 | }); 202 | } 203 | 204 | public async undo(): Promise { 205 | if (this.createdRelease) { 206 | await this.deleteRelease(this.createdRelease); 207 | } 208 | } 209 | 210 | public async do(): Promise { 211 | const uploads: Promise[] = []; 212 | const releaseRestResource = await this.createRelease(); 213 | const releaseAssetUploadURL = releaseRestResource.upload_url as string; 214 | 215 | this.createdRelease = releaseRestResource; 216 | 217 | for (const asset of this.getAssets()) { 218 | const upload = async (): Promise => { 219 | const uploadTimer = timer(); 220 | const response = await this.uploadAsset(releaseAssetUploadURL, asset); 221 | 222 | if (response.status !== 201) { 223 | const resource = await response.json(); 224 | 225 | this.logger.info(JSON.stringify(resource, null, 2)); 226 | 227 | throw new Error(`Failed to upload asset '${asset.absoluteFilePath}'. Status code is ${response.status}`); 228 | } 229 | 230 | this.logger.info(`Asset: ${asset.absoluteFilePath} was uploaded in ${uploadTimer}`); 231 | 232 | return response; 233 | }; 234 | 235 | this.logger.info(`Uploading asset named: ${asset.name} (src: ${asset.absoluteFilePath})`); 236 | 237 | uploads.push(upload()); 238 | } 239 | 240 | if (uploads.length) { 241 | await Promise.all(uploads); 242 | 243 | this.createdRelease = await this.publishRelease(this.createdRelease.url as string); 244 | } 245 | 246 | this.logger.info(`Created release: ${this.createdRelease.html_url} (id: ${this.createdRelease.id})`); 247 | } 248 | } 249 | 250 | export { GithubCreateReleaseCommand, GithubCreateReleaseCommandConfig }; 251 | -------------------------------------------------------------------------------- /src/commands/github/github-http-command.ts: -------------------------------------------------------------------------------- 1 | import uriTemplates from 'uri-templates'; 2 | import type { RequestInit, RequestInfo, Response } from 'undici-types'; 3 | 4 | import { HttpCommand, HttpCommandConfig } from '../http-command.js'; 5 | 6 | const enum MimeTypes { 7 | V3 = 'application/vnd.github.v3+json', 8 | } 9 | 10 | const DEFAULT_HEADERS = { 11 | Accept: 'application/vnd.github.v3+json', 12 | }; 13 | 14 | type GithubHttpCommandConfig = HttpCommandConfig; 15 | 16 | abstract class GithubHttpCommand extends HttpCommand { 17 | public constructor(config: T) { 18 | super({ 19 | ...config, 20 | headers: { 21 | ...DEFAULT_HEADERS, 22 | ...config.headers, 23 | }, 24 | }); 25 | } 26 | 27 | protected expendURL(url: string, parameters: Record): string { 28 | return uriTemplates(url).fill(parameters); 29 | } 30 | 31 | protected async fetch(info: RequestInfo, init: RequestInit): Promise { 32 | const response = await super.fetch(info, init); 33 | 34 | if (!response.ok) { 35 | const tip = response.headers.get('X-Accepted-GitHub-Permissions'); 36 | 37 | if (tip) { 38 | this.logger.warn(`GitHub Missing Permissions: ${tip}`); 39 | } 40 | } 41 | 42 | return response; 43 | } 44 | } 45 | 46 | export { MimeTypes, GithubHttpCommand, GithubHttpCommandConfig }; 47 | -------------------------------------------------------------------------------- /src/commands/http-command.ts: -------------------------------------------------------------------------------- 1 | import fetchDefaults from 'fetch-defaults'; 2 | import type { RequestInit, RequestInfo, Response } from 'undici-types'; 3 | 4 | import { Command, CommandConfig } from '../sdk/command.js'; 5 | 6 | type Fetch = typeof fetch; 7 | 8 | type HttpCommandConfig = CommandConfig & { 9 | fetch?: Fetch; 10 | headers?: Record; 11 | }; 12 | 13 | abstract class HttpCommand extends Command { 14 | private readonly _fetch: Fetch; 15 | 16 | protected constructor(config: T) { 17 | super(config); 18 | 19 | this._fetch = fetchDefaults(config.fetch ?? fetch, { 20 | headers: { 21 | ...config.headers, 22 | }, 23 | }); 24 | } 25 | 26 | protected fetch(input: RequestInfo, init: RequestInit): Promise { 27 | return this._fetch(input, init); 28 | } 29 | } 30 | 31 | export { HttpCommand, HttpCommandConfig }; 32 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | export * from './http-command.js'; 2 | export * from './exec-command.js'; 3 | export * from './file-writer-command.js'; 4 | 5 | export * from './git/git-tag-command.js'; 6 | export * from './git/git-commit-command.js'; 7 | export * from './git/git-push-branch-command.js'; 8 | export * from './git/git-switch-branch-command.js'; 9 | 10 | export * from './npm/npm-command.js'; 11 | export * from './npm/npm-publish-package-command.js'; 12 | export * from './npm/npm-bump-package-version-command.js'; 13 | 14 | export * from './github/github-http-command.js'; 15 | export * from './github/github-create-release-command.js'; 16 | export * from './github/github-create-pull-request-command.js'; 17 | export * from './github/github-create-issue-comments-command.js'; 18 | -------------------------------------------------------------------------------- /src/commands/npm/npm-bump-package-version-command.ts: -------------------------------------------------------------------------------- 1 | import { NpmCommand, NpmCommandConfig } from './npm-command.js'; 2 | 3 | type NpmBumpPackageVersionCommandConfig = NpmCommandConfig & { 4 | version: string; 5 | preReleaseId?: string; 6 | }; 7 | 8 | /** 9 | @example bumping to "0.0.1-beta.0" (assuming current version is 0.0.1) 10 | const command = new NpmBumpPackageVersionCommand({ 11 | preReleaseId: "beta", 12 | workingDirectory: "/absolute/path", <-- package.json should be inside 13 | }); 14 | --- 15 | @example bumping to "1.1.0" (does not matter what the current version is) 16 | const command = new NpmBumpPackageVersionCommand({ 17 | version: "1.1.0", 18 | workingDirectory: "/absolute/path", <-- package.json should be inside 19 | }); 20 | */ 21 | class NpmBumpPackageVersionCommand extends NpmCommand { 22 | private initialVersion: string; 23 | private versionChanged: boolean; 24 | 25 | private async versionCmd(arg: string): Promise { 26 | await this.exec(`npm version ${arg} --no-git-tag-version`); 27 | } 28 | 29 | private async bumpVersion(): Promise { 30 | const preReleaseId = this.config.preReleaseId; 31 | 32 | await this.versionCmd(this.config.version); 33 | 34 | if (preReleaseId) { 35 | await this.versionCmd(`prerelease --preid=${preReleaseId}`); 36 | } 37 | 38 | return (await this.getPackageJson()).version as string; 39 | } 40 | 41 | public async do(): Promise { 42 | const { version, name } = await this.getPackageJson(); 43 | 44 | if (version && name) { 45 | this.initialVersion = version; 46 | 47 | if (this.config.version !== version) { 48 | const changedVersion = await this.bumpVersion(); 49 | 50 | this.logger.info(`Changed package '${name}' version to '${changedVersion}'`); 51 | 52 | this.versionChanged = true; 53 | 54 | return; 55 | } 56 | 57 | throw new Error('version should have changed'); 58 | } 59 | 60 | throw new Error(`Package ${this.packageJsonFilePath} 'version' or 'name' properties are missing`); 61 | } 62 | 63 | public async undo(): Promise { 64 | if (this.versionChanged) { 65 | const { name } = await this.getPackageJson(); 66 | const initialVersion = this.initialVersion; 67 | 68 | await this.versionCmd(initialVersion); 69 | 70 | this.logger.info(`Reverted '${name}' version back to '${initialVersion}'`); 71 | } 72 | } 73 | } 74 | 75 | export { NpmBumpPackageVersionCommand, NpmBumpPackageVersionCommandConfig }; 76 | -------------------------------------------------------------------------------- /src/commands/npm/npm-command.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import type { PackageJson } from 'type-fest'; 3 | 4 | import { ExecCommand, ExecCommandConfig } from '../exec-command.js'; 5 | 6 | type NpmCommandConfig = ExecCommandConfig; 7 | 8 | abstract class NpmCommand extends ExecCommand { 9 | protected readonly packageJsonFilePath: string; 10 | 11 | public constructor(options: T) { 12 | super(options); 13 | 14 | this.packageJsonFilePath = `${options.workingDirectory}/package.json`; 15 | } 16 | 17 | protected async getPackageJson(): Promise { 18 | const packageJson = await fs.promises.readFile(this.packageJsonFilePath, { 19 | flag: 'rs', 20 | encoding: 'utf-8', 21 | }); 22 | 23 | return JSON.parse(packageJson); 24 | } 25 | } 26 | 27 | export { NpmCommand, NpmCommandConfig }; 28 | -------------------------------------------------------------------------------- /src/commands/npm/npm-publish-package-command.ts: -------------------------------------------------------------------------------- 1 | import { NpmCommand, NpmCommandConfig } from './npm-command.js'; 2 | 3 | type NpmPublishPackageCommandConfig = NpmCommandConfig & { 4 | tag?: string; 5 | registry?: string; 6 | undoPublish?: boolean; 7 | }; 8 | 9 | /* 10 | NOTE: THIS COMMAND MOST LIKELY NEED TO BE THE LAST COMMAND TO EXECUTE. 11 | 12 | The commonly used registry (https://registry.npmjs.org) does 13 | allow removing a published package but does not allow publishing 14 | the same version even though it was unpublished... :facepalm: 15 | 16 | Assuming the registry in use does not impose 17 | such a limit, there is an option called "undoPublish" that will 18 | control whether this command undo should "unpublish" 19 | */ 20 | 21 | /** 22 | @example 23 | const command = new NpmPublishPackageCommand({ 24 | tag: "beta", <-- i.e. npm install @beta 25 | registry: "https://npm.evil-corp.com" 26 | undoPublish: true, <-- this should be true only when the registry support publishing the same version again. 27 | workingDirectory: "/absolute/path", <-- package.json should be inside 28 | }); 29 | */ 30 | class NpmPublishPackageCommand extends NpmCommand { 31 | private publishedPackage: string; 32 | 33 | private async publish(args: string[]): Promise { 34 | const { childProcess } = await this.exec({ command: 'npm', args: ['publish', ...args] }); 35 | 36 | if (childProcess.exitCode !== 0) { 37 | throw new Error(`Publish failed. exit code is ${childProcess.exitCode}`); 38 | } 39 | } 40 | 41 | public async undo(): Promise { 42 | if (this.publishedPackage && this.config.undoPublish === true) { 43 | await this.exec({ command: 'npm', args: ['unpublish', this.publishedPackage] }); 44 | } 45 | } 46 | 47 | public async do(): Promise { 48 | const { name, version, ...pkg } = await this.getPackageJson(); 49 | 50 | if (pkg.private) { 51 | this.logger.info(`Skipping publish. Package '${name}' private property is true.`); 52 | } 53 | else { 54 | const args: string[] = []; 55 | const { tag, registry } = this.config; 56 | 57 | if (tag) { 58 | args.push('--tag', tag); 59 | 60 | this.logger.info(`Publishing '${name}@${version}' using dist tag '${tag}'`); 61 | } 62 | 63 | if (registry) { 64 | args.push('--registry', registry); 65 | 66 | this.logger.info(`Publishing '${name}@${version}' to registry '${registry}'`); 67 | } 68 | 69 | await this.publish(args); 70 | 71 | this.logger.info(`Published package '${name}@${version}'`); 72 | 73 | this.publishedPackage = `${name}@${version}`; 74 | } 75 | } 76 | } 77 | 78 | export { NpmPublishPackageCommand, NpmPublishPackageCommandConfig }; 79 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * as SDK from './sdk/index.js'; 2 | export * as Commands from './commands/index.js'; 3 | -------------------------------------------------------------------------------- /src/sdk/command.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from './logger.js'; 2 | import { processStdoutLogger } from './process-stdout-logger.js'; 3 | 4 | type CommandConfig = { 5 | logger?: Logger; 6 | }; 7 | 8 | abstract class Command { 9 | protected readonly logger: Logger; 10 | protected readonly config: Omit; 11 | 12 | public constructor(config?: T) { 13 | this.config = config ?? ({} as T); 14 | 15 | this.logger = config?.logger ?? processStdoutLogger({ name: this.getName() }); 16 | } 17 | 18 | public getName(): string { 19 | return this.constructor.name; 20 | } 21 | 22 | public abstract do(): Promise; 23 | 24 | public undo(): Promise { 25 | return Promise.resolve(undefined); 26 | }; 27 | 28 | public cleanup(): Promise { 29 | return Promise.resolve(undefined); 30 | }; 31 | } 32 | 33 | export { Command, CommandConfig }; 34 | -------------------------------------------------------------------------------- /src/sdk/git-client.d.ts: -------------------------------------------------------------------------------- 1 | type Commit = { 2 | hash: string; 3 | 4 | body: string; 5 | 6 | notes: string | null; 7 | 8 | tags: string[]; 9 | 10 | subject: string; 11 | 12 | committedTimestamp: number; 13 | 14 | author: { 15 | name: string; 16 | email: string; 17 | }; 18 | 19 | committer: { 20 | name: string; 21 | email: string; 22 | }; 23 | }; 24 | 25 | type Tag = { 26 | hash: string; 27 | name: string; 28 | }; 29 | 30 | interface GitClient { 31 | log(range: string, format: string): Promise; 32 | 33 | refHash(ref: string): Promise; 34 | 35 | refName(ref: string): Promise; 36 | 37 | commits(range: string): Promise; 38 | 39 | listTags(ref: string): Promise; 40 | 41 | remoteTagHash(tagName: string): Promise; 42 | 43 | remoteBranchHash(branchName?: string): Promise; 44 | } 45 | 46 | export type { GitClient, Tag, Commit }; 47 | -------------------------------------------------------------------------------- /src/sdk/git-exec-client.ts: -------------------------------------------------------------------------------- 1 | import { exec, ExecOptions } from '../utils/exec.js'; 2 | 3 | import type { GitClient, Tag, Commit } from './git-client.js'; 4 | 5 | type GitExecClientConfig = { 6 | remote: string; 7 | workingDirectory: string; 8 | }; 9 | 10 | class GitExecClient implements GitClient { 11 | private readonly config: GitExecClientConfig; 12 | 13 | public constructor(config: Partial = {}) { 14 | this.config = { 15 | remote: config.remote ?? 'origin', 16 | workingDirectory: config.workingDirectory ?? process.cwd(), 17 | }; 18 | } 19 | 20 | protected exec = exec; 21 | 22 | protected cli(args: string, config?: ExecOptions) { 23 | return this.exec(`git ${args}`, { 24 | cwd: this.config.workingDirectory, 25 | ...config, 26 | }); 27 | } 28 | 29 | async log(range: string, format: string): Promise { 30 | const logs: string[] = []; 31 | const delimiter = ':++:'; 32 | const result = await this.cli(`log ${range} --pretty=format:${format}${delimiter}`); 33 | 34 | for (const log of result.stdout.split(delimiter)) { 35 | if (log.length) { 36 | logs.push(log.startsWith('\n') ? log.substring(1) : log); 37 | } 38 | } 39 | 40 | return logs; 41 | } 42 | 43 | async cliVersion(): Promise { 44 | const { stdout } = await this.cli('--version'); 45 | 46 | return stdout.split(' ')[2]; 47 | } 48 | 49 | async refHash(ref: string): Promise { 50 | const { stdout } = await this.cli(`rev-parse ${ref}`); 51 | 52 | return stdout; 53 | } 54 | 55 | async refName(ref: string): Promise { 56 | const { stdout } = await this.cli(`rev-parse --abbrev-ref ${ref}`); 57 | 58 | return stdout; 59 | } 60 | 61 | /** 62 | * @param range A git log range. 63 | * 64 | * @example 65 | * 66 | * const gitClient = new GitClient(); 67 | * 68 | * // Find all commits since ref 1234 (non-inclusive) 69 | * const commitSince = await gitClient.commits("1234.."); 70 | * 71 | * // Find all commits until 1234 (inclusive) 72 | * const commitsUntil = await gitClient.commits("1234"); 73 | */ 74 | async commits(range: string): Promise { 75 | const commits: Commit[] = []; 76 | const delimiter = ':<>:'; 77 | const tagPattern = /tag: (.*),?/gi; 78 | // @src https://git-scm.com/docs/pretty-formats 79 | const formats = ['%H', '%s', '%b', '%N', '%D', '%ct', '%an', '%ae', '%cn', '%ce']; 80 | const rawCommits = await this.log(range, formats.join(delimiter)); 81 | 82 | for (const rawCommit of rawCommits) { 83 | const tags: string[] = []; 84 | const logEntry = rawCommit.split(delimiter); 85 | 86 | let match: RegExpExecArray | null = null; 87 | 88 | for (const ref of logEntry[4].split(',')) { 89 | while ((match = tagPattern.exec(ref))) { 90 | tags.push(match[1]); 91 | } 92 | } 93 | 94 | commits.push({ 95 | hash: logEntry[0], 96 | 97 | subject: logEntry[1], 98 | 99 | body: logEntry[2], 100 | 101 | notes: logEntry[3] ?? null, 102 | 103 | committedTimestamp: parseInt(logEntry[5], 10) * 1000, 104 | 105 | author: { 106 | name: logEntry[6], 107 | email: logEntry[7], 108 | }, 109 | 110 | committer: { 111 | name: logEntry[8], 112 | email: logEntry[9], 113 | }, 114 | 115 | tags, 116 | }); 117 | } 118 | 119 | return commits; 120 | } 121 | 122 | async listTags(): Promise { 123 | const tags: Tag[] = []; 124 | const delimiter = ':++:'; 125 | const { stdout } = await this.cli(`tag --format=%(refname:strip=2)${delimiter}%(objectname)`); 126 | 127 | for (const tag of stdout.split('\n')) { 128 | if (tag.length) { 129 | const [name, hash] = tag.split(delimiter); 130 | 131 | tags.push({ 132 | hash, 133 | name, 134 | }); 135 | } 136 | } 137 | 138 | return tags; 139 | } 140 | 141 | async remoteTagHash(tagName: string): Promise { 142 | const { stdout } = await this.cli(`ls-remote ${this.config.remote} -t refs/tags/${tagName}`); 143 | 144 | if (stdout.length) { 145 | return stdout.split('\t')[0]; 146 | } 147 | 148 | return null; 149 | } 150 | 151 | async remoteBranchHash(branchName?: string): Promise { 152 | const branch = branchName ?? (await this.refName('HEAD')); 153 | const { stdout } = await this.cli(`ls-remote ${this.config.remote} -h refs/heads/${branch}`); 154 | 155 | if (stdout.length) { 156 | return stdout.split('\t')[0]; 157 | } 158 | 159 | return null; 160 | } 161 | } 162 | 163 | export { GitExecClient, GitExecClientConfig, Tag }; 164 | -------------------------------------------------------------------------------- /src/sdk/git-strategy.ts: -------------------------------------------------------------------------------- 1 | import { Strategy, StrategyConfig } from './strategy.js'; 2 | 3 | import { GitClient } from './git-client.js'; 4 | 5 | type GitStrategyOptions = StrategyConfig & { 6 | gitClient: GitClient; 7 | releaseBranchNames: Set; 8 | }; 9 | 10 | class GitStrategy extends Strategy { 11 | protected async shouldRun(): Promise { 12 | if (await super.shouldRun()) { 13 | const branchName = await this.config.gitClient.refName('HEAD'); 14 | 15 | if (this.config.releaseBranchNames.has(branchName)) { 16 | const [localHash, remoteHash] = await Promise.all([ 17 | this.config.gitClient.refHash(branchName), 18 | this.config.gitClient.remoteBranchHash(branchName), 19 | ]); 20 | 21 | if (localHash === remoteHash) { 22 | return true; 23 | } 24 | 25 | this.config.logger.info(`Local branch hash is ${localHash}`); 26 | 27 | this.config.logger.info(`Remote branch hash is ${remoteHash}`); 28 | 29 | this.config.logger.info('Branch local hash is not the same as its remote counterpart'); 30 | 31 | return false; 32 | } 33 | 34 | this.config.logger.info(`Branch '${branchName}' is not a release branch`); 35 | } 36 | 37 | return false; 38 | } 39 | } 40 | 41 | export { GitStrategy, GitStrategyOptions }; 42 | -------------------------------------------------------------------------------- /src/sdk/git-trunk-release.ts: -------------------------------------------------------------------------------- 1 | import { memo } from 'radash'; 2 | import { loadPreset } from 'conventional-changelog-preset-loader'; 3 | import { Commit as ConventionalCommit, CommitParser, ParserOptions } from 'conventional-commits-parser'; 4 | import { writeChangelogString, Options as WriterOptions, Context as WriterContext } from 'conventional-changelog-writer'; 5 | 6 | import { Logger } from './logger.js'; 7 | import { Release } from './release.js'; 8 | import { GitExecClient } from './git-exec-client.js'; 9 | import { processStdoutLogger } from './process-stdout-logger.js'; 10 | 11 | type ConventionalPreset = { 12 | parser: ParserOptions; 13 | writer: WriterOptions; 14 | whatBump: (commits: ConventionalCommit[]) => { level: number; reason: string }; 15 | }; 16 | 17 | type GitTrunkReleaseConfig = { 18 | logger?: Logger; 19 | 20 | gitClient?: GitExecClient; 21 | 22 | remote?: string; 23 | 24 | workingDirectory?: string; 25 | 26 | changelogCommitFilter?: (commit: ConventionalCommit) => boolean; 27 | 28 | rawConventionalCommits?: (range: string) => Promise<{ hash: string; raw: string }[]>; 29 | 30 | conventionalChangelogPreset?: ConventionalPreset; 31 | 32 | conventionalChangelogWriterContext: WriterContext | null; 33 | }; 34 | 35 | type Options = Required; 36 | 37 | const defaultConfig = async (config: GitTrunkReleaseConfig): Promise => { 38 | const workingDirectory = config.workingDirectory ?? process.cwd(); 39 | const remote = config.remote ?? 'origin'; 40 | const gitClient = config.gitClient ?? new GitExecClient({ 41 | remote, 42 | workingDirectory, 43 | }); 44 | const rawConventionalCommits = async (range: string) => { 45 | const commits = await gitClient.commits(range); 46 | 47 | return commits.map((commit) => { 48 | const lines = [ 49 | // subject 50 | `${commit.subject}`, 51 | 52 | // body 53 | `${commit.body}`, 54 | 55 | // extra fields 56 | '-hash-', 57 | `${commit.hash}`, 58 | 59 | '-gitTags-', 60 | `${commit.tags.join(',')}`, 61 | 62 | '-committerDate-', 63 | `${new Date(commit.committedTimestamp)}`, 64 | ]; 65 | 66 | return { 67 | hash: commit.hash, 68 | raw: lines.join('\n'), 69 | }; 70 | }); 71 | }; 72 | 73 | const changelogCommitFilter = (_commit: ConventionalCommit): boolean => { 74 | return true; 75 | }; 76 | 77 | return { 78 | remote, 79 | gitClient, 80 | workingDirectory, 81 | 82 | logger: config.logger ?? processStdoutLogger({ name: 'GitTrunkRelease' }), 83 | changelogCommitFilter: config.changelogCommitFilter ?? changelogCommitFilter, 84 | rawConventionalCommits: config.rawConventionalCommits ?? rawConventionalCommits, 85 | conventionalChangelogPreset: config.conventionalChangelogPreset ?? (await loadPreset('conventionalcommits')), 86 | conventionalChangelogWriterContext: config.conventionalChangelogWriterContext ?? null, 87 | }; 88 | }; 89 | 90 | const trimHash = (hash: string) => hash.slice(0, 7); 91 | 92 | const gitTrunkRelease = async (config: GitTrunkReleaseConfig): Promise => { 93 | const { logger, ...opt } = await defaultConfig(config); 94 | const preset = opt.conventionalChangelogPreset; 95 | const gitClient = opt.gitClient; 96 | const commitParser = new CommitParser(preset.parser); 97 | 98 | // Private methods 99 | // ================ 100 | const getHeadHash = memo(async () => { 101 | const hash = await gitClient.refHash('HEAD'); 102 | 103 | return hash.slice(0, 7); 104 | }); 105 | 106 | const parseCommit = (rawConventionalCommit: string): ConventionalCommit => { 107 | return commitParser.parse(rawConventionalCommit); 108 | }; 109 | 110 | const generateChangelog = async (version: string, commits: ConventionalCommit[]): Promise => { 111 | if (commits.length) { 112 | if (opt.conventionalChangelogWriterContext) { 113 | const context = { 114 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 115 | ...opt.conventionalChangelogWriterContext as WriterContext, 116 | version, 117 | }; 118 | 119 | return writeChangelogString(commits, context, preset.writer).then((c) => { 120 | return c; 121 | }); 122 | } 123 | 124 | throw new Error('conventional changelog writer context is missing'); 125 | } 126 | 127 | return null; 128 | }; 129 | 130 | const getConventionalCommitByCommitHash = memo(async (version: string): Promise => { 131 | const rawConventionalCommits = await opt.rawConventionalCommits(`${version} -1`); 132 | 133 | if (rawConventionalCommits.length) { 134 | const conventionalCommit = parseCommit(rawConventionalCommits[0].raw); 135 | 136 | if (opt.changelogCommitFilter(conventionalCommit)) { 137 | return conventionalCommit; 138 | } 139 | } 140 | 141 | return null; 142 | }); 143 | 144 | // Public methods 145 | // ============== 146 | const listVersions = memo(async (max = 1): Promise => { 147 | const commits = await gitClient.commits(`-n ${max + 1}`); 148 | 149 | // this shift removes the HEAD commit. 150 | commits.shift(); 151 | 152 | return commits.map((commit) => { 153 | return trimHash(commit.hash); 154 | }); 155 | }); 156 | 157 | const getChangelog = memo(async (): Promise => { 158 | const commitHash = await getNextVersion(); 159 | 160 | return getChangelogByVersion(commitHash); 161 | }); 162 | 163 | const getNextVersion = getHeadHash; 164 | 165 | const getPreviousVersion = memo(async (): Promise => { 166 | const versions = await listVersions(1); 167 | 168 | if (!versions.length) { 169 | const hash = await getHeadHash(); 170 | 171 | logger.info(`Could not find a previous version. Will use HEAD hash ${hash} as initial version`); 172 | 173 | return hash; 174 | } 175 | 176 | return versions[0]; 177 | }); 178 | 179 | const getMentionedIssues = memo(async (): Promise> => { 180 | const issues = new Set(); 181 | const commitHash = await getNextVersion(); 182 | const conventionalCommit = await getConventionalCommitByCommitHash(commitHash); 183 | 184 | if (conventionalCommit) { 185 | for (const reference of conventionalCommit.references) { 186 | const issue = reference.issue; 187 | 188 | if (issue) { 189 | issues.add(issue); 190 | } 191 | } 192 | } 193 | 194 | return issues; 195 | }); 196 | 197 | const getChangelogByVersion = memo(async (version: string): Promise => { 198 | const conventionalCommit = await getConventionalCommitByCommitHash(version); 199 | 200 | if (conventionalCommit) { 201 | return generateChangelog(version, [conventionalCommit]); 202 | } 203 | 204 | return null; 205 | }); 206 | 207 | return { 208 | listVersions, 209 | getChangelog, 210 | getNextVersion, 211 | getMentionedIssues, 212 | getPreviousVersion, 213 | getChangelogByVersion, 214 | }; 215 | }; 216 | 217 | export { gitTrunkRelease, GitTrunkReleaseConfig }; 218 | -------------------------------------------------------------------------------- /src/sdk/github-npm-package-strategy.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { memo } from 'radash'; 3 | import parseGitHubURL from 'parse-github-url'; 4 | import { readPackageUp } from 'read-package-up'; 5 | 6 | import { Logger } from './logger.js'; 7 | import { Release } from './release.js'; 8 | import { GitClient } from './git-client.js'; 9 | import { GitStrategy } from './git-strategy.js'; 10 | import { GitExecClient } from './git-exec-client.js'; 11 | import { gitTagBasedRelease } from './git-tag-based-release.js'; 12 | import { processStdoutLogger } from './process-stdout-logger.js'; 13 | 14 | import * as Commands from '../commands/index.js'; 15 | 16 | type GithubNpmPackageStrategyConfig = { 17 | logger?: Logger; 18 | release?: Release; 19 | gitActor?: string; 20 | gitRemote?: string; 21 | gitClient?: GitClient; 22 | syncRemote?: boolean; 23 | stableBranchName?: string; 24 | workingDirectory?: string; 25 | changelogFilePath?: string; 26 | maintainChangelog?: boolean; 27 | releaseBranchNames?: Set; 28 | githubPersonalAccessToken?: string; 29 | }; 30 | 31 | const createRelease = async () => { 32 | const githubUrl = await getParsedGitHubURL(); 33 | 34 | return gitTagBasedRelease({ 35 | preReleaseBranches: new Set(['beta', 'alpha']), 36 | 37 | conventionalChangelogWriterContext: { 38 | host: `https://${githubUrl.host}`, 39 | owner: githubUrl.repoOwner, 40 | repoUrl: githubUrl.httpsURL, 41 | repository: githubUrl.repoName, 42 | }, 43 | }); 44 | }; 45 | 46 | const getPackageJson = memo(async () => { 47 | const pkg = await readPackageUp(); 48 | 49 | if (pkg) { 50 | return { 51 | packageJson: pkg.packageJson, 52 | folderPath: path.dirname(pkg.path), 53 | }; 54 | } 55 | 56 | throw new Error('could not find package.json'); 57 | }); 58 | 59 | const getParsedGitHubURL = memo(async () => { 60 | const { packageJson } = await getPackageJson(); 61 | const url = packageJson.repository?.url; 62 | 63 | if (url) { 64 | const parsed = parseGitHubURL(url); 65 | 66 | return { 67 | host: parsed.host, 68 | repoName: parsed.name, 69 | repoOwner: parsed.owner, 70 | httpsURL: `https://${parsed.host}/${parsed.owner}/${parsed.name}`, 71 | }; 72 | } 73 | 74 | throw new Error('repo url is missing'); 75 | }); 76 | 77 | const githubNpmPackageStrategy = async (config: GithubNpmPackageStrategyConfig = {}) => { 78 | const pkg = await getPackageJson(); 79 | const strategy = new GitStrategy({ 80 | ...config, 81 | logger: config.logger ?? processStdoutLogger({ name: 'GithubNpmPackageStrategy' }), 82 | release: config.release ?? await createRelease(), 83 | gitActor: config.gitActor ?? process.env.RELEASE_ACTOR, 84 | gitClient: config.gitClient ?? new GitExecClient(), // TODO: This should be wrapped by a cache? 85 | gitRemote: config.gitRemote ?? 'origin', 86 | syncRemote: config.syncRemote ?? false, 87 | stableBranchName: config.stableBranchName ?? 'main', 88 | workingDirectory: config.workingDirectory ?? process.cwd(), 89 | changelogFilePath: config.changelogFilePath ?? `${pkg.folderPath}/CHANGELOG.md`, 90 | maintainChangelog: config.maintainChangelog ?? false, 91 | releaseBranchNames: config.releaseBranchNames ?? new Set(['main', 'beta', 'alpha']), 92 | githubPersonalAccessToken: config.githubPersonalAccessToken ?? process.env.GITHUB_PAT_TOKEN, 93 | }); 94 | 95 | strategy.addCommandProvider(async (config) => { 96 | if (config.maintainChangelog) { 97 | const changelogs = [config.release.getChangelog()]; 98 | const changelog = await Promise.all(changelogs).then((versions) => { 99 | return versions.filter(Boolean).join('\n'); 100 | }); 101 | 102 | if (changelog !== '') { 103 | return new Commands.FileWriterCommand({ 104 | create: true, 105 | content: changelog, 106 | logger: config.logger, 107 | mode: 'prepend', 108 | absoluteFilePath: config.changelogFilePath, 109 | }); 110 | } 111 | } 112 | 113 | return null; 114 | }); 115 | 116 | strategy.addCommandProvider(async (config) => { 117 | const versionName = `v${await config.release.getNextVersion()}`; 118 | 119 | return new Commands.GitTagCommand({ 120 | name: versionName, 121 | logger: config.logger, 122 | remote: config.gitRemote, 123 | workingDirectory: config.workingDirectory, 124 | }); 125 | }); 126 | 127 | strategy.addCommandProvider(async (config) => { 128 | const [changelog, branchName, tagName] = await Promise.all([ 129 | config.release.getChangelog(), 130 | config.gitClient.refName('HEAD'), 131 | config.release.getNextVersion().then(name => `v${name}`), 132 | ]); 133 | const parsedGitHubURL = await getParsedGitHubURL(); 134 | 135 | // When a changelog is missing we should fail? is it even possible? 136 | // Perhaps compress the build folder and upload it as an asset? (use pkg.files) 137 | return new Commands.GithubCreateReleaseCommand({ 138 | body: changelog ?? undefined, 139 | logger: config.logger, 140 | name: tagName, 141 | isStable: branchName === config.stableBranchName, 142 | tagName: tagName, 143 | repo: parsedGitHubURL.repoName, 144 | owner: parsedGitHubURL.repoOwner, 145 | headers: { 146 | Authorization: `token ${config.githubPersonalAccessToken}`, 147 | }, 148 | }); 149 | }); 150 | 151 | strategy.addCommandProvider(async (config) => { 152 | const githubUrl = await getParsedGitHubURL(); 153 | const [issues, versionName] = await Promise.all([ 154 | config.release.getMentionedIssues(), 155 | config.release.getNextVersion().then(name => `v${name}`), 156 | ]); 157 | const releaseURL = `${githubUrl.httpsURL}/releases/tag/${versionName}`; 158 | const comments: { issueNumber: number; commentBody: string }[] = []; 159 | 160 | for (const issue of issues) { 161 | const issueNumber = parseInt(issue, 10); 162 | 163 | if (!isNaN(issueNumber)) { 164 | comments.push({ 165 | issueNumber, 166 | 167 | commentBody: `:mailbox:   This issue was mentioned in release [${versionName}](${releaseURL})`, 168 | }); 169 | } 170 | } 171 | 172 | return new Commands.GithubCreateIssueCommentsCommand({ 173 | logger: config.logger, 174 | repoName: githubUrl.repoName, 175 | repoOwner: githubUrl.repoOwner, 176 | issueComments: comments, 177 | headers: { 178 | Authorization: `token ${config.githubPersonalAccessToken}`, 179 | }, 180 | }); 181 | }); 182 | 183 | strategy.addCommandProvider(async (config) => { 184 | const nextVersion = await config.release.getNextVersion(); 185 | 186 | return new Commands.NpmBumpPackageVersionCommand({ 187 | logger: config.logger, 188 | version: nextVersion, 189 | workingDirectory: config.workingDirectory, 190 | }); 191 | }); 192 | 193 | strategy.addCommandProvider(async (config) => { 194 | if (config.syncRemote) { 195 | const nextVersion = await config.release.getNextVersion(); 196 | const filePaths = ['package.json']; 197 | 198 | if (config.maintainChangelog) { 199 | filePaths.push(config.changelogFilePath); 200 | } 201 | 202 | return new Commands.GitCommitCommand({ 203 | actor: config.gitActor, 204 | logger: config.logger, 205 | workingDirectory: config.workingDirectory, 206 | commitMessage: `chore: release version ${nextVersion}`, 207 | filePaths: new Set(filePaths), 208 | }); 209 | } 210 | 211 | return null; 212 | }); 213 | 214 | strategy.addCommandProvider(async (config) => { 215 | if (config.syncRemote) { 216 | const branchName = await config.gitClient.refName('HEAD'); 217 | 218 | return new Commands.GitPushBranchCommand({ 219 | logger: config.logger, 220 | remote: config.gitRemote, 221 | branchName: branchName, 222 | workingDirectory: config.workingDirectory, 223 | failWhenRemoteBranchExists: false, 224 | }); 225 | } 226 | 227 | return null; 228 | }); 229 | 230 | strategy.addCommandProvider(async (config) => { 231 | const branchName = await config.gitClient.refName('HEAD'); 232 | const distTag = branchName === config.stableBranchName ? 'latest' : branchName; 233 | 234 | return new Commands.NpmPublishPackageCommand({ 235 | tag: distTag, 236 | logger: config.logger, 237 | workingDirectory: config.workingDirectory, 238 | }); 239 | }); 240 | 241 | return strategy; 242 | }; 243 | 244 | export { githubNpmPackageStrategy }; 245 | 246 | export type { GithubNpmPackageStrategyConfig }; 247 | -------------------------------------------------------------------------------- /src/sdk/index.ts: -------------------------------------------------------------------------------- 1 | export * from './command.js'; 2 | 3 | export * from './process-stdout-logger.js'; 4 | 5 | export * from './git-exec-client.js'; 6 | 7 | export * from './git-trunk-release.js'; 8 | export * from './git-tag-based-release.js'; 9 | 10 | export * from './strategy.js'; 11 | export * from './git-strategy.js'; 12 | export * from './github-npm-package-strategy.js'; 13 | 14 | export type * from './logger.js'; 15 | export type * from './release.js'; 16 | export type * from './git-client.js'; 17 | -------------------------------------------------------------------------------- /src/sdk/logger.d.ts: -------------------------------------------------------------------------------- 1 | interface Logger { 2 | info(message: string): void; 3 | 4 | warn(message: string): void; 5 | 6 | error(error: Error | string): void; 7 | 8 | debug(message: string): void; 9 | } 10 | 11 | export { Logger }; 12 | -------------------------------------------------------------------------------- /src/sdk/process-stdout-logger.ts: -------------------------------------------------------------------------------- 1 | import colors from 'colors'; 2 | 3 | import { Logger } from './logger.js'; 4 | 5 | type LogLevel = { 6 | name: string; 7 | priority: number; 8 | }; 9 | 10 | type LoggerOptions = { 11 | name: string; 12 | logLevel?: 'ERROR' | 'WARN' | 'INFO' | 'DEBUG'; 13 | }; 14 | 15 | const COLORED_LOGLEVEL_NAME = { 16 | INFO: colors.blue('INFO'), 17 | WARN: colors.yellow('WARN'), 18 | ERROR: colors.red('ERROR'), 19 | DEBUG: colors.magenta('DEBUG'), 20 | }; 21 | 22 | const LOG_LEVELS: LogLevel[] = [ 23 | { name: 'ERROR', priority: 0 }, 24 | { name: 'WARN', priority: 1 }, 25 | { name: 'INFO', priority: 2 }, 26 | { name: 'DEBUG', priority: 3 }, 27 | ]; 28 | 29 | const getLogLevel = (logLevelName?: string): LogLevel => { 30 | const name = logLevelName ?? process.env.ATOMIC_RELEASE_LOG_LEVEL ?? 'INFO'; 31 | const logLevel = LOG_LEVELS.find((level) => { 32 | return level.name === name.toUpperCase(); 33 | }); 34 | 35 | if (!logLevel) { 36 | throw new Error(`Unknown log level '${name}'`); 37 | } 38 | 39 | return logLevel; 40 | }; 41 | 42 | const messagePrefix = (name: string, logLevelName: string) => { 43 | const date = new Date(); 44 | const hours = `0${date.getHours()}`.slice(-2); 45 | const minutes = `0${date.getMinutes()}`.slice(-2); 46 | const seconds = `0${date.getSeconds()}`.slice(-2); 47 | const timestamp = `${hours}:${minutes}:${seconds}`; 48 | 49 | return colors.gray(`[${timestamp}] [atomic-release] [${name}] ${logLevelName} ›`); 50 | }; 51 | 52 | const stdout = (message: string) => { 53 | return process.stdout.write(`${message}\n`); 54 | }; 55 | 56 | const processStdoutLogger = (options: LoggerOptions): Logger => { 57 | const name = options.name; 58 | const logLevel = getLogLevel(options.logLevel); 59 | 60 | return { 61 | error(error) { 62 | if (logLevel.priority >= 0) { 63 | const isError = error instanceof Error; 64 | const message = `${messagePrefix(name, COLORED_LOGLEVEL_NAME.ERROR)} ${isError ? error.message : error}`; 65 | 66 | stdout(message); 67 | 68 | if (isError && error.stack) { 69 | stdout(error.stack); 70 | } 71 | } 72 | }, 73 | 74 | warn(message) { 75 | if (logLevel.priority >= 1) { 76 | const formattedMessage = `${messagePrefix(name, COLORED_LOGLEVEL_NAME.WARN)} ${message}`; 77 | 78 | stdout(formattedMessage); 79 | } 80 | }, 81 | 82 | info(message) { 83 | if (logLevel.priority >= 2) { 84 | const formattedMessage = `${messagePrefix(name, COLORED_LOGLEVEL_NAME.INFO)} ${message}`; 85 | 86 | stdout(formattedMessage); 87 | } 88 | }, 89 | 90 | debug(message) { 91 | if (logLevel.priority >= 3) { 92 | const formattedMessage = `${messagePrefix(name, COLORED_LOGLEVEL_NAME.DEBUG)} ${message}`; 93 | 94 | stdout(formattedMessage); 95 | } 96 | }, 97 | }; 98 | }; 99 | 100 | export { processStdoutLogger }; 101 | -------------------------------------------------------------------------------- /src/sdk/release.d.ts: -------------------------------------------------------------------------------- 1 | interface Release { 2 | listVersions(max: number): Promise; 3 | 4 | getChangelog(): Promise; 5 | 6 | getNextVersion(): Promise; 7 | 8 | getPreviousVersion(): Promise; 9 | 10 | getMentionedIssues(): Promise>; 11 | 12 | getChangelogByVersion(version: string): Promise; 13 | } 14 | 15 | export { Release }; 16 | -------------------------------------------------------------------------------- /src/sdk/strategy.ts: -------------------------------------------------------------------------------- 1 | import { to } from 'await-to-js'; 2 | 3 | import { Logger } from './logger.js'; 4 | import { Release } from './release.js'; 5 | import { Command } from './command.js'; 6 | 7 | import { timer } from '../utils/timer.js'; 8 | 9 | type StrategyConfig = { 10 | logger: Logger; 11 | release: Release; 12 | }; 13 | 14 | type CommandProvider = (config: T) => Promise; 15 | 16 | type CommandsCollection = Command[]; 17 | 18 | class Strategy { 19 | protected readonly config: T; 20 | protected readonly commandProviders: CommandProvider[]; 21 | 22 | public constructor(config: T) { 23 | this.config = config; 24 | this.commandProviders = []; 25 | } 26 | 27 | private async executeCommands(commands: CommandsCollection): Promise { 28 | const logger = this.config.logger; 29 | 30 | for (let i = 0, l = commands.length; i < l; i += 1) { 31 | const command = commands[i]; 32 | const commandName = command.getName(); 33 | const executeTimer = timer(); 34 | 35 | logger.debug(`Executing command '${commandName}'`); 36 | 37 | const [error] = await to(command.do()); 38 | 39 | logger.debug(`Executing command '${commandName}' completed in ~${executeTimer}`); 40 | 41 | if (error) { 42 | logger.error(`Command '${commandName}.do' execution failed`); 43 | 44 | while (i !== -1) { 45 | const command = commands[i]; 46 | 47 | if (command) { 48 | const [error] = await to(command.undo()); 49 | 50 | if (error) { 51 | logger.error(`An error occurred while undoing command '${command.getName()}'`); 52 | 53 | logger.error(error); 54 | } 55 | } 56 | 57 | i -= 1; 58 | } 59 | 60 | throw error; 61 | } 62 | } 63 | } 64 | 65 | protected async shouldRun(): Promise { 66 | const [nextVersion, prevVersion] = await Promise.all([ 67 | this.config.release.getNextVersion(), 68 | this.config.release.getPreviousVersion(), 69 | ]); 70 | 71 | this.config.logger.info(`Next version is ${nextVersion}`); 72 | 73 | this.config.logger.info(`Previous version is ${prevVersion}`); 74 | 75 | if (nextVersion === prevVersion) { 76 | this.config.logger.info('No version change detected'); 77 | 78 | return false; 79 | } 80 | 81 | return true; 82 | } 83 | 84 | protected async getCommands(): Promise { 85 | const commands: (Command | null)[] = []; 86 | 87 | await Promise.all(this.commandProviders.map(async (p, i) => { 88 | commands[i] = await p(this.config); 89 | })); 90 | 91 | return commands.filter(Boolean) as Command[]; 92 | } 93 | 94 | public addCommandProvider(provider: CommandProvider) { 95 | this.commandProviders.push(provider); 96 | 97 | return this; 98 | } 99 | 100 | public async run(): Promise { 101 | const logger = this.config.logger; 102 | 103 | try { 104 | const shouldRun = await this.shouldRun(); 105 | 106 | if (shouldRun) { 107 | const commands = await this.getCommands(); 108 | 109 | logger.info(`Executing ${commands.length} command(s)`); 110 | 111 | if (commands.length) { 112 | const executionTimer = timer(); 113 | 114 | await this.executeCommands(commands); 115 | 116 | logger.info('Cleaning up...'); 117 | 118 | await Promise.all(commands.map(async (command) => { 119 | try { 120 | if (command) { 121 | await command.cleanup(); 122 | } 123 | } 124 | catch (e) { 125 | logger.warn(e); 126 | } 127 | })); 128 | 129 | logger.info(`Execution completed in ~${executionTimer}`); 130 | } 131 | else { 132 | logger.warn('Strategy has no commands'); 133 | } 134 | } 135 | 136 | logger.info('All done'); 137 | } 138 | catch (e) { 139 | process.exitCode = 1; 140 | 141 | logger.error('Strategy execution failed'); 142 | 143 | throw e; 144 | } 145 | } 146 | } 147 | 148 | export { Strategy, StrategyConfig }; 149 | -------------------------------------------------------------------------------- /src/utils/exec.ts: -------------------------------------------------------------------------------- 1 | import { text } from 'node:stream/consumers'; 2 | import { ChildProcess, spawn as nodeSpawn, SpawnOptionsWithoutStdio } from 'node:child_process'; 3 | 4 | type ExecCommand = string | { command: string; args: string[] }; 5 | 6 | type ExecResult = { 7 | stdout: string; 8 | stderr: string; 9 | childProcess: ChildProcess; 10 | }; 11 | 12 | const extractCommandAndArgs = (cmd: ExecCommand) => { 13 | if (typeof cmd === 'string') { 14 | const [command, ...args] = cmd.split(' '); 15 | 16 | return { 17 | command, 18 | args, 19 | }; 20 | } 21 | 22 | return cmd; 23 | }; 24 | 25 | const exec = (cmd: ExecCommand, options: SpawnOptionsWithoutStdio): Promise => { 26 | return new Promise((resolve, reject) => { 27 | try { 28 | const { command, args } = extractCommandAndArgs(cmd); 29 | const childProcess = nodeSpawn(command, args, options); 30 | const streams = Promise.all([ 31 | text(childProcess.stdout), 32 | text(childProcess.stderr), 33 | ]); 34 | 35 | childProcess.on('error', reject); 36 | 37 | childProcess.on('close', (_code) => { 38 | streams 39 | .then(([stdout, stderr]) => { 40 | return { 41 | stdout: stdout.trimEnd(), 42 | stderr: stderr.trimEnd(), 43 | childProcess, 44 | }; 45 | }) 46 | .then(resolve) 47 | .catch(reject); 48 | }); 49 | } 50 | catch (err) { 51 | reject(err); 52 | } 53 | }); 54 | }; 55 | 56 | export { exec }; 57 | 58 | export type { ExecCommand, SpawnOptionsWithoutStdio as ExecOptions, ExecResult }; 59 | -------------------------------------------------------------------------------- /src/utils/timer.ts: -------------------------------------------------------------------------------- 1 | import prettyMs from 'pretty-ms'; 2 | 3 | type Timer = { 4 | toString: () => string; 5 | elapsedMs: () => number; 6 | }; 7 | 8 | const timer = (): Timer => { 9 | const start = process.hrtime(); 10 | 11 | const elapsedMs = (): number => { 12 | const end = process.hrtime(start); 13 | 14 | return (end[0] * 1000000000 + start[1]) / 1000000; 15 | }; 16 | 17 | const toString = (): string => { 18 | return prettyMs(elapsedMs()); 19 | }; 20 | 21 | return { 22 | elapsedMs, 23 | toString, 24 | }; 25 | }; 26 | 27 | export { timer, Timer }; 28 | -------------------------------------------------------------------------------- /tests/integration/__snapshots__/git-tag-based-release.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`git tag based release > previous release change log is generated 1`] = ` 4 | "## 0.1.0-beta.0 (2021-10-08) 5 | 6 | ### Features 7 | 8 | * ... ([c658ea3](https://github.com/t/t/commit/c658ea3e060490dced90dfb34c018d88b8e797f9)) 9 | " 10 | `; 11 | 12 | exports[`git tag based release > release change log is generated 1`] = ` 13 | "## 0.1.0-beta.1 (2021-10-08) 14 | 15 | ### Features 16 | 17 | * ... ([c658ea3](https://github.com/t/t/commit/c658ea3e060490dced90dfb34c018d88b8e797f9)) 18 | " 19 | `; 20 | -------------------------------------------------------------------------------- /tests/integration/__snapshots__/git-trunk-release.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`git trunk release > generating a changelog 1`] = ` 4 | "## c658ea3 (2021-10-08) 5 | 6 | ### Features 7 | 8 | * ... ([c658ea3](https://github.com/t/t/commit/c658ea3e060490dced90dfb34c018d88b8e797f9)) 9 | " 10 | `; 11 | 12 | exports[`git trunk release > previous version changelog is generated 1`] = ` 13 | "## 10f0340 (2021-10-08) 14 | 15 | ### Features 16 | 17 | * ... ([c658ea3](https://github.com/t/t/commit/c658ea3e060490dced90dfb34c018d88b8e797f9)) 18 | " 19 | `; 20 | -------------------------------------------------------------------------------- /tests/integration/__snapshots__/github-npm-package-strategy.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`github npm package strategy > commands state 1`] = ` 4 | [ 5 | GitTagCommand { 6 | "config": { 7 | "logger": NoopLogger { 8 | "debug": [Function], 9 | "error": [Function], 10 | "info": [Function], 11 | "warn": [Function], 12 | }, 13 | "name": "v1.0.0", 14 | "remote": "origin-test", 15 | "workingDirectory": "/this/is/sparta", 16 | }, 17 | "createChildProcess": [Function], 18 | "localTagCreated": false, 19 | "logger": NoopLogger { 20 | "debug": [Function], 21 | "error": [Function], 22 | "info": [Function], 23 | "warn": [Function], 24 | }, 25 | "remote": "origin-test", 26 | "remoteTagCreated": false, 27 | "tagRef": "refs/tags/v1.0.0", 28 | }, 29 | GithubCreateReleaseCommand { 30 | "_fetch": [Function], 31 | "config": { 32 | "body": "## 1.0.0 (2024-08-05) 33 | 34 | ### ⚠ BREAKING CHANGES 35 | 36 | * ... 37 | 38 | ### Features 39 | 40 | * ... ([c658ea3](https://github.com/owner/repo_name/commit/c658ea3e060490dced90dfb34c018d88b8e797f9)) 41 | ", 42 | "headers": { 43 | "Accept": "application/vnd.github.v3+json", 44 | "Authorization": "token github-personal-access-token", 45 | }, 46 | "isStable": false, 47 | "logger": NoopLogger { 48 | "debug": [Function], 49 | "error": [Function], 50 | "info": [Function], 51 | "warn": [Function], 52 | }, 53 | "name": "v1.0.0", 54 | "owner": "abstracter-io", 55 | "repo": "atomic-release", 56 | "tagName": "v1.0.0", 57 | }, 58 | "createdRelease": undefined, 59 | "logger": NoopLogger { 60 | "debug": [Function], 61 | "error": [Function], 62 | "info": [Function], 63 | "warn": [Function], 64 | }, 65 | }, 66 | GithubCreateIssueCommentsCommand { 67 | "_fetch": [Function], 68 | "config": { 69 | "headers": { 70 | "Accept": "application/vnd.github.v3+json", 71 | "Authorization": "token github-personal-access-token", 72 | }, 73 | "issueComments": [], 74 | "logger": NoopLogger { 75 | "debug": [Function], 76 | "error": [Function], 77 | "info": [Function], 78 | "warn": [Function], 79 | }, 80 | "repoName": "atomic-release", 81 | "repoOwner": "abstracter-io", 82 | }, 83 | "createdCommentsResources": [], 84 | "logger": NoopLogger { 85 | "debug": [Function], 86 | "error": [Function], 87 | "info": [Function], 88 | "warn": [Function], 89 | }, 90 | }, 91 | NpmBumpPackageVersionCommand { 92 | "config": { 93 | "logger": NoopLogger { 94 | "debug": [Function], 95 | "error": [Function], 96 | "info": [Function], 97 | "warn": [Function], 98 | }, 99 | "version": "1.0.0", 100 | "workingDirectory": "/this/is/sparta", 101 | }, 102 | "createChildProcess": [Function], 103 | "initialVersion": undefined, 104 | "logger": NoopLogger { 105 | "debug": [Function], 106 | "error": [Function], 107 | "info": [Function], 108 | "warn": [Function], 109 | }, 110 | "packageJsonFilePath": "/this/is/sparta/package.json", 111 | "versionChanged": undefined, 112 | }, 113 | NpmPublishPackageCommand { 114 | "config": { 115 | "logger": NoopLogger { 116 | "debug": [Function], 117 | "error": [Function], 118 | "info": [Function], 119 | "warn": [Function], 120 | }, 121 | "tag": "beta", 122 | "workingDirectory": "/this/is/sparta", 123 | }, 124 | "createChildProcess": [Function], 125 | "logger": NoopLogger { 126 | "debug": [Function], 127 | "error": [Function], 128 | "info": [Function], 129 | "warn": [Function], 130 | }, 131 | "packageJsonFilePath": "/this/is/sparta/package.json", 132 | "publishedPackage": undefined, 133 | }, 134 | ] 135 | `; 136 | -------------------------------------------------------------------------------- /tests/integration/git-trunk-release.test.ts: -------------------------------------------------------------------------------- 1 | import { vitest, describe, test, expect } from 'vitest'; 2 | 3 | import { SDK } from '../../src/index.js'; 4 | import { Stubs } from '../stubs.js'; 5 | 6 | const releaseOptions = () => { 7 | return { 8 | logger: Stubs.NoopLogger.INSTANCE, 9 | gitClient: new Stubs.GitClientStub(), 10 | conventionalChangelogWriterContext: { 11 | owner: 't', 12 | repository: 't', 13 | host: 'https://github.com', 14 | repoUrl: 'https://github.com/t/t', 15 | date: '2021-10-08', 16 | }, 17 | }; 18 | }; 19 | 20 | describe('git trunk release', () => { 21 | test('changelog is null', async () => { 22 | const changelogCommitFilter = vitest.fn(); 23 | const gitClient = new Stubs.GitClientStub(); 24 | const release = await SDK.gitTrunkRelease({ 25 | ...releaseOptions(), 26 | gitClient, 27 | changelogCommitFilter, 28 | }); 29 | 30 | await expect(release.getChangelog()).resolves.toBeNull(); 31 | 32 | expect(changelogCommitFilter).toBeCalledTimes(1); 33 | }); 34 | 35 | test.todo('list previous versions'); 36 | 37 | test('generating a changelog', async () => { 38 | const rawConventionalCommits = vitest.fn(async (range: string) => { 39 | const commits = await gitClient.commits(range); 40 | 41 | return commits.map((commit) => { 42 | const lines = [ 43 | // subject 44 | `${commit.subject}`, 45 | 46 | // body 47 | `${commit.body}`, 48 | 49 | // extra fields 50 | '-hash-', 51 | `${commit.hash}`, 52 | 53 | '-gitTags-', 54 | `${commit.tags.join(',')}`, 55 | 56 | '-committerDate-', 57 | `${new Date(1633686020134)}`, 58 | ]; 59 | 60 | return { 61 | hash: commit.hash, 62 | raw: lines.join('\n'), 63 | }; 64 | }); 65 | }); 66 | const gitClient = new Stubs.GitClientStub(); 67 | const release = await SDK.gitTrunkRelease({ 68 | ...releaseOptions(), 69 | 70 | gitClient, 71 | 72 | rawConventionalCommits, 73 | }); 74 | const changelog = await release.getChangelog(); 75 | 76 | expect(changelog).toMatchSnapshot(); 77 | 78 | expect(rawConventionalCommits).toBeCalledWith(`${Stubs.GitClientStub.HASH.slice(0, 7)} -1`); 79 | }); 80 | 81 | test('next version is \'HEAD\' hash', async () => { 82 | const release = await SDK.gitTrunkRelease(releaseOptions()); 83 | const expectedHash = Stubs.GitClientStub.HASH.slice(0, 7); 84 | 85 | await expect(release.getNextVersion()).resolves.toStrictEqual(expectedHash); 86 | }); 87 | 88 | test('list issues mentioned in commits', async () => { 89 | const gitClient = new Stubs.GitClientStub(); 90 | const release = await SDK.gitTrunkRelease({ 91 | ...releaseOptions(), 92 | gitClient, 93 | }); 94 | 95 | gitClient.commits.mockImplementation(async () => { 96 | return [ 97 | { 98 | ...Stubs.conventionalCommit(), 99 | subject: 'feat!: ... closes #3, #46, #39', 100 | }, 101 | ]; 102 | }); 103 | 104 | await expect(release.getMentionedIssues()).resolves.toStrictEqual(new Set(['3', '39', '46'])); 105 | }); 106 | 107 | test('previous version changelog is null', async () => { 108 | const hash = '10f03409c73ffa37fbd2b890d99c74c63d0f9f03'; 109 | const changelogCommitFilter = vitest.fn(() => { 110 | return false; 111 | }); 112 | const gitClient = new Stubs.GitClientStub(); 113 | const release = await SDK.gitTrunkRelease({ 114 | ...releaseOptions(), 115 | 116 | gitClient, 117 | 118 | changelogCommitFilter, 119 | }); 120 | 121 | gitClient.commits.mockImplementation(async () => { 122 | return [ 123 | Stubs.conventionalCommit(), 124 | { ...Stubs.conventionalCommit(), hash }, 125 | ]; 126 | }); 127 | 128 | expect(await release.getChangelogByVersion(hash.slice(0, 7))).toBeNull(); 129 | 130 | expect(changelogCommitFilter).toBeCalledTimes(1); 131 | }); 132 | 133 | test('previous version changelog is generated', async () => { 134 | const hash = '10f03409c73ffa37fbd2b890d99c74c63d0f9f03'; 135 | const gitClient = new Stubs.GitClientStub(); 136 | const release = await SDK.gitTrunkRelease({ 137 | ...releaseOptions(), 138 | gitClient, 139 | }); 140 | 141 | gitClient.commits.mockImplementation(async () => { 142 | return [ 143 | Stubs.conventionalCommit(), 144 | { ...Stubs.conventionalCommit(), hash }, 145 | ]; 146 | }); 147 | 148 | return release.getChangelogByVersion(hash.slice(0, 7)).then((changelog) => { 149 | expect(changelog).not.toBeNull(); 150 | 151 | expect(changelog).toMatchSnapshot(); 152 | 153 | return; 154 | }); 155 | }); 156 | 157 | test('previous version fallbacks to \'HEAD\' hash', async () => { 158 | const hash = Stubs.GitClientStub.HASH.slice(0, 7); 159 | const logger = new Stubs.LoggerStub(); 160 | const release = await SDK.gitTrunkRelease({ 161 | ...releaseOptions(), 162 | logger, 163 | }); 164 | 165 | await expect(release.getPreviousVersion()).resolves.toStrictEqual(hash); 166 | 167 | expect(logger.info).toBeCalledWith(`Could not find a previous version. Will use HEAD hash ${hash} as initial version`); 168 | }); 169 | 170 | test('previous version is the second commit hash', async () => { 171 | const gitClient = new Stubs.GitClientStub(); 172 | const release = await SDK.gitTrunkRelease({ 173 | ...releaseOptions(), 174 | gitClient, 175 | }); 176 | const expectedVersion = Stubs.GitClientStub.HASH.slice(0, 7); 177 | 178 | gitClient.commits.mockImplementation(async () => { 179 | return [ 180 | { ...Stubs.conventionalCommit(), hash: '1234' }, 181 | 182 | { ...Stubs.conventionalCommit(), hash: Stubs.GitClientStub.HASH }, 183 | ]; 184 | }); 185 | 186 | expect(await release.getPreviousVersion()).toStrictEqual(expectedVersion); 187 | }); 188 | 189 | test('generating changelog fails when writer context is missing', async () => { 190 | const release = await SDK.gitTrunkRelease({ 191 | ...releaseOptions(), 192 | conventionalChangelogWriterContext: null as never, 193 | }); 194 | const expectedError = new Error('conventional changelog writer context is missing'); 195 | 196 | await expect(release.getChangelog()).rejects.toStrictEqual(expectedError); 197 | }); 198 | 199 | test('generating version changelog returns null when version does not exists', async () => { 200 | const rawConventionalCommits = vitest.fn(async () => []); 201 | const gitClient = new Stubs.GitClientStub(); 202 | const release = await SDK.gitTrunkRelease({ 203 | ...releaseOptions(), 204 | 205 | gitClient, 206 | 207 | rawConventionalCommits, 208 | }); 209 | 210 | await expect(release.getChangelogByVersion('xyz')).resolves.toStrictEqual(null); 211 | }); 212 | }); 213 | -------------------------------------------------------------------------------- /tests/integration/github-npm-package-strategy.test.ts: -------------------------------------------------------------------------------- 1 | import { vitest, describe, test, expect } from 'vitest'; 2 | 3 | import { Stubs } from '../stubs.js'; 4 | import { SDK, Commands } from '../../src/index.js'; 5 | 6 | const WORKING_DIRECTORY = '/this/is/sparta'; 7 | 8 | const releaseConfig = (): SDK.GitTagBasedReleaseConfig => { 9 | return { 10 | gitClient: new Stubs.GitClientStub(), 11 | logger: Stubs.NoopLogger.INSTANCE, 12 | preReleaseBranches: new Set([Stubs.GitClientStub.PRE_RELEASE_BRANCH_NAME]), 13 | conventionalChangelogWriterContext: { 14 | owner: 'owner', 15 | repository: 'repo_name', 16 | host: 'https://github.com', 17 | repoUrl: 'https://github.com/owner/repo_name', 18 | }, 19 | }; 20 | }; 21 | 22 | const strategyConfig = (): SDK.GithubNpmPackageStrategyConfig => { 23 | return { 24 | gitClient: new Stubs.GitClientStub(), 25 | logger: Stubs.NoopLogger.INSTANCE, 26 | gitActor: 'Rick Sanchez ', 27 | gitRemote: 'origin-test', 28 | workingDirectory: WORKING_DIRECTORY, 29 | changelogFilePath: `${WORKING_DIRECTORY}/CHANGELOG.md`, 30 | releaseBranchNames: new Set([ 31 | Stubs.GitClientStub.STABLE_BRANCH_NAME, 32 | Stubs.GitClientStub.PRE_RELEASE_BRANCH_NAME, 33 | ]), 34 | githubPersonalAccessToken: 'github-personal-access-token', 35 | }; 36 | }; 37 | 38 | describe('github npm package strategy', () => { 39 | test('commands state', async () => { 40 | const gitClient = new Stubs.GitClientStub(); 41 | const release = await SDK.gitTagBasedRelease({ 42 | ...releaseConfig(), 43 | gitClient, 44 | }); 45 | const strategy = await SDK.githubNpmPackageStrategy({ 46 | ...strategyConfig(), 47 | release, 48 | gitClient, 49 | releaseBranchNames: new Set(['test']), 50 | }); 51 | 52 | gitClient.refName.mockImplementationOnce(async () => { 53 | return Stubs.GitClientStub.STABLE_BRANCH_NAME; 54 | }); 55 | 56 | gitClient.listTags.mockImplementationOnce(async () => { 57 | return [{ 58 | name: 'v0.0.0', 59 | hash: Stubs.GitClientStub.HASH, 60 | }]; 61 | }); 62 | 63 | gitClient.commits.mockImplementationOnce(async () => { 64 | return [{ 65 | ...Stubs.conventionalCommit(), 66 | subject: 'feat!: ...', 67 | }]; 68 | }); 69 | 70 | await Stubs.fixedDate(new Date('2024-08-05'), async () => { 71 | // @ts-expect-error protected method 72 | await expect(strategy.getCommands()).resolves.toMatchSnapshot(); 73 | }); 74 | }); 75 | 76 | test('command errored', async () => { 77 | const logger = new Stubs.LoggerStub(); 78 | const release = await SDK.gitTagBasedRelease(releaseConfig()); 79 | const strategy = await SDK.githubNpmPackageStrategy({ 80 | ...strategyConfig(), 81 | logger, 82 | release, 83 | }); 84 | const expectedError = new Error('Error'); 85 | 86 | // @ts-expect-error protected method can be spied 87 | vitest.spyOn(strategy, 'getCommands').mockImplementationOnce(() => { 88 | return [ 89 | { 90 | getName() { 91 | return 'Dummy Command'; 92 | }, 93 | 94 | do() { 95 | return Promise.reject(expectedError); 96 | }, 97 | 98 | undo() { 99 | return Promise.resolve(); 100 | }, 101 | }, 102 | ]; 103 | }); 104 | 105 | await expect(strategy.run()).rejects.toStrictEqual(expectedError); 106 | 107 | expect(process.exitCode).toStrictEqual(1); 108 | 109 | expect(logger.error).toBeCalledWith('Strategy execution failed'); 110 | }); 111 | 112 | test('does not run when version has not changed', async () => { 113 | const gitClient = new Stubs.GitClientStub(); 114 | const release = await SDK.gitTagBasedRelease({ 115 | ...releaseConfig(), 116 | gitClient, 117 | }); 118 | const strategy = await SDK.githubNpmPackageStrategy({ 119 | ...strategyConfig(), 120 | release, 121 | gitClient, 122 | }); 123 | 124 | // @ts-expect-error protected method can be spied 125 | const shouldRunSpy = vitest.spyOn(strategy, 'shouldRun'); 126 | 127 | gitClient.commits.mockImplementation(async () => { 128 | return []; 129 | }); 130 | 131 | await strategy.run(); 132 | 133 | expect(shouldRunSpy).lastReturnedWith(Promise.resolve(true)); 134 | }); 135 | 136 | test('does not run when branch is not a release branch', async () => { 137 | const logger = new Stubs.LoggerStub(); 138 | const gitClient = new Stubs.GitClientStub(); 139 | const release = await SDK.gitTagBasedRelease(releaseConfig()); 140 | const strategy = await SDK.githubNpmPackageStrategy({ 141 | ...strategyConfig(), 142 | release, 143 | logger, 144 | gitClient, 145 | releaseBranchNames: new Set(['test']), 146 | }); 147 | 148 | // @ts-expect-error protected method can be spied 149 | const shouldRunSpy = vitest.spyOn(strategy, 'shouldRun'); 150 | 151 | await strategy.run(); 152 | 153 | expect(shouldRunSpy).lastReturnedWith(Promise.resolve(false)); 154 | 155 | expect(logger.info).toBeCalledWith(`Branch '${Stubs.GitClientStub.PRE_RELEASE_BRANCH_NAME}' is not a release branch`); 156 | }); 157 | 158 | test('does not run when remote/local branch hash differ', async () => { 159 | const logger = new Stubs.LoggerStub(); 160 | const gitClient = new Stubs.GitClientStub(); 161 | const release = await SDK.gitTagBasedRelease(releaseConfig()); 162 | const strategy = await SDK.githubNpmPackageStrategy({ 163 | ...strategyConfig(), 164 | release, 165 | logger, 166 | gitClient, 167 | }); 168 | 169 | // @ts-expect-error protected method can be spied 170 | const getCommandsSpy = vitest.spyOn(strategy, 'getCommands'); 171 | 172 | gitClient.remoteBranchHash.mockImplementation(async () => { 173 | return '1'; 174 | }); 175 | 176 | await strategy.run(); 177 | 178 | expect(getCommandsSpy).not.toBeCalled(); 179 | 180 | expect(logger.info).toBeCalledWith('Branch local hash is not the same as its remote counterpart'); 181 | }); 182 | 183 | test('changelog writer command is null when changelog is empty', async () => { 184 | const logger = new Stubs.LoggerStub(); 185 | const gitClient = new Stubs.GitClientStub(); 186 | const release = await SDK.gitTagBasedRelease(releaseConfig()); 187 | const strategy = await SDK.githubNpmPackageStrategy({ 188 | ...strategyConfig(), 189 | logger, 190 | release, 191 | syncRemote: true, 192 | releaseBranchNames: new Set(['test']), 193 | gitClient, 194 | }); 195 | 196 | vitest.spyOn(release, 'getChangelog').mockImplementationOnce(async () => { 197 | return null; 198 | }); 199 | 200 | vitest.spyOn(release, 'getChangelogByVersion').mockImplementationOnce(async () => { 201 | return null; 202 | }); 203 | 204 | // @ts-expect-error protected method can be spied 205 | const commands = await strategy.getCommands(); 206 | 207 | // @ts-expect-error protected method can be spied 208 | vitest.spyOn(strategy, 'getCommands').mockReturnValueOnce(commands); 209 | 210 | for (const command of commands) { 211 | vitest.spyOn(command, 'do').mockResolvedValue(undefined); 212 | } 213 | 214 | await strategy.run(); 215 | 216 | expect(commands[0]?.constructor).not.toStrictEqual(Commands.FileWriterCommand); 217 | }); 218 | }); 219 | -------------------------------------------------------------------------------- /tests/stubs.ts: -------------------------------------------------------------------------------- 1 | import { vitest } from 'vitest'; 2 | import { ChildProcess } from 'node:child_process'; 3 | 4 | import { SDK } from '../src/index.js'; 5 | 6 | const conventionalCommit = () => { 7 | return { 8 | subject: 'feat: ...', 9 | body: 'test', 10 | notes: '', 11 | author: { 12 | name: '', 13 | email: '', 14 | }, 15 | committer: { 16 | name: '', 17 | email: '', 18 | }, 19 | tags: [], 20 | hash: Stubs.GitClientStub.HASH, 21 | committedTimestamp: 1633686020134, 22 | }; 23 | }; 24 | 25 | type StrategyStubConfig = SDK.StrategyConfig & { 26 | test: number; 27 | }; 28 | 29 | class CommandA extends SDK.Command { 30 | do(): Promise { 31 | return Promise.resolve(); 32 | } 33 | } 34 | 35 | class CommandB extends SDK.Command { 36 | do(): Promise { 37 | return Promise.resolve(); 38 | } 39 | } 40 | 41 | class LoggerStub implements SDK.Logger { 42 | info = vitest.fn(); 43 | warn = vitest.fn(); 44 | error = vitest.fn(); 45 | debug = vitest.fn(); 46 | } 47 | 48 | class NoopLogger implements SDK.Logger { 49 | public static INSTANCE = new NoopLogger(); 50 | 51 | info = () => {}; 52 | warn = () => {}; 53 | error = () => {}; 54 | debug = () => {}; 55 | } 56 | 57 | class ReleaseStub implements SDK.Release { 58 | listVersions = vitest.fn(); 59 | getChangelog = vitest.fn(); 60 | getNextVersion = vitest.fn(); 61 | getMentionedIssues = vitest.fn(); 62 | getPreviousVersion = vitest.fn(); 63 | getChangelogByVersion = vitest.fn(); 64 | } 65 | 66 | class StrategyStub extends SDK.Strategy { 67 | public constructor(config: StrategyStubConfig) { 68 | super(config); 69 | } 70 | } 71 | 72 | class GitClientStub extends SDK.GitExecClient { 73 | public static readonly HASH = 'c658ea3e060490dced90dfb34c018d88b8e797f9'; 74 | public static readonly STABLE_BRANCH_NAME = 'main'; 75 | public static readonly PRE_RELEASE_BRANCH_NAME = 'beta'; 76 | 77 | constructor() { 78 | super({ workingDirectory: process.cwd() }); 79 | } 80 | 81 | refHash = vitest.fn(async () => { 82 | return GitClientStub.HASH; 83 | }); 84 | 85 | refName = vitest.fn(async () => { 86 | return GitClientStub.PRE_RELEASE_BRANCH_NAME; 87 | }); 88 | 89 | commits = vitest.fn(async (..._args: any[]) => { 90 | return [conventionalCommit()]; 91 | }); 92 | 93 | listTags = vitest.fn(async () => { 94 | const name = 'v0.1.0-beta.0'; 95 | const hash = GitClientStub.HASH; 96 | 97 | return [{ name, hash }]; 98 | }); 99 | 100 | cliVersion = vitest.fn(async () => { 101 | return '2.7.0'; 102 | }); 103 | 104 | remoteTagHash = vitest.fn(async () => { 105 | return null; 106 | }); 107 | 108 | remoteBranchHash = vitest.fn(async () => { 109 | return GitClientStub.HASH; 110 | }); 111 | } 112 | 113 | export const Stubs = { 114 | CommandA, 115 | CommandB, 116 | LoggerStub, 117 | NoopLogger, 118 | ReleaseStub, 119 | StrategyStub, 120 | GitClientStub, 121 | 122 | childProcess() { 123 | const childProcess = new ChildProcess(); 124 | 125 | // @ts-expect-error override a process exit code 126 | childProcess.exitCode = 0; 127 | 128 | return childProcess; 129 | }, 130 | 131 | conventionalCommit, 132 | 133 | async fixedDate(date: Date, cb: (now: Date) => T) { 134 | vitest.useFakeTimers({ now: date }); 135 | 136 | return Promise.resolve(cb(date)).finally(() => { 137 | vitest.useRealTimers(); 138 | }); 139 | }, 140 | }; 141 | -------------------------------------------------------------------------------- /tests/unit/__snapshots__/strategy.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`strategy > config state 1`] = ` 4 | StrategyStub { 5 | "commandProviders": [], 6 | "config": { 7 | "logger": LoggerStub { 8 | "debug": [MockFunction spy], 9 | "error": [MockFunction spy], 10 | "info": [MockFunction spy], 11 | "warn": [MockFunction spy], 12 | }, 13 | "release": ReleaseStub { 14 | "getChangelog": [MockFunction spy], 15 | "getChangelogByVersion": [MockFunction spy], 16 | "getMentionedIssues": [MockFunction spy], 17 | "getNextVersion": [MockFunction spy], 18 | "getPreviousVersion": [MockFunction spy], 19 | "listVersions": [MockFunction spy], 20 | }, 21 | "test": 1, 22 | }, 23 | } 24 | `; 25 | -------------------------------------------------------------------------------- /tests/unit/exec-command.test.ts: -------------------------------------------------------------------------------- 1 | import * as process from 'node:process'; 2 | import { vitest, describe, test, expect } from 'vitest'; 3 | 4 | import { Stubs } from '../stubs.js'; 5 | import { Commands } from '../../src/index.js'; 6 | 7 | const CMD_CONFIG: Commands.ExecCommandConfig = { 8 | logger: new Stubs.NoopLogger(), 9 | logStd: false, 10 | workingDirectory: '/this/is/sparta', 11 | }; 12 | 13 | class ExecCommandStub extends Commands.ExecCommand { 14 | constructor(config?: Partial) { 15 | super({ ...CMD_CONFIG, ...config }); 16 | } 17 | 18 | createChildProcess = vitest.fn(async (__args: any) => { 19 | return { 20 | stdout: '', 21 | stderr: '', 22 | childProcess: Stubs.childProcess(), 23 | }; 24 | }); 25 | 26 | async do() { 27 | await this.exec('cat', { 28 | cwd: this.config.workingDirectory, 29 | }); 30 | }; 31 | } 32 | 33 | describe('exec command', () => { 34 | test('log std', async () => { 35 | const expected = { 36 | errorMessage: 'error', 37 | infoMessage: 'info', 38 | }; 39 | const logger = new Stubs.LoggerStub(); 40 | const commandStub = new ExecCommandStub({ logger, logStd: true }); 41 | 42 | commandStub.createChildProcess.mockImplementationOnce(async () => { 43 | return { 44 | stderr: expected.errorMessage, 45 | stdout: expected.infoMessage, 46 | childProcess: Stubs.childProcess(), 47 | }; 48 | }); 49 | 50 | await commandStub.do(); 51 | 52 | expect(logger.info).toBeCalledWith(expected.infoMessage); 53 | 54 | expect(logger.error).toBeCalledWith(expected.errorMessage); 55 | }); 56 | 57 | test('log std using env variable', async () => { 58 | const expected = { 59 | errorMessage: 'error', 60 | infoMessage: 'info', 61 | }; 62 | const logger = new Stubs.LoggerStub(); 63 | const commandStub = new ExecCommandStub({ logger, logStd: false }); 64 | 65 | commandStub.createChildProcess.mockImplementationOnce(async () => { 66 | return { 67 | stderr: expected.errorMessage, 68 | stdout: expected.infoMessage, 69 | childProcess: Stubs.childProcess(), 70 | }; 71 | }); 72 | 73 | process.env.EXEC_COMMAND_LOG_STD = 'ExecCommandStub'; 74 | 75 | await commandStub.do(); 76 | 77 | expect(logger.info).toBeCalledWith(expected.infoMessage); 78 | 79 | expect(logger.error).toBeCalledWith(expected.errorMessage); 80 | 81 | delete process.env.EXEC_COMMAND_LOG_STD; 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /tests/unit/file-write-command.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { vitest, describe, test, expect, beforeAll, afterEach } from 'vitest'; 4 | 5 | import { Stubs } from '../stubs.js'; 6 | import { Commands } from '../../src/index.js'; 7 | 8 | const PACKAGE_JSON_PATH = path.resolve(__dirname, '../fixtures/package.json'); 9 | 10 | const CMD_CONFIG: Commands.FileWriterCommandConfig = { 11 | logger: new Stubs.NoopLogger(), 12 | create: false, 13 | mode: 'append', 14 | content: '[CONTENT]', 15 | absoluteFilePath: PACKAGE_JSON_PATH, 16 | }; 17 | 18 | class FileWriterCommandStub extends Commands.FileWriterCommand { 19 | constructor(config?: Partial) { 20 | super({ ...CMD_CONFIG, ...config }); 21 | } 22 | } 23 | 24 | describe('managing a file content', () => { 25 | let access, readFile, writeFile, unlink; 26 | 27 | beforeAll(() => { 28 | access = vitest.spyOn(fs.promises, 'access'); 29 | unlink = vitest.spyOn(fs.promises, 'unlink'); 30 | readFile = vitest.spyOn(fs.promises, 'readFile'); 31 | writeFile = vitest.spyOn(fs.promises, 'writeFile'); 32 | }); 33 | 34 | afterEach(() => { 35 | access.mockClear(); 36 | unlink.mockClear(); 37 | readFile.mockClear(); 38 | writeFile.mockClear(); 39 | }); 40 | 41 | test('undo deletes file when file is created', async () => { 42 | const logger = new Stubs.LoggerStub(); 43 | const expectedContent = 'EXPECTED_CONTENT'; 44 | const filePath = path.resolve(__dirname, '../fixtures/does-not-exists.json'); 45 | const fileWriterCommand = new FileWriterCommandStub({ 46 | logger, 47 | 48 | create: true, 49 | content: expectedContent, 50 | absoluteFilePath: filePath, 51 | }); 52 | 53 | unlink.mockImplementationOnce(() => { 54 | return Promise.resolve(); 55 | }); 56 | 57 | access.mockImplementationOnce(() => { 58 | return Promise.reject(new Error()); 59 | }); 60 | 61 | writeFile.mockImplementationOnce(() => { 62 | return Promise.resolve(); 63 | }); 64 | 65 | await fileWriterCommand.do(); 66 | await fileWriterCommand.undo(); 67 | 68 | expect(unlink).toBeCalledWith(filePath); 69 | 70 | expect(logger.info).toBeCalledWith(`Deleted file ${filePath}`); 71 | }); 72 | 73 | test('undo restores content when file existed', async () => { 74 | const logger = new Stubs.LoggerStub(); 75 | const initialContent = 'INITIAL CONTENT'; 76 | const expectedContent = 'EXPECTED CONTENT'; 77 | const fileWriterCommand = new FileWriterCommandStub({ 78 | logger, 79 | 80 | create: true, 81 | content: expectedContent, 82 | absoluteFilePath: PACKAGE_JSON_PATH, 83 | }); 84 | 85 | writeFile.mockImplementation(() => { 86 | return Promise.resolve(); 87 | }); 88 | 89 | access.mockImplementationOnce(() => { 90 | return Promise.resolve(); 91 | }); 92 | 93 | readFile.mockImplementationOnce(() => { 94 | return Promise.resolve(initialContent); 95 | }); 96 | 97 | await fileWriterCommand.do(); 98 | 99 | await fileWriterCommand.undo(); 100 | 101 | expect(writeFile).toHaveBeenCalledTimes(2); 102 | 103 | expect(writeFile).toBeCalledWith(PACKAGE_JSON_PATH, initialContent); 104 | 105 | expect(logger.info).toBeCalledWith(`Reverted file ${PACKAGE_JSON_PATH}`); 106 | }); 107 | 108 | test('file is created when file does not exist', async () => { 109 | const logger = new Stubs.LoggerStub(); 110 | const expectedContent = 'EXPECTED_CONTENT'; 111 | const filePath = path.resolve(__dirname, '../fixtures/does-not-exists.json'); 112 | const fileWriterCommand = new FileWriterCommandStub({ 113 | logger, 114 | 115 | create: true, 116 | content: expectedContent, 117 | absoluteFilePath: filePath, 118 | }); 119 | 120 | access.mockImplementationOnce(() => { 121 | return Promise.reject(new Error()); 122 | }); 123 | 124 | writeFile.mockImplementationOnce(() => { 125 | return Promise.resolve(); 126 | }); 127 | 128 | await fileWriterCommand.do(); 129 | 130 | expect(access).toBeCalledWith(filePath, fs.constants.F_OK); 131 | expect(writeFile).toBeCalledWith(filePath, expectedContent); 132 | expect(logger.info).toBeCalledWith(`Created file ${filePath}`); 133 | }); 134 | 135 | test('file content is appended/prepended/replaced', async () => { 136 | const newContent = 'NEW CONTENT'; 137 | const initialContent = 'SOME CONTENT'; 138 | const cases = [ 139 | { 140 | mode: 'append', 141 | expectedContent: `${initialContent}${newContent}`, 142 | log: `Appended content to file ${PACKAGE_JSON_PATH}`, 143 | }, 144 | { 145 | mode: 'prepend', 146 | expectedContent: `${newContent}${initialContent}`, 147 | log: `Prepended content to file ${PACKAGE_JSON_PATH}`, 148 | }, 149 | { 150 | mode: 'replace', 151 | expectedContent: `${newContent}`, 152 | log: `Replaced ${PACKAGE_JSON_PATH} content`, 153 | }, 154 | ]; 155 | 156 | for (const testCase of cases) { 157 | const logger = new Stubs.LoggerStub(); 158 | const fileWriterCommand = new FileWriterCommandStub({ 159 | logger, 160 | 161 | content: newContent, 162 | absoluteFilePath: PACKAGE_JSON_PATH, 163 | mode: testCase.mode as Commands.FileWriterCommandConfig['mode'], 164 | }); 165 | 166 | access.mockImplementation(() => { 167 | return Promise.resolve(); 168 | }); 169 | readFile.mockImplementation(() => { 170 | return Promise.resolve(initialContent); 171 | }); 172 | writeFile.mockImplementationOnce(() => { 173 | return Promise.resolve(); 174 | }); 175 | 176 | await fileWriterCommand.do(); 177 | 178 | expect(logger.info).toBeCalledWith(testCase.log); 179 | expect(writeFile).toBeCalledWith(PACKAGE_JSON_PATH, testCase.expectedContent); 180 | } 181 | }); 182 | }); 183 | -------------------------------------------------------------------------------- /tests/unit/git-commit-command.test.ts: -------------------------------------------------------------------------------- 1 | import { vitest, describe, test, expect } from 'vitest'; 2 | 3 | import { Stubs } from '../stubs.js'; 4 | import { GitCommitCommand, GitCommitCommandConfig } from '../../src/commands/index.js'; 5 | 6 | const LOGGER = new Stubs.LoggerStub(); 7 | 8 | const CMD_CONFIG: GitCommitCommandConfig = { 9 | logger: LOGGER, 10 | workingDirectory: '/bla/bla', 11 | commitMessage: 'There is no spoon', 12 | filePaths: new Set(['CHANGELOG.md', 'package.json']), 13 | }; 14 | 15 | class GitCommitCommandStub extends GitCommitCommand { 16 | constructor(config?: Partial) { 17 | super({ 18 | ...CMD_CONFIG, 19 | ...config, 20 | }); 21 | } 22 | 23 | public createChildProcess = vitest.fn(async (__args: any) => { 24 | return { 25 | stdout: '', 26 | stderr: '', 27 | childProcess: Stubs.childProcess(), 28 | }; 29 | }); 30 | } 31 | 32 | describe('perform a git commit', () => { 33 | test('invalid actor throws', async () => { 34 | const expectedError = new Error('actor must follow "name " format'); 35 | const commandStub = new GitCommitCommandStub({ 36 | actor: '...', 37 | }); 38 | 39 | await expect(commandStub.do()).rejects.toStrictEqual(expectedError); 40 | }); 41 | 42 | test('undo removes last commit', async () => { 43 | const commandStub = new GitCommitCommandStub(); 44 | 45 | await commandStub.do(); 46 | 47 | await commandStub.undo(); 48 | 49 | expect(commandStub.createChildProcess).toBeCalledWith('git reset HEAD~', { 50 | cwd: CMD_CONFIG.workingDirectory, 51 | 52 | }); 53 | }); 54 | 55 | test('files are staged & committed', async () => { 56 | const filePaths = Array.from(CMD_CONFIG.filePaths); 57 | const commandStub = new GitCommitCommandStub(); 58 | 59 | await commandStub.do(); 60 | 61 | for (const filePath of filePaths) { 62 | expect(LOGGER.info).toBeCalledWith(`Committed file ${filePath}`); 63 | } 64 | 65 | expect(commandStub.createChildProcess).toBeCalledWith(`git add ${filePaths.join(' ')}`, { 66 | cwd: CMD_CONFIG.workingDirectory, 67 | }); 68 | 69 | expect(commandStub.createChildProcess).toBeCalledWith({ command: 'git', args: ['commit', '-m', CMD_CONFIG.commitMessage] }, { 70 | env: expect.any(Object), 71 | cwd: CMD_CONFIG.workingDirectory, 72 | }); 73 | }); 74 | 75 | test('actor is translated into git env vars', async () => { 76 | const name = 'Bot'; 77 | const email = 'bot@email.com'; 78 | const commandStub = new GitCommitCommandStub({ 79 | actor: `${name} <${email}>`, 80 | }); 81 | 82 | await commandStub.do(); 83 | 84 | expect(commandStub.createChildProcess).toBeCalledWith({ command: 'git', args: ['commit', '-m', CMD_CONFIG.commitMessage] }, { 85 | env: expect.objectContaining({ 86 | GIT_COMMITTER_NAME: name, 87 | GIT_COMMITTER_EMAIL: email, 88 | GIT_AUTHOR_NAME: name, 89 | GIT_AUTHOR_EMAIL: email, 90 | }), 91 | cwd: CMD_CONFIG.workingDirectory, 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /tests/unit/git-exec-client.test.ts: -------------------------------------------------------------------------------- 1 | import { vitest, describe, test, expect } from 'vitest'; 2 | 3 | import { SDK } from '../../src/index.js'; 4 | import { Stubs } from '../stubs.js'; 5 | 6 | const STUB_CONFIG = { 7 | remote: 'origin2', 8 | workingDirectory: '/this/is/sparta', 9 | }; 10 | 11 | class GitExecClientStub extends SDK.GitExecClient { 12 | constructor(config = STUB_CONFIG) { 13 | super(config); 14 | } 15 | 16 | exec = vitest.fn(async (_args: any) => { 17 | return { 18 | stderr: '', 19 | stdout: '', 20 | childProcess: Stubs.childProcess(), 21 | }; 22 | }); 23 | } 24 | 25 | describe('git exec client', () => { 26 | test('log', async () => { 27 | const range = '123..'; 28 | const format = '%s'; 29 | const delimiter = ':++:'; 30 | const gitClient = new GitExecClientStub(); 31 | 32 | gitClient.exec.mockImplementation(async () => { 33 | return { 34 | stderr: '', 35 | stdout: ['1', '2'].join(delimiter), 36 | childProcess: Stubs.childProcess(), 37 | }; 38 | }); 39 | 40 | expect(await gitClient.log(range, format)).toStrictEqual(['1', '2']); 41 | 42 | expect(gitClient.exec).toBeCalledWith(`git log ${range} --pretty=format:${format}${delimiter}`, { 43 | cwd: STUB_CONFIG.workingDirectory, 44 | 45 | }); 46 | }); 47 | 48 | test('commits', async () => { 49 | const delimiter = ':<>:'; 50 | const expectedRange = '123..'; 51 | const commit: SDK.Commit = { 52 | hash: `${Date.now()}`, 53 | subject: 'chore(scope): some text', 54 | body: 'This is some additional description', 55 | notes: '123', 56 | tags: ['v0.1.0', 'v2.1.0'], 57 | committedTimestamp: 1633041877 * 1000, 58 | author: { 59 | name: 'Rick Sanchez', 60 | email: 'rick.sanchez@show-me-what-got.com', 61 | }, 62 | committer: { 63 | name: 'Rick Sanchez', 64 | email: 'rick.sanchez@show-me-what-got.com', 65 | }, 66 | }; 67 | const expectedFormat = ['%H', '%s', '%b', '%N', '%D', '%ct', '%an', '%ae', '%cn', '%ce']; 68 | const gitClient = new GitExecClientStub(); 69 | 70 | gitClient.exec.mockImplementation(async () => { 71 | const formattedLog = [ 72 | commit.hash, 73 | commit.subject, 74 | commit.body, 75 | commit.notes, 76 | commit.tags.map((tag) => { 77 | return `tag: ${tag}`; 78 | }).join(', '), 79 | commit.committedTimestamp / 1000, 80 | commit.author.name, 81 | commit.author.email, 82 | commit.committer.name, 83 | commit.committer.email, 84 | ]; 85 | 86 | return { 87 | stderr: '', 88 | stdout: formattedLog.join(delimiter), 89 | childProcess: Stubs.childProcess(), 90 | }; 91 | }); 92 | 93 | expect(await gitClient.commits(expectedRange)).toStrictEqual([commit]); 94 | 95 | expect(gitClient.exec).toBeCalledWith(`git log ${expectedRange} --pretty=format:${expectedFormat.join(delimiter)}:++:`, { 96 | cwd: STUB_CONFIG.workingDirectory, 97 | 98 | }); 99 | }); 100 | 101 | test('ref hash', async () => { 102 | const ref = 'HEAD'; 103 | const expectedHash = '123'; 104 | const gitClient = new GitExecClientStub(); 105 | 106 | gitClient.exec.mockImplementation(async () => { 107 | return { 108 | stderr: '', 109 | stdout: expectedHash, 110 | childProcess: Stubs.childProcess(), 111 | }; 112 | }); 113 | 114 | expect(await gitClient.refHash(ref)).toStrictEqual(expectedHash); 115 | 116 | expect(gitClient.exec).toBeCalledWith(`git rev-parse ${ref}`, { 117 | cwd: STUB_CONFIG.workingDirectory, 118 | 119 | }); 120 | }); 121 | 122 | test('ref name', async () => { 123 | const ref = 'HEAD'; 124 | const expectedName = 'main'; 125 | const gitClient = new GitExecClientStub(); 126 | 127 | gitClient.exec.mockImplementation(async () => { 128 | return { 129 | stderr: '', 130 | stdout: expectedName, 131 | childProcess: Stubs.childProcess(), 132 | }; 133 | }); 134 | 135 | expect(await gitClient.refName(ref)).toStrictEqual(expectedName); 136 | 137 | expect(gitClient.exec).toBeCalledWith(`git rev-parse --abbrev-ref ${ref}`, { 138 | cwd: STUB_CONFIG.workingDirectory, 139 | 140 | }); 141 | }); 142 | 143 | test('list tags', async () => { 144 | const delimiter = ':++:'; 145 | const expectedTag = { 146 | name: 'v0.1.0', 147 | hash: 'c658ea3e060490dced90dfb34c018d88b8e797f9', 148 | }; 149 | const gitClient = new GitExecClientStub(); 150 | 151 | gitClient.exec.mockImplementationOnce(async () => { 152 | return { 153 | stderr: '', 154 | stdout: `${expectedTag.name}${delimiter}${expectedTag.hash}`, 155 | childProcess: Stubs.childProcess(), 156 | }; 157 | }); 158 | 159 | await expect(gitClient.listTags()).resolves.toEqual([expectedTag]); 160 | 161 | expect(gitClient.exec).toBeCalledWith(`git tag --format=%(refname:strip=2)${delimiter}%(objectname)`, { 162 | cwd: STUB_CONFIG.workingDirectory, 163 | }); 164 | }); 165 | 166 | test('cli version', async () => { 167 | const expectedVersion = '2.7.0'; 168 | const gitClient = new GitExecClientStub(); 169 | 170 | gitClient.exec.mockImplementation(async () => { 171 | return { 172 | stderr: '', 173 | stdout: `git version ${expectedVersion}`, 174 | childProcess: Stubs.childProcess(), 175 | }; 176 | }); 177 | 178 | expect(await gitClient.cliVersion()).toStrictEqual(expectedVersion); 179 | 180 | expect(gitClient.exec).toBeCalledWith('git --version', { 181 | cwd: STUB_CONFIG.workingDirectory, 182 | 183 | }); 184 | }); 185 | 186 | test('remote tag hash', async () => { 187 | const tagName = 'v1.0.0'; 188 | const expectedHash = '1234'; 189 | const gitClient = new GitExecClientStub(); 190 | 191 | gitClient.exec.mockImplementation(async () => { 192 | return { 193 | stderr: '', 194 | stdout: `${expectedHash}\trefs/tags/${tagName}`, 195 | childProcess: Stubs.childProcess(), 196 | }; 197 | }); 198 | 199 | expect(await gitClient.remoteTagHash(tagName)).toStrictEqual(expectedHash); 200 | 201 | expect(gitClient.exec).toBeCalledWith(`git ls-remote ${STUB_CONFIG.remote} -t refs/tags/${tagName}`, { 202 | cwd: STUB_CONFIG.workingDirectory, 203 | 204 | }); 205 | }); 206 | 207 | test('remote branch hash', async () => { 208 | const branchName = 'v1.0.0'; 209 | const expectedHash = '1234'; 210 | const gitClient = new GitExecClientStub(); 211 | 212 | gitClient.exec.mockImplementation(async () => { 213 | return { 214 | stderr: '', 215 | stdout: `${expectedHash}\trefs/heads/${branchName}`, 216 | childProcess: Stubs.childProcess(), 217 | }; 218 | }); 219 | 220 | expect(await gitClient.remoteBranchHash(branchName)).toStrictEqual(expectedHash); 221 | 222 | expect(gitClient.exec).toBeCalledWith(`git ls-remote ${STUB_CONFIG.remote} -h refs/heads/${branchName}`, { 223 | cwd: STUB_CONFIG.workingDirectory, 224 | 225 | }); 226 | }); 227 | 228 | test('remote tag hash is null', async () => { 229 | const tagName = 'v1.0.0'; 230 | const gitClient = new GitExecClientStub(); 231 | 232 | expect(await gitClient.remoteTagHash(tagName)).toStrictEqual(null); 233 | 234 | expect(gitClient.exec).toBeCalledWith(`git ls-remote ${STUB_CONFIG.remote} -t refs/tags/${tagName}`, { 235 | cwd: STUB_CONFIG.workingDirectory, 236 | 237 | }); 238 | }); 239 | 240 | test('remote branch hash is null', async () => { 241 | const tagName = 'v1.0.0'; 242 | const gitClient = new GitExecClientStub(); 243 | 244 | expect(await gitClient.remoteBranchHash(tagName)).toStrictEqual(null); 245 | 246 | expect(gitClient.exec).toBeCalledWith(`git ls-remote ${STUB_CONFIG.remote} -h refs/heads/${tagName}`, { 247 | cwd: STUB_CONFIG.workingDirectory, 248 | 249 | }); 250 | }); 251 | }); 252 | -------------------------------------------------------------------------------- /tests/unit/git-push-branch-command.test.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcess } from 'node:child_process'; 2 | import { describe, test, expect, vitest } from 'vitest'; 3 | 4 | import { Stubs } from '../stubs'; 5 | import { Commands } from '../../src'; 6 | 7 | const CMD_CONFIG: Commands.GitPushBranchCommandConfig = { 8 | remote: 'custom-remote', 9 | logger: Stubs.NoopLogger.INSTANCE, 10 | workingDirectory: '/home/bla/bla', 11 | branchName: 'version-123-generated-files', 12 | }; 13 | 14 | class GitPushBranchCommandStub extends Commands.GitPushBranchCommand { 15 | constructor(config?: Partial) { 16 | super({ ...CMD_CONFIG, ...config }); 17 | } 18 | 19 | public createChildProcess = vitest.fn((__args: any) => { 20 | const childProcess = new ChildProcess(); 21 | 22 | return Promise.resolve({ 23 | stdout: '', 24 | stderr: '', 25 | childProcess: childProcess, 26 | }); 27 | }); 28 | } 29 | 30 | describe('perform git push', () => { 31 | test('undo warns when branch was pushed', async () => { 32 | const logger = new Stubs.LoggerStub(); 33 | const commandStub = new GitPushBranchCommandStub({ 34 | logger, 35 | }); 36 | 37 | await commandStub.do(); 38 | 39 | commandStub.createChildProcess.mockClear(); 40 | 41 | await commandStub.undo(); 42 | 43 | expect(commandStub.createChildProcess).not.toBeCalled(); 44 | 45 | expect(logger.warn).toBeCalledWith(`Cannot un-push remote branch '${CMD_CONFIG.branchName}'`); 46 | }); 47 | 48 | test('execution fails when remote branch exists', async () => { 49 | const logger = new Stubs.LoggerStub(); 50 | const commandStub = new GitPushBranchCommandStub({ 51 | logger, 52 | }); 53 | const expectedError = new Error(`Remote '${CMD_CONFIG.remote}' already has a branch named '${CMD_CONFIG.branchName}'`); 54 | 55 | commandStub.createChildProcess.mockImplementationOnce(async () => { 56 | return { 57 | stderr: '', 58 | stdout: 'branch exists', 59 | childProcess: new ChildProcess(), 60 | }; 61 | }); 62 | 63 | await expect(commandStub.do()).rejects.toEqual(expectedError); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /tests/unit/git-switch-branch-command.test.ts: -------------------------------------------------------------------------------- 1 | import { vitest, describe, test, expect } from 'vitest'; 2 | 3 | import { Stubs } from '../stubs'; 4 | import { Commands } from '../../src'; 5 | 6 | const CMD_CONFIG: Commands.GitSwitchCommandOptions = { 7 | logger: Stubs.NoopLogger.INSTANCE, 8 | workingDirectory: '/bla/bla', 9 | branchName: 'v1.1.1', 10 | }; 11 | 12 | class GitSwitchBranchCommandStub extends Commands.GitSwitchBranchCommand { 13 | constructor(config?: Partial) { 14 | super({ ...CMD_CONFIG, ...config }); 15 | } 16 | 17 | public createChildProcess = vitest.fn(async (__args: any) => { 18 | return { 19 | stdout: '', 20 | stderr: '', 21 | childProcess: Stubs.childProcess(), 22 | }; 23 | }); 24 | } 25 | 26 | describe('switch git branch', () => { 27 | test('branch is switched', async () => { 28 | const logger = new Stubs.LoggerStub(); 29 | const commandStub = new GitSwitchBranchCommandStub({ logger }); 30 | 31 | commandStub.createChildProcess.mockImplementation(async (cmd) => { 32 | if (cmd === 'git switch rev-parse --abbrev-ref HEAD') { 33 | return { 34 | stderr: '', 35 | stdout: Date.now().toString(), 36 | childProcess: Stubs.childProcess(), 37 | }; 38 | } 39 | 40 | return { 41 | stderr: '', 42 | stdout: '', 43 | childProcess: Stubs.childProcess(), 44 | }; 45 | }); 46 | 47 | await commandStub.do(); 48 | 49 | expect(logger.info).toBeCalledWith(`Switched to branch '${CMD_CONFIG.branchName}'`); 50 | 51 | expect(commandStub.createChildProcess).toBeCalledWith(`git switch ${CMD_CONFIG.branchName}`, { 52 | 53 | cwd: CMD_CONFIG.workingDirectory, 54 | }); 55 | }); 56 | 57 | test('branch is not switched', async () => { 58 | const commandStub = new GitSwitchBranchCommandStub(); 59 | 60 | commandStub.createChildProcess.mockImplementation(async (cmd) => { 61 | if (cmd === 'git rev-parse --abbrev-ref HEAD') { 62 | return { 63 | stderr: '', 64 | stdout: CMD_CONFIG.branchName, 65 | childProcess: Stubs.childProcess(), 66 | }; 67 | } 68 | 69 | return { 70 | stderr: '', 71 | stdout: '', 72 | childProcess: Stubs.childProcess(), 73 | }; 74 | }); 75 | 76 | await commandStub.do(); 77 | 78 | expect(commandStub.createChildProcess).not.toBeCalledWith(`git switch -c ${CMD_CONFIG.branchName}`, { 79 | cwd: CMD_CONFIG.workingDirectory, 80 | 81 | }); 82 | }); 83 | 84 | test('missing branch name throws', async () => { 85 | const expectedError = new Error('Missing branch name'); 86 | const commandStub = new GitSwitchBranchCommandStub({ 87 | branchName: undefined, 88 | }); 89 | const error = await commandStub.do().catch((e) => { 90 | return e; 91 | }); 92 | 93 | expect(error).toStrictEqual(expectedError); 94 | }); 95 | 96 | test('undo deletes created branch', async () => { 97 | const logger = new Stubs.LoggerStub(); 98 | const commandStub = new GitSwitchBranchCommandStub({ logger }); 99 | 100 | commandStub.createChildProcess.mockImplementation(async (cmd) => { 101 | if (cmd === `git rev-parse --verify refs/heads/${CMD_CONFIG.branchName}`) { 102 | throw new Error(); 103 | } 104 | 105 | return { 106 | stdout: '', 107 | stderr: '', 108 | childProcess: Stubs.childProcess(), 109 | }; 110 | }); 111 | 112 | await commandStub.do(); 113 | 114 | await commandStub.undo(); 115 | 116 | expect(logger.info).toBeCalledWith(`Deleted branch '${CMD_CONFIG.branchName}'`); 117 | 118 | expect(commandStub.createChildProcess).toBeCalledWith(`git branch -D ${CMD_CONFIG.branchName}`, { 119 | cwd: CMD_CONFIG.workingDirectory, 120 | 121 | }); 122 | }); 123 | 124 | test('undo switches to initial branch', async () => { 125 | const logger = new Stubs.LoggerStub(); 126 | const initialBranchName = Date.now().toString(); 127 | const commandStub = new GitSwitchBranchCommandStub({ logger }); 128 | 129 | commandStub.createChildProcess.mockImplementation(async (cmd) => { 130 | if (cmd === 'git rev-parse --abbrev-ref HEAD') { 131 | return { 132 | stderr: '', 133 | stdout: initialBranchName, 134 | childProcess: Stubs.childProcess(), 135 | }; 136 | } 137 | 138 | return { 139 | stdout: '', 140 | stderr: '', 141 | childProcess: Stubs.childProcess(), 142 | }; 143 | }); 144 | 145 | await commandStub.do(); 146 | 147 | await commandStub.undo(); 148 | 149 | expect(logger.info).toBeCalledWith(`Switched to branch '${initialBranchName}'`); 150 | 151 | expect(commandStub.createChildProcess).toBeCalledWith(`git switch ${initialBranchName}`, { 152 | cwd: CMD_CONFIG.workingDirectory, 153 | 154 | }); 155 | }); 156 | 157 | test('branch is created and switched when branch does not exists', async () => { 158 | const commandStub = new GitSwitchBranchCommandStub(); 159 | 160 | commandStub.createChildProcess.mockImplementation(async (cmd) => { 161 | if (cmd === `git rev-parse --verify refs/heads/${CMD_CONFIG.branchName}`) { 162 | throw new Error(); 163 | } 164 | 165 | if (cmd === 'git rev-parse --abbrev-ref HEAD') { 166 | return { 167 | stderr: '', 168 | stdout: Date.now().toString(), 169 | childProcess: Stubs.childProcess(), 170 | }; 171 | } 172 | 173 | return { 174 | stderr: '', 175 | stdout: '', 176 | childProcess: Stubs.childProcess(), 177 | }; 178 | }); 179 | 180 | await commandStub.do(); 181 | 182 | expect(commandStub.createChildProcess).toBeCalledWith(`git switch -c ${CMD_CONFIG.branchName}`, { 183 | cwd: CMD_CONFIG.workingDirectory, 184 | 185 | }); 186 | }); 187 | }); 188 | -------------------------------------------------------------------------------- /tests/unit/git-tag-command.test.ts: -------------------------------------------------------------------------------- 1 | import to from 'await-to-js'; 2 | import { ChildProcess } from 'node:child_process'; 3 | import { vitest, describe, expect, test } from 'vitest'; 4 | 5 | import { Stubs } from '../stubs'; 6 | import { Commands } from '../../src'; 7 | 8 | const CMD_CONFIG: Commands.GitTagCommandConfig = { 9 | logger: Stubs.NoopLogger.INSTANCE, 10 | remote: Date.now().toString(), 11 | workingDirectory: '/home/super.mario', 12 | name: 'version-123-generated-files', 13 | }; 14 | 15 | class GitTagCommandStub extends Commands.GitTagCommand { 16 | constructor(config?: Partial) { 17 | super({ ...CMD_CONFIG, ...config }); 18 | } 19 | 20 | public createChildProcess = vitest.fn(async (_args: any) => { 21 | return { 22 | stdout: '', 23 | stderr: '', 24 | childProcess: Stubs.childProcess(), 25 | }; 26 | }); 27 | } 28 | 29 | describe('create a git tag locally/remotely', () => { 30 | const localTagExistsParameters = `git tag --list ${CMD_CONFIG.name}`; 31 | 32 | const remoteTagExistsParameters = `git ls-remote ${CMD_CONFIG.remote} refs/tags/${CMD_CONFIG.name}`; 33 | 34 | const createLocalTagParameters = `git tag ${CMD_CONFIG.name}`; 35 | 36 | const createRemoteTagParameters = `git push ${CMD_CONFIG.remote} refs/tags/${CMD_CONFIG.name}`; 37 | 38 | const deleteLocalTagParameters = `git tag --delete ${CMD_CONFIG.name}`; 39 | 40 | const deleteRemoteTagParameters = `git push ${CMD_CONFIG.remote} --delete refs/tags/${CMD_CONFIG.name}`; 41 | 42 | test('tag is created locally', async () => { 43 | const logger = new Stubs.LoggerStub(); 44 | const commandStub = new GitTagCommandStub({ 45 | logger, 46 | }); 47 | 48 | await commandStub.do(); 49 | 50 | expect(logger.info).toBeCalledWith(`Created a local tag '${CMD_CONFIG.name}'`); 51 | 52 | expect(commandStub.createChildProcess).toBeCalledWith(createLocalTagParameters, { 53 | 54 | cwd: CMD_CONFIG.workingDirectory, 55 | }); 56 | }); 57 | 58 | test('tag is pushed to remote', async () => { 59 | const logger = new Stubs.LoggerStub(); 60 | const commandStub = new GitTagCommandStub({ logger }); 61 | 62 | await commandStub.do(); 63 | 64 | expect(commandStub.createChildProcess).toBeCalledWith(createLocalTagParameters, { 65 | 66 | cwd: CMD_CONFIG.workingDirectory, 67 | }); 68 | 69 | expect(commandStub.createChildProcess).toBeCalledWith(createRemoteTagParameters, { 70 | 71 | cwd: CMD_CONFIG.workingDirectory, 72 | }); 73 | 74 | expect(logger.info).toBeCalledWith(`Pushed tag '${CMD_CONFIG.name}' to remote '${CMD_CONFIG.remote}'`); 75 | }); 76 | 77 | test('tag is not created when local tag exists', async () => { 78 | const commandStub = new GitTagCommandStub(); 79 | 80 | commandStub.createChildProcess.mockImplementation(async (cmd) => { 81 | return { 82 | stderr: '', 83 | stdout: cmd.includes('ls-remote') ? '' : 'v2', 84 | exitCode: 0, 85 | childProcess: Stubs.childProcess(), 86 | }; 87 | }); 88 | 89 | const [error] = await to(commandStub.do()); 90 | 91 | expect(commandStub.createChildProcess).toBeCalledWith(localTagExistsParameters, { 92 | 93 | cwd: CMD_CONFIG.workingDirectory, 94 | }); 95 | 96 | expect(commandStub.createChildProcess).not.toBeCalledWith(createLocalTagParameters, { 97 | 98 | cwd: CMD_CONFIG.workingDirectory, 99 | }); 100 | 101 | expect(error).toEqual(new Error(`A local tag named '${CMD_CONFIG.name}' already exists`)); 102 | }); 103 | 104 | test('tag is not created when remote tag exists', async () => { 105 | const commandStub = new GitTagCommandStub(); 106 | 107 | commandStub.createChildProcess.mockImplementation(async (cmd) => { 108 | return { 109 | stderr: '', 110 | stdout: cmd.includes('ls-remote') ? 'exists' : '', 111 | childProcess: Stubs.childProcess(), 112 | }; 113 | }); 114 | 115 | const [error] = await to(commandStub.do()); 116 | 117 | expect(commandStub.createChildProcess).toBeCalledWith(remoteTagExistsParameters, { 118 | 119 | cwd: CMD_CONFIG.workingDirectory, 120 | }); 121 | 122 | expect(commandStub.createChildProcess).not.toBeCalledWith(createRemoteTagParameters, { 123 | 124 | cwd: CMD_CONFIG.workingDirectory, 125 | }); 126 | 127 | expect(error).toEqual(new Error(`A tag named '${CMD_CONFIG.name}' already exists in remote '${CMD_CONFIG.remote}'`)); 128 | }); 129 | 130 | test('undo deletes local tag when it was created', async () => { 131 | const logger = new Stubs.LoggerStub(); 132 | const commandStub = new GitTagCommandStub({ logger }); 133 | 134 | await commandStub.do(); 135 | 136 | await commandStub.undo(); 137 | 138 | expect(commandStub.createChildProcess).toBeCalledWith(deleteLocalTagParameters, { 139 | 140 | cwd: CMD_CONFIG.workingDirectory, 141 | }); 142 | 143 | expect(logger.info).toBeCalledWith(`Deleted local tag '${CMD_CONFIG.name}'`); 144 | }); 145 | 146 | test('undo deletes remote tag when it was created', async () => { 147 | const commandStub = new GitTagCommandStub(); 148 | 149 | await commandStub.do(); 150 | 151 | await commandStub.undo(); 152 | 153 | expect(commandStub.createChildProcess).toBeCalledWith(deleteRemoteTagParameters, { 154 | 155 | cwd: CMD_CONFIG.workingDirectory, 156 | }); 157 | }); 158 | 159 | test('undo deletes local tag before deleting remote tag', async () => { 160 | const commandStub = new GitTagCommandStub(); 161 | 162 | await commandStub.do(); 163 | 164 | await commandStub.undo(); 165 | 166 | expect(commandStub.createChildProcess).toBeCalledWith(deleteLocalTagParameters, { 167 | 168 | cwd: CMD_CONFIG.workingDirectory, 169 | }); 170 | 171 | expect(commandStub.createChildProcess).toBeCalledWith(deleteRemoteTagParameters, { 172 | 173 | cwd: CMD_CONFIG.workingDirectory, 174 | }); 175 | }); 176 | 177 | test('undo deletes remote tag when deleting local tag failed', async () => { 178 | const logger = new Stubs.LoggerStub(); 179 | const commandStub = new GitTagCommandStub({ logger }); 180 | 181 | commandStub.createChildProcess.mockImplementation(async (cmd) => { 182 | if (cmd !== deleteLocalTagParameters) { 183 | return { 184 | stdout: '', 185 | stderr: '', 186 | childProcess: Stubs.childProcess(), 187 | }; 188 | } 189 | 190 | throw new Error(); 191 | }); 192 | 193 | await commandStub.do(); 194 | 195 | await commandStub.undo(); 196 | 197 | expect(commandStub.createChildProcess).toBeCalledWith(deleteRemoteTagParameters, { 198 | 199 | cwd: CMD_CONFIG.workingDirectory, 200 | }); 201 | 202 | expect(logger.error).toBeCalledWith(new Error(`Failed to delete local tag '${CMD_CONFIG.name}'`)); 203 | }); 204 | 205 | test('remote tag is not created when creating local tag fails', async () => { 206 | const expectedError = new Error(); 207 | const commandStub = new GitTagCommandStub(); 208 | 209 | commandStub.createChildProcess.mockImplementation(async (cmd) => { 210 | if (cmd === createLocalTagParameters) { 211 | throw expectedError; 212 | } 213 | 214 | return { 215 | stderr: '', 216 | stdout: '', 217 | childProcess: new ChildProcess(), 218 | }; 219 | }); 220 | 221 | const error = await commandStub.do().catch(e => e); 222 | 223 | await commandStub.undo(); 224 | 225 | expect(error).toEqual(expectedError); 226 | 227 | expect(commandStub.createChildProcess).toBeCalledWith(createLocalTagParameters, { 228 | 229 | cwd: CMD_CONFIG.workingDirectory, 230 | }); 231 | 232 | expect(commandStub.createChildProcess).not.toBeCalledWith(createRemoteTagParameters, expect.anything()); 233 | }); 234 | 235 | test('undo does not deletes local tag when it was not created', async () => { 236 | const commandStub = new GitTagCommandStub(); 237 | 238 | commandStub.createChildProcess.mockImplementation(async (cmd) => { 239 | if (cmd.includes(`tag ${CMD_CONFIG.name}`)) { 240 | throw new Error(); 241 | } 242 | 243 | return { 244 | stderr: '', 245 | stdout: '', 246 | childProcess: new ChildProcess(), 247 | }; 248 | }); 249 | 250 | await to(commandStub.do()); 251 | 252 | await commandStub.undo(); 253 | 254 | expect(commandStub.createChildProcess).not.toBeCalledWith(deleteLocalTagParameters); 255 | }); 256 | 257 | test('undo does not deletes remote tag when it was not created', async () => { 258 | const commandStub = new GitTagCommandStub(); 259 | 260 | commandStub.createChildProcess.mockImplementation(async (cmd) => { 261 | if (cmd.join(`push ${CMD_CONFIG.remote} refs/tags/${CMD_CONFIG.name}`)) { 262 | throw new Error(); 263 | } 264 | 265 | return { 266 | stdout: '', 267 | stderr: '', 268 | childProcess: new ChildProcess(), 269 | }; 270 | }); 271 | 272 | await to(commandStub.do()); 273 | 274 | await commandStub.undo(); 275 | 276 | expect(commandStub.createChildProcess).not.toBeCalledWith(deleteRemoteTagParameters); 277 | }); 278 | }); 279 | -------------------------------------------------------------------------------- /tests/unit/github-create-issue-comments-command.test.ts: -------------------------------------------------------------------------------- 1 | import to from 'await-to-js'; 2 | import uriTemplates from 'uri-templates'; 3 | import { vitest, describe, test, expect } from 'vitest'; 4 | 5 | import { Stubs } from '../stubs'; 6 | import { Commands } from '../../src'; 7 | 8 | const mockedFetch = () => { 9 | return vitest.fn(async (url: string) => { 10 | const { issueNumber } = URL_TEMPLATES.CREATE_COMMENT.fromUri(url); 11 | 12 | const resource = JSON.stringify({ 13 | id: CREATED_COMMENT_ID, 14 | html_url: URL_TEMPLATES.CREATED_COMMENT_HTML.fill({ issueNumber }), 15 | }); 16 | 17 | return new Response(resource, { 18 | status: 201, 19 | statusText: 'Created', 20 | }); 21 | }); 22 | }; 23 | 24 | const CMD_CONFIG: Commands.GithubCreateIssueCommentsCommandConfig = { 25 | logger: Stubs.NoopLogger.INSTANCE, 26 | fetch: mockedFetch(), 27 | 28 | repoOwner: 'nintendo', 29 | repoName: 'super-mario', 30 | 31 | issueComments: [], 32 | 33 | headers: { 34 | 'X-Custom-Header': '1', 35 | }, 36 | }; 37 | 38 | const V3_MIME_TYPE = 'application/vnd.github.v3+json'; 39 | 40 | const CREATED_COMMENT_ID = Date.now(); 41 | 42 | const URL_TEMPLATES = { 43 | CREATE_COMMENT: uriTemplates( 44 | `https://api.github.com/repos/${CMD_CONFIG.repoOwner}/${CMD_CONFIG.repoName}/issues/{issueNumber}/comments`, 45 | ), 46 | 47 | CREATED_COMMENT_HTML: uriTemplates( 48 | `https://github.com/${CMD_CONFIG.repoOwner}/${CMD_CONFIG.repoName}/pull/{issueNumber}#issuecomment-${CREATED_COMMENT_ID}`, 49 | ), 50 | 51 | // Cutting corners... not really a template 52 | DELETE_COMMENT: `https://api.github.com/repos/${CMD_CONFIG.repoOwner}/${CMD_CONFIG.repoName}/issues/comments/${CREATED_COMMENT_ID}`, 53 | }; 54 | 55 | class GithubCommentOnIssuesCommandStub extends Commands.GithubCreateIssueCommentsCommand { 56 | public constructor(config?: Partial) { 57 | super(Object.assign({}, CMD_CONFIG, config)); 58 | } 59 | } 60 | 61 | describe('comment in github issues', () => { 62 | test('comments are created', async () => { 63 | const fetch = mockedFetch(); 64 | const logger = new Stubs.LoggerStub(); 65 | const issueComments = [ 66 | { issueNumber: Date.now(), commentBody: 'Test' }, 67 | { issueNumber: Date.now(), commentBody: 'Another test' }, 68 | ]; 69 | const commandStub = new GithubCommentOnIssuesCommandStub({ 70 | fetch, 71 | logger, 72 | issueComments, 73 | }); 74 | 75 | await commandStub.do(); 76 | 77 | for (const issueComment of issueComments) { 78 | const expectedURL = URL_TEMPLATES.CREATE_COMMENT.fill({ 79 | repo: CMD_CONFIG.repoName, 80 | owner: CMD_CONFIG.repoOwner, 81 | issueNumber: issueComment.issueNumber, 82 | }); 83 | const resourceURL = URL_TEMPLATES.CREATED_COMMENT_HTML.fill({ 84 | issueNumber: issueComment.issueNumber, 85 | }); 86 | 87 | expect(fetch).toBeCalledWith(expectedURL, { 88 | method: 'POST', 89 | 90 | headers: { 91 | ...CMD_CONFIG.headers, 92 | 'Content-Type': 'application/json', 93 | 'Accept': V3_MIME_TYPE, 94 | }, 95 | 96 | body: expect.stringMatching(JSON.stringify({ 97 | body: issueComment.commentBody, 98 | })), 99 | }); 100 | 101 | expect(logger.info).toBeCalledWith(`Created comment: ${resourceURL} (id: ${CREATED_COMMENT_ID})`); 102 | } 103 | }); 104 | 105 | test('undo delete comments when one or more was created', async () => { 106 | const fetch = mockedFetch(); 107 | const logger = new Stubs.LoggerStub(); 108 | const issueComments = [ 109 | { issueNumber: Date.now(), commentBody: 'Test' }, 110 | { issueNumber: Date.now(), commentBody: 'Another test' }, 111 | ]; 112 | const commandStub = new GithubCommentOnIssuesCommandStub({ 113 | fetch, 114 | logger, 115 | issueComments, 116 | }); 117 | 118 | await commandStub.do(); 119 | 120 | fetch.mockImplementation(async () => { 121 | return new Response(null, { 122 | status: 204, 123 | statusText: 'No Content', 124 | }); 125 | }); 126 | 127 | await commandStub.undo(); 128 | 129 | for (const issueComment of issueComments) { 130 | const resourceURL = URL_TEMPLATES.CREATED_COMMENT_HTML.fill({ 131 | issueNumber: issueComment.issueNumber, 132 | }); 133 | 134 | expect(fetch).toBeCalledWith(URL_TEMPLATES.DELETE_COMMENT, { 135 | method: 'DELETE', 136 | 137 | headers: { 138 | ...CMD_CONFIG.headers, 139 | Accept: V3_MIME_TYPE, 140 | }, 141 | }); 142 | 143 | expect(logger.info).toBeCalledWith(`Deleted comment: ${resourceURL}`); 144 | } 145 | }); 146 | 147 | test('undo logs a message when deleting a comment failed', async () => { 148 | const fetch = mockedFetch(); 149 | const logger = new Stubs.LoggerStub(); 150 | const issueNumber = Date.now(); 151 | const expectedStatus = { 152 | status: 404, 153 | statusText: 'Not Found', 154 | }; 155 | const commandStub = new GithubCommentOnIssuesCommandStub({ 156 | fetch, 157 | logger, 158 | issueComments: [{ issueNumber, commentBody: 'Test' }], 159 | }); 160 | 161 | await commandStub.do(); 162 | 163 | fetch.mockImplementation(async () => { 164 | return new Response('', expectedStatus); 165 | }); 166 | 167 | await commandStub.undo(); 168 | 169 | const resourceURL = URL_TEMPLATES.CREATED_COMMENT_HTML.fill({ issueNumber }); 170 | 171 | expect(logger.warn).toBeCalledWith( 172 | `Failed to delete comment '${resourceURL}'. Status code is ${expectedStatus.status}`, 173 | ); 174 | }); 175 | 176 | test('execution does not fail when status code is 404/410', async () => { 177 | const fetch = mockedFetch(); 178 | const logger = new Stubs.LoggerStub(); 179 | const issueNumber = Date.now(); 180 | const statusArray = [ 181 | { status: 410, statusText: 'Gone' }, 182 | { status: 404, statusText: 'Not Found' }, 183 | ]; 184 | const commandStub = new GithubCommentOnIssuesCommandStub({ 185 | fetch, 186 | logger, 187 | issueComments: [{ issueNumber, commentBody: 'Test' }], 188 | }); 189 | 190 | for (const status of statusArray) { 191 | fetch.mockImplementationOnce(async () => { 192 | return new Response('', status); 193 | }); 194 | 195 | const [error] = await to(commandStub.do()); 196 | 197 | expect(error).toBeNull(); 198 | expect(logger.info).toBeCalledWith(`Could not find issue '${issueNumber}'. Comment was not created.`); 199 | 200 | logger.info.mockReset(); 201 | } 202 | }); 203 | 204 | test('execution fails when status code is not 404/410/201', async () => { 205 | const fetch = mockedFetch(); 206 | const logger = new Stubs.LoggerStub(); 207 | const statusCode = 422; 208 | const issueNumber = Date.now(); 209 | const commandStub = new GithubCommentOnIssuesCommandStub({ 210 | fetch, 211 | logger, 212 | issueComments: [{ issueNumber, commentBody: 'Test' }], 213 | }); 214 | const expectedError = new Error(`Failed to create a comment in issue '${issueNumber}'. Status code is ${statusCode}`); 215 | 216 | fetch.mockImplementationOnce(async () => { 217 | return new Response('', { 218 | status: statusCode, 219 | statusText: 'Unprocessable Entity', 220 | }); 221 | }); 222 | 223 | await expect(commandStub.do()).rejects.toEqual(expectedError); 224 | }); 225 | }); 226 | -------------------------------------------------------------------------------- /tests/unit/github-create-pull-request-command.test.ts: -------------------------------------------------------------------------------- 1 | import { vitest, describe, test, expect } from 'vitest'; 2 | 3 | import { Stubs } from '../stubs.js'; 4 | import { Commands } from '../../src/index.js'; 5 | 6 | const CMD_CONFIG: Commands.GithubCreatePullRequestCommandConfig = { 7 | logger: Stubs.NoopLogger.INSTANCE, 8 | fetch, 9 | 10 | owner: 'nintendo', 11 | repo: 'super-mario', 12 | base: 'main', 13 | head: 'version-123-generated-files', 14 | title: '[Atomic Release] v123 generated files', 15 | body: 'This is sparta', 16 | 17 | headers: { 18 | 'X-Custom-Header': '1', 19 | }, 20 | }; 21 | const V3_MIME_TYPE = 'application/vnd.github.v3+json'; 22 | const PR_NUMBER = 1; 23 | const PR_URL = `https://github.com/${CMD_CONFIG.owner}/${CMD_CONFIG.repo}/pull/${PR_NUMBER}`; 24 | const CREATE_PR_URL = `https://api.github.com/repos/${CMD_CONFIG.owner}/${CMD_CONFIG.repo}/pulls`; 25 | const UPDATE_PR_URL = `https://api.github.com/repos/${CMD_CONFIG.owner}/${CMD_CONFIG.repo}/pulls/${PR_NUMBER}`; 26 | const CREATED_PR_ID = Date.now(); 27 | 28 | class GithubCreatePullRequestCommandStub extends Commands.GithubCreatePullRequestCommand { 29 | public constructor(config?: Partial) { 30 | super(Object.assign({}, CMD_CONFIG, config)); 31 | } 32 | } 33 | 34 | const mockedFetch = () => vitest.fn(async () => { 35 | const body = JSON.stringify({ 36 | id: CREATED_PR_ID, 37 | html_url: `https://github.com/${CMD_CONFIG.owner}/${CMD_CONFIG.repo}/pull/1`, 38 | number: 1, 39 | }); 40 | 41 | return new Response(body, { 42 | status: 201, 43 | statusText: 'Created', 44 | }); 45 | }); 46 | 47 | describe('create a github pull request', () => { 48 | test('pull request is created', async () => { 49 | const fetch = mockedFetch(); 50 | const logger = new Stubs.LoggerStub(); 51 | const gitTagCommandStub = new GithubCreatePullRequestCommandStub({ logger, fetch }); 52 | 53 | await gitTagCommandStub.do(); 54 | 55 | expect(fetch).toBeCalledWith(CREATE_PR_URL, { 56 | method: 'POST', 57 | 58 | headers: { 59 | ...CMD_CONFIG.headers, 60 | Accept: V3_MIME_TYPE, 61 | }, 62 | 63 | body: JSON.stringify({ 64 | head: CMD_CONFIG.head, 65 | base: CMD_CONFIG.base, 66 | body: CMD_CONFIG.body, 67 | title: CMD_CONFIG.title, 68 | }), 69 | }); 70 | 71 | expect(logger.info).toHaveBeenCalledWith(`Created pull request: ${PR_URL} (id: ${CREATED_PR_ID})`); 72 | }); 73 | 74 | test('undo closes pull request when it was created', async () => { 75 | const fetch = mockedFetch(); 76 | const logger = new Stubs.LoggerStub(); 77 | const gitTagCommandStub = new GithubCreatePullRequestCommandStub({ logger, fetch }); 78 | 79 | await gitTagCommandStub.do(); 80 | 81 | fetch.mockImplementation(async () => { 82 | return new Response('', { 83 | status: 200, 84 | statusText: 'OK', 85 | }); 86 | }); 87 | 88 | await gitTagCommandStub.undo(); 89 | 90 | expect(fetch).toBeCalledWith(UPDATE_PR_URL, { 91 | method: 'PATCH', 92 | 93 | headers: { 94 | ...CMD_CONFIG.headers, 95 | Accept: V3_MIME_TYPE, 96 | }, 97 | 98 | body: JSON.stringify({ 99 | state: 'closed', 100 | }), 101 | }); 102 | 103 | expect(logger.info).toHaveBeenCalledWith(`Closed pull request: ${PR_URL}`); 104 | }); 105 | 106 | test('execution fails when response status code is not 201', async () => { 107 | const fetch = mockedFetch(); 108 | const expectedStatusCode = 200; 109 | const gitTagCommandStub = new GithubCreatePullRequestCommandStub({ fetch }); 110 | const expectedError = new Error(`Failed to create pull request. Status code is ${expectedStatusCode}`); 111 | 112 | fetch.mockImplementation(async () => { 113 | return new Response('', { 114 | status: expectedStatusCode, 115 | statusText: 'OK', 116 | }); 117 | }); 118 | 119 | await expect(gitTagCommandStub.do()).rejects.toEqual(expectedError); 120 | }); 121 | 122 | test('undo logs a message when failing to close pull request', async () => { 123 | const fetch = mockedFetch(); 124 | const logger = new Stubs.LoggerStub(); 125 | const gitTagCommandStub = new GithubCreatePullRequestCommandStub({ logger, fetch }); 126 | 127 | await gitTagCommandStub.do(); 128 | 129 | await gitTagCommandStub.undo(); 130 | 131 | expect(logger.warn).toBeCalledWith(`Failed to close pull request ${PR_URL}. Status code is 201`); 132 | }); 133 | 134 | test('undo does not close pull request when it was not created', async () => { 135 | const fetch = mockedFetch(); 136 | const gitTagCommandStub = new GithubCreatePullRequestCommandStub({ fetch }); 137 | 138 | fetch.mockImplementation(async () => { 139 | return new Response('', { 140 | status: 403, 141 | statusText: 'FORBIDDEN', 142 | }); 143 | }); 144 | 145 | await gitTagCommandStub.do().catch(e => e); 146 | 147 | fetch.mockClear(); 148 | 149 | await gitTagCommandStub.undo(); 150 | 151 | expect(fetch).not.toHaveBeenCalled(); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /tests/unit/github-create-release-command.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | import to from 'await-to-js'; 4 | import { Readable } from 'node:stream'; 5 | import uriTemplate from 'uri-templates'; 6 | import { vitest, test, expect, describe, beforeEach } from 'vitest'; 7 | 8 | import { Stubs } from '../stubs'; 9 | import { Commands } from '../../src'; 10 | 11 | const fetch = vitest.fn(); 12 | 13 | const LOGGER = new Stubs.LoggerStub(); 14 | 15 | const CMD_CONFIG: Commands.GithubCreateReleaseCommandConfig = { 16 | logger: LOGGER, 17 | fetch, 18 | 19 | owner: 'nintendo', 20 | repo: 'super-mario', 21 | 22 | tagName: 'v123', 23 | name: 'v123', 24 | body: 'This is sparta', 25 | 26 | headers: { 27 | 'X-Custom-Header': '1', 28 | }, 29 | }; 30 | 31 | class GithubCreateReleaseCommandStub extends Commands.GithubCreateReleaseCommand { 32 | public constructor(config?: Partial) { 33 | super(Object.assign({}, CMD_CONFIG, config)); 34 | } 35 | } 36 | 37 | describe('github create release command', () => { 38 | const CREATED_RELEASE_ID = Date.now(); 39 | const V3_MIME_TYPE = 'application/vnd.github.v3+json'; 40 | const URLS = { 41 | CREATE_RELEASE: `https://api.github.com/repos/${CMD_CONFIG.owner}/${CMD_CONFIG.repo}/releases`, 42 | 43 | DELETE_RELEASE: `https://api.github.com/repos/${CMD_CONFIG.owner}/${CMD_CONFIG.repo}/releases/${CREATED_RELEASE_ID}`, 44 | 45 | RELEASE_HTML: `https://github.com/${CMD_CONFIG.owner}/${CMD_CONFIG.repo}/releases/tag/${CMD_CONFIG.tagName}`, 46 | 47 | RELEASE_RESOURCE: `https://api.github.com/repos/${CMD_CONFIG.owner}/${CMD_CONFIG.repo}/releases/${CREATED_RELEASE_ID}`, 48 | 49 | UPLOAD: `https://uploads.github.com/repos/${CMD_CONFIG.owner}/${CMD_CONFIG.repo}/releases/${CREATED_RELEASE_ID}/assets{?name,label}`, 50 | }; 51 | 52 | beforeEach(() => { 53 | fetch.mockImplementation(async (url: string) => { 54 | const body = JSON.stringify({ 55 | url: URLS.RELEASE_RESOURCE, 56 | upload_url: URLS.UPLOAD, 57 | html_url: URLS.RELEASE_HTML, 58 | id: CREATED_RELEASE_ID, 59 | }); 60 | 61 | const status = { 62 | status: 201, 63 | statusText: 'Created', 64 | }; 65 | 66 | if (url === URLS.RELEASE_RESOURCE) { 67 | status.status = 200; 68 | status.statusText = 'OK'; 69 | } 70 | 71 | return new Response(body, status); 72 | }); 73 | }); 74 | 75 | test('asset is uploaded with a label', async () => { 76 | const asset = { absoluteFilePath: __filename, label: 'LABEL' }; 77 | const expectedURL = uriTemplate(URLS.UPLOAD).fill({ 78 | name: path.basename(__filename), 79 | label: asset.label, 80 | }); 81 | const gitCommandStub = new GithubCreateReleaseCommandStub({ 82 | assets: [asset], 83 | }); 84 | const expectedRequest = { 85 | method: 'POST', 86 | 87 | headers: { 88 | 'X-Custom-Header': '1', 89 | 'Content-Type': 'video/mp2t', 90 | 'Content-Length': fs.statSync(__filename).size.toString(), 91 | 'Accept': V3_MIME_TYPE, 92 | }, 93 | 94 | body: expect.any(Readable), 95 | }; 96 | 97 | await gitCommandStub.do(); 98 | 99 | expect(fetch).toBeCalledWith(expectedURL, expect.objectContaining(expectedRequest)); 100 | }); 101 | 102 | test('release is created as pre release', async () => { 103 | const gitCommandStub = new GithubCreateReleaseCommandStub(); 104 | 105 | await gitCommandStub.do(); 106 | 107 | expect(LOGGER.info).toBeCalledWith(`Created release: ${URLS.RELEASE_HTML} (id: ${CREATED_RELEASE_ID})`); 108 | 109 | expect(fetch).toBeCalledWith(URLS.CREATE_RELEASE, { 110 | method: 'POST', 111 | 112 | headers: { 113 | ...CMD_CONFIG.headers, 114 | 'Accept': V3_MIME_TYPE, 115 | 'Content-Type': 'application/json', 116 | }, 117 | 118 | body: expect.stringMatching(JSON.stringify({ 119 | tag_name: CMD_CONFIG.tagName, 120 | name: CMD_CONFIG.name, 121 | body: CMD_CONFIG.body, 122 | draft: false, 123 | prerelease: true, 124 | })), 125 | }); 126 | }); 127 | 128 | test('release is not created as pre release', async () => { 129 | const gitCommandStub = new GithubCreateReleaseCommandStub({ 130 | isStable: true, 131 | }); 132 | 133 | await gitCommandStub.do(); 134 | 135 | expect(LOGGER.info).toBeCalledWith(`Created release: ${URLS.RELEASE_HTML} (id: ${CREATED_RELEASE_ID})`); 136 | 137 | expect(fetch).toBeCalledWith(URLS.CREATE_RELEASE, { 138 | method: 'POST', 139 | 140 | headers: { 141 | ...CMD_CONFIG.headers, 142 | 'Accept': V3_MIME_TYPE, 143 | 'Content-Type': 'application/json', 144 | }, 145 | 146 | body: expect.stringMatching(JSON.stringify({ 147 | tag_name: CMD_CONFIG.tagName, 148 | name: CMD_CONFIG.name, 149 | body: CMD_CONFIG.body, 150 | draft: false, 151 | prerelease: false, 152 | })), 153 | }); 154 | }); 155 | 156 | test('undo deletes release when it was created', async () => { 157 | const gitCommandStub = new GithubCreateReleaseCommandStub(); 158 | 159 | await gitCommandStub.do(); 160 | 161 | fetch.mockClear().mockImplementation(async () => { 162 | return new Response(null, { 163 | status: 204, 164 | statusText: 'No Content', 165 | }); 166 | }); 167 | 168 | await gitCommandStub.undo(); 169 | 170 | expect(LOGGER.info).toBeCalledWith(`Deleted release: ${URLS.RELEASE_HTML}`); 171 | 172 | expect(fetch).toBeCalledWith(URLS.DELETE_RELEASE, { 173 | method: 'DELETE', 174 | 175 | headers: { 176 | ...CMD_CONFIG.headers, 177 | Accept: V3_MIME_TYPE, 178 | }, 179 | }); 180 | }); 181 | 182 | test('assets with the same name are uploaded once', async () => { 183 | const asset = { absoluteFilePath: __filename }; 184 | const expectedURL = uriTemplate(URLS.UPLOAD).fill({ 185 | name: path.basename(__filename), 186 | }); 187 | const gitCommandStub = new GithubCreateReleaseCommandStub({ 188 | assets: [asset, asset], 189 | }); 190 | 191 | await gitCommandStub.do(); 192 | 193 | const uploads = fetch.mock.calls.filter((parameters) => { 194 | return parameters[0] === expectedURL; 195 | }); 196 | 197 | expect(uploads.length).toEqual(1); 198 | 199 | expect(LOGGER.warn).toBeCalledWith('Duplicate asset will be filtered out'); 200 | expect(LOGGER.warn).toBeCalledWith(`An asset named '${path.basename(__filename)}' already exists`); 201 | }); 202 | 203 | test('execution fails when response status code is not 201', async () => { 204 | const expectedStatusCode = 200; 205 | const gitCommandStub = new GithubCreateReleaseCommandStub(); 206 | 207 | fetch.mockClear().mockImplementation(async () => { 208 | return new Response('', { 209 | status: expectedStatusCode, 210 | statusText: 'OK', 211 | }); 212 | }); 213 | 214 | const [error] = await to(gitCommandStub.do()); 215 | 216 | expect(error).toEqual(new Error(`Failed to create release. Status code is ${expectedStatusCode}`)); 217 | }); 218 | 219 | test('undo does not delete release when it was not created', async () => { 220 | const gitCommandStub = new GithubCreateReleaseCommandStub(); 221 | 222 | fetch.mockClear().mockImplementation(async () => { 223 | return new Response('', { 224 | status: 403, 225 | statusText: 'OK', 226 | }); 227 | }); 228 | 229 | await to(gitCommandStub.do()); 230 | 231 | fetch.mockClear(); 232 | 233 | await gitCommandStub.undo(); 234 | 235 | expect(fetch).not.toHaveBeenCalled(); 236 | }); 237 | 238 | test('undo logs a message when failing to close pull request', async () => { 239 | const gitCommandStub = new GithubCreateReleaseCommandStub(); 240 | 241 | await gitCommandStub.do(); 242 | await gitCommandStub.undo(); 243 | 244 | expect(LOGGER.warn).toBeCalledWith(`Failed to delete release '${URLS.RELEASE_HTML}'. Status code is 200`); 245 | }); 246 | 247 | test('release is created in draft mode when there are assets', async () => { 248 | const gitCommandStub = new GithubCreateReleaseCommandStub({ 249 | assets: [{ absoluteFilePath: __filename, label: 'LABEL' }], 250 | }); 251 | 252 | await gitCommandStub.do(); 253 | 254 | expect(fetch).toBeCalledWith(URLS.CREATE_RELEASE, { 255 | method: 'POST', 256 | 257 | headers: { 258 | ...CMD_CONFIG.headers, 259 | 'Accept': 'application/vnd.github.v3+json', 260 | 'Content-Type': 'application/json', 261 | }, 262 | 263 | body: expect.stringMatching(JSON.stringify({ 264 | tag_name: CMD_CONFIG.tagName, 265 | name: CMD_CONFIG.name, 266 | body: CMD_CONFIG.body, 267 | draft: true, 268 | prerelease: true, 269 | })), 270 | }); 271 | }); 272 | 273 | test('release is taken out of draft mode (published) when uploading assets completes', async () => { 274 | const gitCommandStub = new GithubCreateReleaseCommandStub({ 275 | assets: [{ absoluteFilePath: __filename }], 276 | }); 277 | 278 | await gitCommandStub.do(); 279 | 280 | expect(fetch).toBeCalledWith(URLS.RELEASE_RESOURCE, { 281 | method: 'POST', 282 | 283 | headers: { 284 | ...CMD_CONFIG.headers, 285 | 'Accept': 'application/vnd.github.v3+json', 286 | 'Content-Type': 'application/json', 287 | }, 288 | 289 | body: expect.stringMatching(JSON.stringify({ 290 | draft: false, 291 | })), 292 | }); 293 | }); 294 | }); 295 | -------------------------------------------------------------------------------- /tests/unit/npm-bump-package-version-command.test.ts: -------------------------------------------------------------------------------- 1 | import { vitest, describe, test, expect } from 'vitest'; 2 | 3 | import { Stubs } from '../stubs'; 4 | import { Commands } from '../../src'; 5 | 6 | const PACKAGE_JSON = { 7 | private: false, 8 | name: 'test', 9 | version: '0.1.0', 10 | }; 11 | 12 | const CMD_CONFIG: Commands.NpmBumpPackageVersionCommandConfig = { 13 | logger: Stubs.NoopLogger.INSTANCE, 14 | 15 | version: '0.2.0', 16 | 17 | workingDirectory: '/fake/path', 18 | }; 19 | 20 | class NpmBumpPackageVersionCommandStub extends Commands.NpmBumpPackageVersionCommand { 21 | public constructor(config?: Partial) { 22 | super({ ...CMD_CONFIG, ...config }); 23 | } 24 | 25 | getPackageJson = vitest.fn(async () => { 26 | return PACKAGE_JSON; 27 | }); 28 | 29 | public createChildProcess = vitest.fn(async (_args: any) => { 30 | return { 31 | stdout: '', 32 | stderr: '', 33 | childProcess: Stubs.childProcess(), 34 | }; 35 | }); 36 | } 37 | 38 | describe('bumping package.json version', () => { 39 | const PACKAGE_JSON_PATH = `${CMD_CONFIG.workingDirectory}/package.json`; 40 | 41 | test('execution fails when package name is missing', async () => { 42 | const commandStub = new NpmBumpPackageVersionCommandStub(); 43 | const expectedError = `Package ${PACKAGE_JSON_PATH} 'version' or 'name' properties are missing`; 44 | 45 | commandStub.getPackageJson.mockImplementation(async () => { 46 | return { 47 | name: undefined as any, 48 | version: '1', 49 | private: true, 50 | }; 51 | }); 52 | 53 | return expect(commandStub.do()).rejects.toEqual(new Error(expectedError)); 54 | }); 55 | 56 | test('npm version command is called using \'version\'', async () => { 57 | const logger = new Stubs.LoggerStub(); 58 | const expectedVersion = `0.${Date.now()}.1`; 59 | const commandStub = new NpmBumpPackageVersionCommandStub({ 60 | logger, 61 | version: expectedVersion, 62 | }); 63 | 64 | commandStub.getPackageJson.mockImplementationOnce(async () => { 65 | return PACKAGE_JSON; 66 | }); 67 | 68 | commandStub.getPackageJson.mockImplementationOnce(async () => { 69 | return { 70 | ...PACKAGE_JSON, 71 | version: expectedVersion, 72 | }; 73 | }); 74 | 75 | await commandStub.do(); 76 | 77 | expect(logger.info).toBeCalledWith(`Changed package '${PACKAGE_JSON.name}' version to '${expectedVersion}'`); 78 | 79 | expect(commandStub.createChildProcess).toBeCalledWith(`npm version ${expectedVersion} --no-git-tag-version`, { 80 | cwd: CMD_CONFIG.workingDirectory, 81 | 82 | }); 83 | }); 84 | 85 | test('execution fails when package version is missing', async () => { 86 | const commandStub = new NpmBumpPackageVersionCommandStub(); 87 | const expectedError = `Package ${PACKAGE_JSON_PATH} 'version' or 'name' properties are missing`; 88 | 89 | commandStub.getPackageJson.mockImplementation(async () => { 90 | return { 91 | ...PACKAGE_JSON, 92 | version: undefined as any, 93 | }; 94 | }); 95 | 96 | await expect(commandStub.do()).rejects.toEqual(new Error(expectedError)); 97 | }); 98 | 99 | test('npm version command is called using \'preReleaseId\'', async () => { 100 | const logger = new Stubs.LoggerStub(); 101 | const expectedPreReleaseId = 'beta'; 102 | const expectedVersion = `0.${Date.now()}.0-${expectedPreReleaseId}.0`; 103 | const commandStub = new NpmBumpPackageVersionCommandStub({ 104 | logger, 105 | preReleaseId: expectedPreReleaseId, 106 | }); 107 | const expectedArgs = ['version', 'prerelease', `--preid=${expectedPreReleaseId}`, '--no-git-tag-version']; 108 | 109 | commandStub.getPackageJson.mockImplementation(async () => { 110 | return { 111 | ...PACKAGE_JSON, 112 | version: expectedVersion, 113 | }; 114 | }); 115 | 116 | await commandStub.do(); 117 | 118 | expect(commandStub.createChildProcess).toBeCalledWith(`npm ${expectedArgs.join(' ')}`, { 119 | cwd: CMD_CONFIG.workingDirectory, 120 | 121 | }); 122 | 123 | expect(logger.info).toBeCalledWith(`Changed package '${PACKAGE_JSON.name}' version to '${expectedVersion}'`); 124 | }); 125 | 126 | test('undo reverts to initial version when version was bumped', async () => { 127 | const logger = new Stubs.LoggerStub(); 128 | const commandStub = new NpmBumpPackageVersionCommandStub({ 129 | logger, 130 | version: `0.${Date.now()}.0`, 131 | }); 132 | const expectedArgs = ['version', PACKAGE_JSON.version, '--no-git-tag-version']; 133 | 134 | await commandStub.do(); 135 | 136 | await commandStub.undo(); 137 | 138 | expect(commandStub.createChildProcess).toBeCalledWith(`npm ${expectedArgs.join(' ')}`, { 139 | cwd: CMD_CONFIG.workingDirectory, 140 | 141 | }); 142 | 143 | expect(logger.info).toBeCalledWith(`Reverted '${PACKAGE_JSON.name}' version back to '${PACKAGE_JSON.version}'`); 144 | }); 145 | 146 | test('undo does not revert to initial version when version was not bumped', async () => { 147 | const commandStub = new NpmBumpPackageVersionCommandStub({ 148 | version: PACKAGE_JSON.version, 149 | }); 150 | const expectedArgs = ['version', PACKAGE_JSON.version, '--no-git-tag-version']; 151 | 152 | commandStub.getPackageJson.mockImplementation(async () => { 153 | return PACKAGE_JSON; 154 | }); 155 | 156 | await commandStub.do().catch(e => e); 157 | 158 | await commandStub.undo(); 159 | 160 | expect(commandStub.createChildProcess).not.toBeCalledWith(`npm ${expectedArgs.join(' ')}`, { 161 | cwd: CMD_CONFIG.workingDirectory, 162 | 163 | }); 164 | }); 165 | }); 166 | -------------------------------------------------------------------------------- /tests/unit/npm-publish-package-command.test.ts: -------------------------------------------------------------------------------- 1 | import { vitest, describe, test, expect } from 'vitest'; 2 | 3 | import { Stubs } from '../stubs.js'; 4 | import { Commands } from '../../src/index.js'; 5 | 6 | const PACKAGE_JSON = { 7 | private: false, 8 | name: '@scope/name', 9 | version: '0.1.0', 10 | }; 11 | 12 | const CMD_CONFIG: Commands.NpmPublishPackageCommandConfig = { 13 | logger: Stubs.NoopLogger.INSTANCE, 14 | 15 | tag: 'dummy', 16 | 17 | workingDirectory: '/fake/path', 18 | }; 19 | 20 | class NpmPublishPackageCommandStub extends Commands.NpmPublishPackageCommand { 21 | public constructor(config?: Partial) { 22 | super({ ...CMD_CONFIG, ...config }); 23 | } 24 | 25 | getPackageJson = vitest.fn(async () => { 26 | return PACKAGE_JSON; 27 | }); 28 | 29 | public createChildProcess = vitest.fn(async (_args: any) => { 30 | return { 31 | stdout: '', 32 | stderr: '', 33 | childProcess: Stubs.childProcess(), 34 | }; 35 | }); 36 | } 37 | 38 | describe('publish npm package', () => { 39 | const PACKAGE_NAME_AND_VERSION = `${PACKAGE_JSON.name}@${PACKAGE_JSON.version}`; 40 | 41 | test('errors when exit code is not 0', async () => { 42 | const code = 127; 43 | const commandStub = new NpmPublishPackageCommandStub(); 44 | 45 | commandStub.createChildProcess.mockImplementationOnce(async () => { 46 | const childProcess = Stubs.childProcess(); 47 | 48 | // @ts-expect-error ignore this 49 | childProcess.exitCode = code; 50 | 51 | return { 52 | stderr: '', 53 | stdout: '', 54 | childProcess, 55 | }; 56 | }); 57 | 58 | await expect(commandStub.do()).rejects.toStrictEqual(new Error(`Publish failed. exit code is ${code}`)); 59 | }); 60 | 61 | test('publishing to a specified dist tag', async () => { 62 | const logger = new Stubs.LoggerStub(); 63 | const commandStub = new NpmPublishPackageCommandStub({ 64 | logger, 65 | }); 66 | 67 | await commandStub.do(); 68 | 69 | expect(logger.info).toBeCalledWith(`Publishing '${PACKAGE_NAME_AND_VERSION}' using dist tag '${CMD_CONFIG.tag}'`); 70 | 71 | expect(commandStub.createChildProcess).toBeCalledWith({ command: 'npm', args: ['publish', '--tag', CMD_CONFIG.tag] }, { 72 | cwd: CMD_CONFIG.workingDirectory, 73 | }); 74 | }); 75 | 76 | test('publishing to a specified registry', async () => { 77 | const logger = new Stubs.LoggerStub(); 78 | const expectedRegistry = 'https://npm.local.registry'; 79 | const commandStub = new NpmPublishPackageCommandStub({ 80 | logger, 81 | tag: undefined, 82 | registry: expectedRegistry, 83 | }); 84 | 85 | await commandStub.do(); 86 | 87 | expect(logger.info).toBeCalledWith(`Publishing '${PACKAGE_NAME_AND_VERSION}' to registry '${expectedRegistry}'`); 88 | 89 | expect(commandStub.createChildProcess).toBeCalledWith({ command: 'npm', args: ['publish', '--registry', expectedRegistry] }, { 90 | cwd: CMD_CONFIG.workingDirectory, 91 | }); 92 | }); 93 | 94 | test('publishing is skipped when package is private', async () => { 95 | const logger = new Stubs.LoggerStub(); 96 | const commandStub = new NpmPublishPackageCommandStub({ logger }); 97 | 98 | commandStub.getPackageJson.mockImplementation(async () => { 99 | return { 100 | ...PACKAGE_JSON, 101 | private: true, 102 | }; 103 | }); 104 | 105 | await commandStub.do(); 106 | 107 | expect(commandStub.createChildProcess).not.toBeCalled(); 108 | 109 | expect(logger.info).toBeCalledWith(`Skipping publish. Package '${PACKAGE_JSON.name}' private property is true.`); 110 | }); 111 | 112 | test('undo does not unpublish when package was not published', async () => { 113 | const expectedError = new Error('Some failure'); 114 | const commandStub = new NpmPublishPackageCommandStub(); 115 | 116 | commandStub.createChildProcess.mockImplementationOnce(async () => { 117 | throw expectedError; 118 | }); 119 | 120 | const error = await commandStub.do().catch(e => e); 121 | 122 | await commandStub.undo(); 123 | 124 | expect(error).toEqual(error); 125 | 126 | expect(commandStub.createChildProcess).not.toBeCalledWith('npm unpublish', { 127 | cwd: CMD_CONFIG.workingDirectory, 128 | 129 | }); 130 | }); 131 | 132 | test('undo does not unpublish when "undoPublish" is not true', async () => { 133 | const commandStub = new NpmPublishPackageCommandStub(); 134 | 135 | await commandStub.do(); 136 | 137 | await commandStub.undo(); 138 | 139 | expect(commandStub.createChildProcess).not.toBeCalledWith('npm unpublish', { 140 | cwd: CMD_CONFIG.workingDirectory, 141 | 142 | }); 143 | }); 144 | 145 | test('undo unpublish when package was published and "undoPublish" is true', async () => { 146 | const commandStub = new NpmPublishPackageCommandStub({ 147 | undoPublish: true, 148 | }); 149 | 150 | await commandStub.do(); 151 | 152 | await commandStub.undo(); 153 | 154 | expect(commandStub.createChildProcess).toBeCalledWith({ command: 'npm', args: ['unpublish', PACKAGE_NAME_AND_VERSION] }, { 155 | cwd: CMD_CONFIG.workingDirectory, 156 | }); 157 | }); 158 | }); 159 | -------------------------------------------------------------------------------- /tests/unit/strategy.test.ts: -------------------------------------------------------------------------------- 1 | import { vitest, describe, test, expect, beforeEach } from 'vitest'; 2 | 3 | import { SDK } from '../../src/index.js'; 4 | 5 | import { Stubs } from '../stubs.js'; 6 | 7 | const LOGGER = new Stubs.LoggerStub(); 8 | 9 | describe('strategy', () => { 10 | let release: InstanceType; 11 | let strategy: InstanceType; 12 | 13 | beforeEach(async () => { 14 | release = new Stubs.ReleaseStub(); 15 | 16 | strategy = new Stubs.StrategyStub({ 17 | logger: LOGGER, 18 | release, 19 | test: 1, 20 | }); 21 | 22 | release.getNextVersion.mockImplementation(() => '2.0.0'); 23 | 24 | release.getPreviousVersion.mockImplementation(() => '1.0.0'); 25 | }); 26 | 27 | test('config state', async () => { 28 | expect(strategy).toMatchSnapshot(); 29 | }); 30 | 31 | test('commands cleanup is invoked', async () => { 32 | const commandA = new Stubs.CommandA(); 33 | const commandB = new Stubs.CommandB(); 34 | const cleanupA = vitest.spyOn(commandA, 'cleanup'); 35 | const cleanupB = vitest.spyOn(commandB, 'cleanup'); 36 | 37 | strategy.addCommandProvider(async () => { 38 | return commandA; 39 | }); 40 | 41 | strategy.addCommandProvider(async () => { 42 | return commandB; 43 | }); 44 | 45 | await strategy.run(); 46 | 47 | expect(cleanupA).toBeCalledTimes(1); 48 | 49 | expect(cleanupB).toBeCalledTimes(1); 50 | }); 51 | 52 | test('command error is thrown and logged', async () => { 53 | const expectedError = new Error('Error'); 54 | 55 | // @ts-expect-error protected method can be spied 56 | vitest.spyOn(strategy, 'getCommands').mockImplementationOnce(() => { 57 | return [ 58 | { 59 | getName() { 60 | return 'Dummy Command'; 61 | }, 62 | 63 | do() { 64 | return Promise.reject(expectedError); 65 | }, 66 | 67 | undo() { 68 | return Promise.resolve(); 69 | }, 70 | }, 71 | ]; 72 | }); 73 | 74 | await expect(strategy.run()).rejects.toStrictEqual(expectedError); 75 | 76 | expect(process.exitCode).toStrictEqual(1); 77 | 78 | expect(LOGGER.error).toBeCalledWith('Strategy execution failed'); 79 | }); 80 | 81 | test('undo is invoked for executed commands', async () => { 82 | const commandA = new Stubs.CommandA(); 83 | const commandB = new Stubs.CommandB(); 84 | const undoA = vitest.spyOn(commandA, 'undo'); 85 | const undoB = vitest.spyOn(commandB, 'undo'); 86 | 87 | strategy.addCommandProvider(async () => { 88 | return commandA; 89 | }); 90 | 91 | strategy.addCommandProvider(async () => { 92 | return commandB; 93 | }); 94 | 95 | vitest.spyOn(commandB, 'do').mockImplementation(async () => { 96 | throw new Error('shit'); 97 | }); 98 | 99 | await strategy.run().catch(c => c); 100 | 101 | expect(undoA).toBeCalledTimes(1); 102 | 103 | expect(undoB).toBeCalledTimes(1); 104 | }); 105 | 106 | test('commands are executed in specified order', async () => { 107 | const commandA = new Stubs.CommandA(); 108 | const commandB = new Stubs.CommandB(); 109 | const executedOrder: SDK.Command[] = []; 110 | 111 | strategy.addCommandProvider(async () => { 112 | return commandA; 113 | }); 114 | 115 | strategy.addCommandProvider(async () => { 116 | return commandB; 117 | }); 118 | 119 | vitest.spyOn(commandA, 'do').mockImplementation(async () => { 120 | executedOrder.push(commandA); 121 | }); 122 | 123 | vitest.spyOn(commandB, 'do').mockImplementation(async () => { 124 | executedOrder.push(commandB); 125 | }); 126 | 127 | await strategy.run(); 128 | 129 | expect(executedOrder).toStrictEqual([commandA, commandB]); 130 | }); 131 | 132 | test('does not run when version has not changed', async () => { 133 | // @ts-expect-error protected method can be spied 134 | const shouldRunSpy = vitest.spyOn(strategy, 'shouldRun'); 135 | 136 | release.getNextVersion.mockImplementationOnce(() => '2.0.0'); 137 | 138 | release.getPreviousVersion.mockImplementationOnce(() => '2.0.0'); 139 | 140 | await strategy.run(); 141 | 142 | expect(await shouldRunSpy.mock.results[0].value).toStrictEqual(false); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 2 | { 3 | "compilerOptions": { 4 | "lib": [], 5 | "target": "esnext", 6 | "module": "nodenext", 7 | "moduleResolution": "nodenext", 8 | 9 | "outDir": "./build", 10 | "incremental": true, 11 | "declaration": true, 12 | "removeComments": false, 13 | 14 | "baseUrl": ".", 15 | "allowJs": false, 16 | "esModuleInterop": true, 17 | "strictNullChecks": true, 18 | "forceConsistentCasingInFileNames": true, 19 | 20 | "paths": { 21 | "conventional-commits-parser": [ 22 | "node_modules/conventional-commits-parser/dist", 23 | ], 24 | "conventional-changelog-writer": [ 25 | "node_modules/conventional-changelog-writer/dist" 26 | ], 27 | "conventional-changelog-preset-loader": [ 28 | "node_modules/conventional-changelog-preset-loader/dist" 29 | ] 30 | }, 31 | }, 32 | "include": [ 33 | "src", 34 | ], 35 | "exclude": [ 36 | "node_modules" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ['tests/**/*.test.ts'], 6 | 7 | coverage: { 8 | all: true, 9 | include: ['src/**'], 10 | provider: 'v8', 11 | enabled: Boolean(process.env.CI), 12 | reporter: ['text'], 13 | }, 14 | }, 15 | }); 16 | --------------------------------------------------------------------------------