├── .editorconfig ├── .eslintrc.js ├── .gitattributes ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ ├── build-lint-test.yml │ ├── create-release-pr.yml │ ├── publish-release.yml │ ├── scripts │ └── update-major-version-tag.sh │ ├── security-code-scanner.yml │ └── shellcheck.yml ├── .gitignore ├── .nvmrc ├── .prettierrc.js ├── .yarnrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── action.yml ├── dist └── index.js ├── jest.config.js ├── package.json ├── scripts └── create-release-pr.sh ├── src ├── git-operations.test.ts ├── git-operations.ts ├── index.test.ts ├── index.ts ├── package-operations.test.ts ├── package-operations.ts ├── update.test.ts ├── update.ts ├── utils.test.ts └── utils.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.md] 13 | indent_size = 4 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | 4 | extends: ['@metamask/eslint-config', '@metamask/eslint-config-nodejs'], 5 | 6 | rules: { 7 | 'n/no-process-env': 'off', 8 | }, 9 | 10 | overrides: [ 11 | { 12 | files: ['**/*.ts'], 13 | extends: ['@metamask/eslint-config-typescript'], 14 | rules: { 15 | '@typescript-eslint/consistent-type-definitions': [ 16 | 'error', 17 | 'interface', 18 | ], 19 | '@typescript-eslint/naming-convention': 'off', 20 | '@typescript-eslint/no-shadow': ['error', { builtinGlobals: true }], 21 | 'no-shadow': 'off', 22 | }, 23 | }, 24 | { 25 | files: ['**/*.d.ts'], 26 | rules: { 27 | 'import/unambiguous': 'off', 28 | }, 29 | }, 30 | { 31 | files: ['**/*.test.js', '**/*.test.ts'], 32 | extends: ['@metamask/eslint-config-jest'], 33 | rules: { 34 | '@typescript-eslint/restrict-template-expressions': 'off', 35 | }, 36 | }, 37 | ], 38 | 39 | ignorePatterns: ['!.eslintrc.js', 'lib/', 'dist/'], 40 | }; 41 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | # Reviewing the lockfile contents is an important step in verifying that 4 | # we're using the dependencies we expect to be using 5 | yarn.lock linguist-generated=false 6 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Lines starting with '#' are comments. 2 | # Each line is a file pattern followed by one or more owners. 3 | 4 | * @MetaMask/wallet-framework-engineers 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: 'npm' 7 | directory: '/' 8 | schedule: 9 | interval: 'daily' 10 | time: '06:00' 11 | allow: 12 | - dependency-name: '@metamask/*' 13 | target-branch: 'main' 14 | versioning-strategy: 'increase-if-necessary' 15 | open-pull-requests-limit: 10 16 | -------------------------------------------------------------------------------- /.github/workflows/build-lint-test.yml: -------------------------------------------------------------------------------- 1 | name: Build, Lint, and Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | 8 | jobs: 9 | build-lint-test: 10 | runs-on: ubuntu-20.04 11 | steps: 12 | - uses: actions/checkout@v3 13 | - uses: actions/setup-node@v3 14 | with: 15 | node-version-file: '.nvmrc' 16 | - run: yarn install --frozen-lockfile 17 | - run: yarn setup:postinstall 18 | - run: yarn build 19 | # below step will fail if there're still changes to commit after "yarn build" 20 | # see if dist/index.js modified after "yarn build". 21 | - run: git diff --quiet || { echo 'working directory dirty after "yarn build"'; exit 1; } 22 | - run: yarn lint 23 | - run: yarn test 24 | 25 | all-tests-pass: 26 | runs-on: ubuntu-20.04 27 | needs: build-lint-test 28 | steps: 29 | - run: echo "Great success" 30 | -------------------------------------------------------------------------------- /.github/workflows/create-release-pr.yml: -------------------------------------------------------------------------------- 1 | name: Create Release Pull Request 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | base-branch: 7 | description: 'The base branch for git operations and the pull request.' 8 | default: 'main' 9 | required: true 10 | release-type: 11 | description: 'A SemVer version diff, i.e. major, minor, patch, prerelease etc. Mutually exclusive with "release-version".' 12 | required: false 13 | release-version: 14 | description: 'A specific version to bump to. Mutually exclusive with "release-type".' 15 | required: false 16 | 17 | jobs: 18 | create-release-pr: 19 | runs-on: ubuntu-latest 20 | permissions: 21 | contents: write 22 | pull-requests: write 23 | steps: 24 | - uses: actions/checkout@v3 25 | with: 26 | # This is to guarantee that the most recent tag is fetched. 27 | # This can be configured to a more reasonable value by consumers. 28 | fetch-depth: 0 29 | # We check out the specified branch, which will be used as the base 30 | # branch for all git operations and the release PR. 31 | ref: ${{ github.event.inputs.base-branch }} 32 | - name: Get Node.js version 33 | id: nvm 34 | run: echo "NODE_VERSION=$(cat .nvmrc)" >> "$GITHUB_OUTPUT" 35 | - uses: actions/setup-node@v3 36 | with: 37 | node-version: ${{ steps.nvm.outputs.NODE_VERSION }} 38 | - uses: ./ 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 41 | with: 42 | release-type: ${{ github.event.inputs.release-type }} 43 | release-version: ${{ github.event.inputs.release-version }} 44 | artifacts-path: gh-action__release-authors 45 | # Upload the release author artifact for use in subsequent workflows 46 | - uses: actions/upload-artifact@v3 47 | with: 48 | name: release-authors 49 | path: gh-action__release-authors 50 | if-no-files-found: error 51 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | 3 | on: 4 | pull_request: 5 | types: [closed] 6 | 7 | jobs: 8 | publish-release: 9 | permissions: 10 | contents: write 11 | if: | 12 | github.event.pull_request.merged == true && 13 | startsWith(github.event.pull_request.head.ref, 'release/') 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | with: 18 | # This is to guarantee that the most recent tag is fetched, 19 | # which we need for updating the shorthand major version tag. 20 | fetch-depth: 0 21 | # We check out the release pull request's base branch, which will be 22 | # used as the base branch for all git operations. 23 | ref: ${{ github.event.pull_request.base.ref }} 24 | - name: Get Node.js version 25 | id: nvm 26 | run: echo "NODE_VERSION=$(cat .nvmrc)" >> "$GITHUB_OUTPUT" 27 | - uses: actions/setup-node@v3 28 | with: 29 | node-version: ${{ steps.nvm.outputs.NODE_VERSION }} 30 | - uses: MetaMask/action-publish-release@v1 31 | id: publish-release 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | - name: Update shorthand major version tag 35 | run: | 36 | ./.github/workflows/scripts/update-major-version-tag.sh \ 37 | ${{ steps.publish-release.outputs.release-version }} 38 | -------------------------------------------------------------------------------- /.github/workflows/scripts/update-major-version-tag.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -x 4 | set -e 5 | set -o pipefail 6 | 7 | RELEASE_VERSION="${1}" 8 | 9 | if [[ -z $RELEASE_VERSION ]]; then 10 | echo "Error: No release version specified." 11 | exit 1 12 | fi 13 | 14 | MAJOR_VERSION_TAG="v${RELEASE_VERSION/\.*/}" 15 | 16 | git config user.name github-actions 17 | git config user.email github-actions@github.com 18 | 19 | if git show-ref --tags "$MAJOR_VERSION_TAG" --quiet; then 20 | echo "Tag \"${MAJOR_VERSION_TAG}\" exists, attempting to delete it." 21 | git tag --delete "$MAJOR_VERSION_TAG" 22 | git push --delete origin "$MAJOR_VERSION_TAG" 23 | else 24 | echo "Tag \"${MAJOR_VERSION_TAG}\" does not exist, creating it from scratch." 25 | fi 26 | 27 | git tag "$MAJOR_VERSION_TAG" HEAD 28 | git push --tags 29 | echo "Updated shorthand major version tag." 30 | 31 | echo "MAJOR_VERSION_TAG=$MAJOR_VERSION_TAG" >> "$GITHUB_OUTPUT" 32 | -------------------------------------------------------------------------------- /.github/workflows/security-code-scanner.yml: -------------------------------------------------------------------------------- 1 | name: MetaMask Security Code Scanner 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | workflow_dispatch: 11 | 12 | jobs: 13 | run-security-scan: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | actions: read 17 | contents: read 18 | security-events: write 19 | steps: 20 | - name: MetaMask Security Code Scanner 21 | uses: MetaMask/Security-Code-Scanner@main 22 | with: 23 | repo: ${{ github.repository }} 24 | paths_ignored: | 25 | .storybook/ 26 | '**/__snapshots__/' 27 | '**/*.snap' 28 | '**/*.stories.js' 29 | '**/*.stories.tsx' 30 | '**/*.test.browser.ts*' 31 | '**/*.test.js*' 32 | '**/*.test.ts*' 33 | '**/fixtures/' 34 | '**/jest.config.js' 35 | '**/jest.environment.js' 36 | '**/mocks/' 37 | '**/test*/' 38 | docs/ 39 | e2e/ 40 | merged-packages/ 41 | node_modules 42 | storybook/ 43 | test*/ 44 | rules_excluded: example 45 | project_metrics_token: ${{ secrets.SECURITY_SCAN_METRICS_TOKEN }} 46 | slack_webhook: ${{ secrets.APPSEC_BOT_SLACK_WEBHOOK }} 47 | -------------------------------------------------------------------------------- /.github/workflows/shellcheck.yml: -------------------------------------------------------------------------------- 1 | name: shellcheck 2 | 3 | permissions: 4 | checks: write 5 | 6 | on: [push] 7 | 8 | jobs: 9 | lint: 10 | name: lint 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@master 14 | - name: ShellCheck Action 15 | uses: fearphage/shellcheck-action@95d2a3d34d381a7314c286ea1725ca8cce3b51fd 16 | env: 17 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | lib/ 3 | 4 | # Editors 5 | .vscode/ 6 | .idea/ 7 | *.iml 8 | 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | 16 | # Runtime data 17 | pids 18 | *.pid 19 | *.seed 20 | *.pid.lock 21 | 22 | # Directory for instrumented libs generated by jscoverage/JSCover 23 | lib-cov 24 | 25 | # Coverage directory used by tools like istanbul 26 | coverage 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Compiled binary addons (https://nodejs.org/api/addons.html) 32 | build/Release 33 | 34 | # Optional npm cache directory 35 | .npm 36 | 37 | # Optional eslint cache 38 | .eslintcache 39 | 40 | # Optional REPL history 41 | .node_repl_history 42 | 43 | # Output of 'npm pack' 44 | *.tgz 45 | 46 | # Yarn Integrity file 47 | .yarn-integrity 48 | 49 | # dotenv environment variables file 50 | .env 51 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 2 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | // All of these are defaults except singleQuote, but we specify them 2 | // for explicitness 3 | module.exports = { 4 | quoteProps: 'as-needed', 5 | singleQuote: true, 6 | tabWidth: 2, 7 | trailingComma: 'all', 8 | }; 9 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | ignore-scripts true 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [4.0.0] 11 | 12 | ### Changed 13 | 14 | - **BREAKING:** Bump minimum Node.js version to `^18.18` ([#142](https://github.com/MetaMask/action-create-release-pr/pull/142)) 15 | - **BREAKING:** Bump `@metamask/auto-changelog` to `^4.0.0` ([#142](https://github.com/MetaMask/action-create-release-pr/pull/142)) 16 | - This requires `prettier@>=3.0.0`. 17 | 18 | ## [3.0.1] 19 | 20 | ### Changed 21 | 22 | - build(deps): bump @metamask/auto-changelog from 3.3.0 to 3.4.4 ([#127](https://github.com/MetaMask/action-create-release-pr/pull/127), [#124](https://github.com/MetaMask/action-create-release-pr/pull/124)) 23 | 24 | ### Fixed 25 | 26 | - Fixed `updatePackageChangelog` to not throw when there are unreleased changes ([#139](https://github.com/MetaMask/action-create-release-pr/pull/139)) 27 | 28 | ## [3.0.0] 29 | 30 | ### Changed 31 | 32 | - **BREAKING**: Bump minimum Node version to 18 ([#118](https://github.com/MetaMask/action-create-release-pr/pull/118)) 33 | - **BREAKING**: Format changelog using Prettier ([#116](https://github.com/MetaMask/action-create-release-pr/pull/116)) 34 | 35 | ## [2.0.0] 36 | 37 | ### Changed 38 | 39 | - **BREAKING**: Bump minimum Node version to 16 ([#111](https://github.com/MetaMask/action-create-release-pr/pull/111)) 40 | - build(deps): bump @metamask/auto-changelog from 2.6.0 to 3.1.0 ([#105](https://github.com/MetaMask/action-create-release-pr/pull/105)) 41 | 42 | ## [1.5.0] 43 | 44 | ### Changed 45 | 46 | - Check for `workspace:^` before replacing the version in package.json ([#108](https://github.com/MetaMask/action-create-release-pr/pull/108)) 47 | 48 | ## [1.4.3] 49 | 50 | ### Changed 51 | 52 | - Bump `@actions/core` ([#102](https://github.com/MetaMask/action-create-release-pr/pull/102)) 53 | 54 | ## [1.4.2] 55 | 56 | ### Changed 57 | 58 | - Resolve GitHub action deprecation warnings ([#98](https://github.com/MetaMask/action-create-release-pr/pull/98), [#100](https://github.com/MetaMask/action-create-release-pr/pull/100)) 59 | - GitHub actions have been updated to v3, and the deprecated `set-output` command is no longer used. 60 | 61 | ## [1.4.1] 62 | 63 | ### Fixed 64 | 65 | - Handle GitHub being slow to create a pull request ([#96](https://github.com/MetaMask/action-create-release-pr/pull/96)) 66 | 67 | ## [1.4.0] 68 | 69 | ### Changed 70 | 71 | - Resolve workspaces recursively for better Yarn 3 support ([#85](https://github.com/MetaMask/action-create-release-pr/pull/85)) 72 | 73 | ## [1.3.0] 74 | 75 | ### Fixed 76 | 77 | - Synchronize monorepo packages for versions in range 0.x.x ([#80](https://github.com/MetaMask/action-create-release-pr/pull/80)) 78 | - Before this change, monorepo packages on major version `0` would break if any sibling packages contained breaking changes, which is permitted by SemVer. 79 | 80 | ## [1.2.0] 81 | 82 | ### Added 83 | 84 | - Instructions for adding new packages to monorepos ([#74](https://github.com/MetaMask/action-create-release-pr/pull/74)) 85 | - Draft PR status option ([#76](https://github.com/MetaMask/action-create-release-pr/pull/76)) 86 | 87 | ## [1.1.0] 88 | 89 | ### Added 90 | 91 | - Write file identifying the initiator of the workflow using this action ([#72](https://github.com/MetaMask/action-create-release-pr/pull/72)) 92 | - This is can be used to upload the artifact required by [MetaMask/action-require-additional-reviewer](https://github.com/MetaMask/action-require-additional-reviewer) in subsequent workflows. 93 | 94 | ### Changed 95 | 96 | - Prevent `Uncategorized` change category from appearing in new releases ([#69](https://github.com/MetaMask/action-create-release-pr/pull/69)) 97 | 98 | ## [1.0.2] 99 | 100 | ### Changed 101 | 102 | - Bump `@metamask/auto-changelog` from `2.3.0` to `2.4.0` ([#59](https://github.com/MetaMask/action-create-release-pr/pull/59)) 103 | 104 | ## [1.0.1] 105 | 106 | ### Changed 107 | 108 | - Improve usage instructions ([#53](https://github.com/MetaMask/action-create-release-pr/pull/53)) 109 | 110 | ### Fixed 111 | 112 | - Error logging on Action failure ([#56](https://github.com/MetaMask/action-create-release-pr/pull/56)) 113 | 114 | ## [1.0.0] 115 | 116 | ### Uncategorized 117 | 118 | - First stable release 119 | 120 | ### Changed 121 | 122 | - Default release branch prefix ([#49](https://github.com/MetaMask/action-create-release-pr/pull/49)) 123 | - The default prefix is now `release/`, matching [`action-publish-release@v1`](https://github.com/MetaMask/action-publish-release). 124 | 125 | ### Fixed 126 | 127 | - Faulty usage instructions in readme ([#48](https://github.com/MetaMask/action-create-release-pr/pull/48)) 128 | 129 | ## [0.1.1] 130 | 131 | ### Fixed 132 | 133 | - This action ([#42](https://github.com/MetaMask/action-create-release-pr/pull/42)) 134 | - Due to a poorly formatted Bash invocation in `action.yml`, this Action was completely broken. 135 | 136 | ## [0.1.0] 137 | 138 | ### Added 139 | 140 | - Add `release-branch-prefix` input ([#38](https://github.com/MetaMask/action-create-release-pr/pull/38)) 141 | - This matches the name of the corresponding input to [MetaMask/action-publish-release@v0.1.0](https://github.com/MetaMask/action-publish-release). 142 | 143 | ### Changed 144 | 145 | - Remove " RC" suffix from PR title ([#39](https://github.com/MetaMask/action-create-release-pr/pull/39)) 146 | - The PR title is now just the SemVer version of the release. 147 | 148 | ## [0.0.20] 149 | 150 | ### Changed 151 | 152 | - **(BREAKING)** Change release branch prefix from 'release-v' to 'automation_release-' ([#35](https://github.com/MetaMask/action-create-release-pr/pull/35)) 153 | 154 | ### Fixed 155 | 156 | - Changelog updating in monorepos and repositories with merge commits ([#33](https://github.com/MetaMask/action-create-release-pr/pull/33)) 157 | - Done by updating `@metamask/auto-changelog` to `2.3.0`. See [MetaMask/auto-changelog#87](https://github.com/MetaMask/auto-changelog/pull/87) for details. 158 | 159 | ## [0.0.19] 160 | 161 | ### Fixed 162 | 163 | - Monorepo package diffing ([#31](https://github.com/MetaMask/action-create-release-pr/pull/31)) 164 | - In ([#20](https://github.com/MetaMask/action-create-release-pr/pull/20)), we started returning the cached result for the first diffed package for every diffed package. 165 | 166 | ## [0.0.18] 167 | 168 | ### Fixed 169 | 170 | - Changelog updating ([#28](https://github.com/MetaMask/action-create-release-pr/pull/28)) 171 | - The updated changelog content was never written to disk. 172 | 173 | ## [0.0.17] 174 | 175 | ### Changed 176 | 177 | - **(BREAKING)** Re-implement in TypeScript, add monorepo support ([#15](https://github.com/MetaMask/action-create-release-pr/pull/15)) 178 | - Use `workspaces` manifest entry to find workspaces ([#20](https://github.com/MetaMask/action-create-release-pr/pull/20)) 179 | - Add `@lavamoat/allow-scripts` and `setup` command ([#21](https://github.com/MetaMask/action-create-release-pr/pull/21)) 180 | - Add README description ([#22](https://github.com/MetaMask/action-create-release-pr/pull/22)) 181 | - Remove package manager restriction ([#23](https://github.com/MetaMask/action-create-release-pr/pull/23)) 182 | - Previously, use of Yarn `^1.0.0` was mandated. 183 | - Migrate various utilities to `@metamask/action-utils` ([#24](https://github.com/MetaMask/action-create-release-pr/pull/24)) 184 | 185 | ## [0.0.16] 186 | 187 | ### Uncategorized 188 | 189 | - First semi-stable release. Polyrepos only. 190 | 191 | [Unreleased]: https://github.com/MetaMask/action-create-release-pr/compare/v4.0.0...HEAD 192 | [4.0.0]: https://github.com/MetaMask/action-create-release-pr/compare/v3.0.1...v4.0.0 193 | [3.0.1]: https://github.com/MetaMask/action-create-release-pr/compare/v3.0.0...v3.0.1 194 | [3.0.0]: https://github.com/MetaMask/action-create-release-pr/compare/v2.0.0...v3.0.0 195 | [2.0.0]: https://github.com/MetaMask/action-create-release-pr/compare/v1.5.0...v2.0.0 196 | [1.5.0]: https://github.com/MetaMask/action-create-release-pr/compare/v1.4.3...v1.5.0 197 | [1.4.3]: https://github.com/MetaMask/action-create-release-pr/compare/v1.4.2...v1.4.3 198 | [1.4.2]: https://github.com/MetaMask/action-create-release-pr/compare/v1.4.1...v1.4.2 199 | [1.4.1]: https://github.com/MetaMask/action-create-release-pr/compare/v1.4.0...v1.4.1 200 | [1.4.0]: https://github.com/MetaMask/action-create-release-pr/compare/v1.3.0...v1.4.0 201 | [1.3.0]: https://github.com/MetaMask/action-create-release-pr/compare/v1.2.0...v1.3.0 202 | [1.2.0]: https://github.com/MetaMask/action-create-release-pr/compare/v1.1.0...v1.2.0 203 | [1.1.0]: https://github.com/MetaMask/action-create-release-pr/compare/v1.0.2...v1.1.0 204 | [1.0.2]: https://github.com/MetaMask/action-create-release-pr/compare/v1.0.1...v1.0.2 205 | [1.0.1]: https://github.com/MetaMask/action-create-release-pr/compare/v1.0.0...v1.0.1 206 | [1.0.0]: https://github.com/MetaMask/action-create-release-pr/compare/v0.1.1...v1.0.0 207 | [0.1.1]: https://github.com/MetaMask/action-create-release-pr/compare/v0.1.0...v0.1.1 208 | [0.1.0]: https://github.com/MetaMask/action-create-release-pr/compare/v0.0.20...v0.1.0 209 | [0.0.20]: https://github.com/MetaMask/action-create-release-pr/compare/v0.0.19...v0.0.20 210 | [0.0.19]: https://github.com/MetaMask/action-create-release-pr/compare/v0.0.18...v0.0.19 211 | [0.0.18]: https://github.com/MetaMask/action-create-release-pr/compare/v0.0.17...v0.0.18 212 | [0.0.17]: https://github.com/MetaMask/action-create-release-pr/compare/v0.0.16...v0.0.17 213 | [0.0.16]: https://github.com/MetaMask/action-create-release-pr/releases/tag/v0.0.16 214 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 ConsenSys Software Inc. 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 | # MetaMask/action-create-release-pr 2 | 3 | ## Description 4 | 5 | This action creates a new release branch and PR. It also makes a commit that includes any release preparation that can be easily automated, which includes version bumps and a partial changelog updates (using `@metamask/auto-changelog`). 6 | 7 | This action is only tested with Yarn v1. 8 | 9 | ### Monorepos 10 | 11 | This action is compatible with monorepos, but only if they use workspaces. It uses the `workspaces` property of `package.json` to determine if the repo is a monorepo, and to find each workspace. 12 | 13 | The release type and git history determine which packages are bumped. For major releases, all packages are bumped. But for minor and patch releases, the git history will be scanned to determine whether each package has changed since the previous version. Only packages with changes since the previous version will be bumped. 14 | 15 | This action uses a synchronized versioning strategy, meaning that the version of the root `package.json` file will be set on any updated packages. Packages in the monorepo may fall behind in version if they don't have any changes, but they'll jump directly to the current version of the entire monorepo if they change at all. As a result, the version change for individual packages might be beyond what SemVer would recommend. For example, packages might get major version bumps despite having no breaking changes. This is unfortunate, but for us the benefits of this simplified versioning scheme outweigh the harms. 16 | 17 | #### Adding New Packages 18 | 19 | In order for this action to continue working after new packages have been added to a monorepo with previously released packages, simply make the following changes to the new package: 20 | 21 | 1. Set the `version` field in its `package.json` to the version of the most recently released package in the monorepo. 22 | 2. Run `yarn auto-changelog init` in the root directory of the new package. 23 | 24 | ## Usage 25 | 26 | This Action can be used on its own, but we recommend using it with [MetaMask/action-publish-release](https://github.com/MetaMask/action-publish-release). 27 | 28 | In order for this action to run, the project must have a [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)-compatible changelog, even if it's empty. 29 | You should use [`@metamask/auto-changelog`](https://github.com/MetaMask/auto-changelog) to do this. 30 | 31 | You must add the [Create Release Pull Request Workflow](#create-pull-request-workflow) (or something equivalent) to use this action. 32 | Depending on the review processes of your organization, you may also want to add the [Require Additional Reviewer Workflow](#require-additional-reviewer-workflow). 33 | 34 | ### Create Release Pull Request Workflow 35 | 36 | Add the workflow file referenced below to your repository in the path `.github/workflows/create-release-pr.yml`. 37 | You'll notice that the workflow is manually triggered using the `workflow_dispatch` event. 38 | Once you've added the workflow file, you trigger the workflow via the Actions tab of the [GitHub Web UI](https://github.blog/changelog/2020-07-06-github-actions-manual-triggers-with-workflow_dispatch/) or the [`gh` CLI](https://cli.github.com/manual/gh_workflow_run). 39 | 40 | A `release-branch-prefix` input must be specified to the Action, which will be used as the prefix for the names of release PR branches. 41 | The SemVer version being released is appended to the prefix. 42 | The default prefix is `release/`, which creates branches named e.g. `release/1.0.0`. 43 | 44 | If this Action is used with [MetaMask/action-publish-release](https://github.com/MetaMask/action-publish-release), both Actions must be configured to use the same branch prefix. 45 | Their branch prefix defaults are the same within major versions. 46 | 47 | - [`.github/workflows/create-release-pr.yml`](https://github.com/MetaMask/action-create-release-pr/blob/main/.github/workflows/create-release-pr.yml) 48 | \_ **This workflow file self-references this action with the string "`/.`". Replace that string with "`MetaMask/action-create-release-pr@v1`" in your workflow.** 49 | 50 | ## Contributing 51 | 52 | ### Setup 53 | 54 | - Install [Node.js](https://nodejs.org) version 18 55 | - If you are using [nvm](https://github.com/creationix/nvm#installation) (recommended) running `nvm use` will automatically choose the right node version for you. 56 | - Install [Yarn v1](https://yarnpkg.com/en/docs/install) 57 | - Run `yarn setup` to install dependencies and run any requried post-install scripts 58 | - **Warning**: Do not use the `yarn` / `yarn install` command directly. Use `yarn setup` instead. The normal install command will skip required post-install scripts, leaving your development environment in an invalid state. 59 | 60 | ### Testing and Linting 61 | 62 | Run `yarn test` to run the tests once. To run tests on file changes, run `yarn test:watch`. 63 | 64 | Run `yarn lint` to run the linter, or run `yarn lint:fix` to run the linter and fix any automatically fixable issues. 65 | 66 | ### Releasing 67 | 68 | The project follows the same release process as the other GitHub Actions in the MetaMask organization. The GitHub Actions [`action-create-release-pr`](https://github.com/MetaMask/action-create-release-pr) and [`action-publish-release`](https://github.com/MetaMask/action-publish-release) are used to automate the release process; see those repositories for more information about how they work. 69 | 70 | 1. Choose a release version. 71 | 72 | - The release version should be chosen according to SemVer. Analyze the changes to see whether they include any breaking changes, new features, or deprecations, then choose the appropriate SemVer version. See [the SemVer specification](https://semver.org/) for more information. 73 | 74 | 2. If this release is backporting changes onto a previous release, then ensure there is a major version branch for that version (e.g. `1.x` for a `v1` backport release). 75 | 76 | - The major version branch should be set to the most recent release with that major version. For example, when backporting a `v1.0.2` release, you'd want to ensure there was a `1.x` branch that was set to the `v1.0.1` tag. 77 | 78 | 3. Trigger the [`workflow_dispatch`](https://docs.github.com/en/actions/reference/events-that-trigger-workflows#workflow_dispatch) event [manually](https://docs.github.com/en/actions/managing-workflow-runs/manually-running-a-workflow) for the `Create Release Pull Request` action to create the release PR. 79 | 80 | - For a backport release, the base branch should be the major version branch that you ensured existed in step 2. For a normal release, the base branch should be the main branch for that repository (which should be the default value). 81 | - This should trigger the [`action-create-release-pr`](https://github.com/MetaMask/action-create-release-pr) workflow to create the release PR. 82 | 83 | 4. Update the changelog to move each change entry into the appropriate change category ([See here](https://keepachangelog.com/en/1.0.0/#types) for the full list of change categories, and the correct ordering), and edit them to be more easily understood by users of the package. 84 | 85 | - Generally any changes that don't affect consumers of the package (e.g. lockfile changes or development environment changes) are omitted. Exceptions may be made for changes that might be of interest despite not having an effect upon the published package (e.g. major test improvements, security improvements, improved documentation, etc.). 86 | - Try to explain each change in terms that users of the package would understand (e.g. avoid referencing internal variables/concepts). 87 | - Consolidate related changes into one change entry if it makes it easier to explain. 88 | - Run `yarn auto-changelog validate --prettier --rc` to check that the changelog is correctly formatted. 89 | 90 | 5. Review and QA the release. 91 | 92 | - If changes are made to the base branch, the release branch will need to be updated with these changes and review/QA will need to restart again. As such, it's probably best to avoid merging other PRs into the base branch while review is underway. 93 | 94 | 6. Squash & Merge the release. 95 | 96 | - This should trigger the [`action-publish-release`](https://github.com/MetaMask/action-publish-release) workflow to tag the final release commit and publish the release on GitHub. Since this repository is a GitHub Action, this completes the release process. 97 | - Note that the shorthand major version tag is automatically updated when the release PR is merged. See [`publish-release.yml`](https://github.com/MetaMask/action-create-release-pr/blob/main/.github/workflows/publish-release.yml) for details. 98 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Create Release PR' 2 | description: "Update a repository's npm package(s). Monorepo-compatible." 3 | 4 | inputs: 5 | release-branch-prefix: 6 | description: "The prefix of the release PR branch name, to which the release's SemVer version will be appended." 7 | default: 'release/' 8 | required: true 9 | release-type: 10 | description: 'A SemVer version diff, e.g. "major", "minor", or "patch". Mutually exclusive with "release-version".' 11 | required: false 12 | release-version: 13 | description: 'A plain SemVer version string, specifying the version to bump to. Mutually exclusive with "release-type".' 14 | required: false 15 | artifacts-path: 16 | description: 'The path to the directory where this action will create its artifact files.' 17 | required: false 18 | created-pr-status: 19 | default: 'draft' 20 | description: 'The status of the pull request when created. Allowed options are "draft" and "open"' 21 | required: true 22 | 23 | runs: 24 | using: 'composite' 25 | steps: 26 | - id: update-packages 27 | shell: bash 28 | run: node ${{ github.action_path }}/dist/index.js 29 | env: 30 | RELEASE_TYPE: ${{ inputs.release-type }} 31 | RELEASE_VERSION: ${{ inputs.release-version }} 32 | - id: create-release-pr 33 | shell: bash 34 | run: | 35 | ${{ github.action_path }}/scripts/create-release-pr.sh \ 36 | ${{ steps.update-packages.outputs.NEW_VERSION }} \ 37 | ${{ inputs.release-branch-prefix }} \ 38 | ${{ inputs.created-pr-status }} \ 39 | ${{ github.actor }} \ 40 | ${{ inputs.artifacts-path }} 41 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | collectCoverageFrom: ['src/**/*.ts', '!**/*.d.ts'], 4 | coverageProvider: 'babel', 5 | coverageReporters: ['text', 'html'], 6 | coverageThreshold: { 7 | global: { 8 | branches: 100, 9 | functions: 100, 10 | lines: 100, 11 | statements: 100, 12 | }, 13 | }, 14 | moduleFileExtensions: ['ts', 'js', 'json', 'node'], 15 | preset: 'ts-jest', 16 | // "resetMocks" resets all mocks, including mocked modules, to jest.fn(), 17 | // between each test case. 18 | resetMocks: true, 19 | // "restoreMocks" restores all mocks created using jest.spyOn to their 20 | // original implementations, between each test. It does not affect mocked 21 | // modules. 22 | restoreMocks: true, 23 | testEnvironment: 'node', 24 | testRegex: ['\\.test\\.(ts|js)$'], 25 | testTimeout: 2500, 26 | }; 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "action-create-release-pr", 3 | "private": true, 4 | "version": "4.0.0", 5 | "description": "A GitHub Action for creating a release PR. Monorepo-compatible.", 6 | "files": [ 7 | "lib/" 8 | ], 9 | "main": "lib/index.js", 10 | "engines": { 11 | "node": "^18.18 || >=20", 12 | "yarn": "^1.22.22" 13 | }, 14 | "scripts": { 15 | "setup": "yarn install && yarn setup:postinstall", 16 | "setup:postinstall": "yarn allow-scripts", 17 | "lint:eslint": "yarn eslint . --cache --ext js,ts", 18 | "lint:misc": "prettier '**/*.json' '**/*.md' '!CHANGELOG.md' '**/*.yml' --ignore-path .gitignore", 19 | "lint": "yarn lint:eslint && yarn lint:misc --check", 20 | "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write", 21 | "build:clean": "yarn rimraf 'lib/*' 'dist/*'", 22 | "build:tsc": "tsc --project tsconfig.build.json", 23 | "build:ncc": "ncc build lib/index.js --out dist", 24 | "build": "yarn build:clean && yarn build:tsc && yarn build:ncc", 25 | "test": "jest", 26 | "test:watch": "jest --watch", 27 | "prepublishOnly": "yarn build && yarn lint && yarn test" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "https://github.com/MetaMask/action-create-release-pr.git" 32 | }, 33 | "keywords": [ 34 | "GitHub", 35 | "Actions", 36 | "JavaScript", 37 | "TypeScript", 38 | "npm", 39 | "monorepo" 40 | ], 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/MetaMask/action-create-release-pr/issues" 44 | }, 45 | "homepage": "https://github.com/MetaMask/action-create-release-pr#readme", 46 | "dependencies": { 47 | "@actions/core": "^1.10.0", 48 | "@metamask/action-utils": "^1.0.0", 49 | "@metamask/auto-changelog": "^4.0.0", 50 | "execa": "^4.1.0", 51 | "glob": "^7.1.7", 52 | "prettier": "^3.3.3", 53 | "semver": "^7.3.5" 54 | }, 55 | "devDependencies": { 56 | "@lavamoat/allow-scripts": "^2.5.1", 57 | "@lavamoat/preinstall-always-fail": "^2.0.0", 58 | "@metamask/eslint-config": "^12.2.0", 59 | "@metamask/eslint-config-jest": "^12.1.0", 60 | "@metamask/eslint-config-nodejs": "^12.1.0", 61 | "@metamask/eslint-config-typescript": "^12.1.0", 62 | "@types/glob": "^7.1.3", 63 | "@types/jest": "^26.0.22", 64 | "@types/lodash.clonedeep": "^4.5.6", 65 | "@types/node": "^16.18.59", 66 | "@types/semver": "^7.3.4", 67 | "@typescript-eslint/eslint-plugin": "^5.62.0", 68 | "@typescript-eslint/parser": "^5.62.0", 69 | "@vercel/ncc": "^0.38.1", 70 | "eslint": "^8.51.0", 71 | "eslint-config-prettier": "^9.1.0", 72 | "eslint-plugin-import": "~2.26.0", 73 | "eslint-plugin-jest": "^27.1.5", 74 | "eslint-plugin-jsdoc": "^39.9.1", 75 | "eslint-plugin-n": "^15.7.0", 76 | "eslint-plugin-prettier": "^5.2.1", 77 | "eslint-plugin-promise": "^6.1.1", 78 | "jest": "^28.1.0", 79 | "lodash.clonedeep": "^4.5.0", 80 | "rimraf": "^3.0.2", 81 | "ts-jest": "^28.0.3", 82 | "typescript": "~4.8.4" 83 | }, 84 | "lavamoat": { 85 | "allowScripts": { 86 | "@lavamoat/preinstall-always-fail": false 87 | } 88 | }, 89 | "packageManager": "yarn@1.22.22" 90 | } 91 | -------------------------------------------------------------------------------- /scripts/create-release-pr.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -x 4 | set -e 5 | set -o pipefail 6 | 7 | NEW_VERSION="${1}" 8 | 9 | if [[ -z $NEW_VERSION ]]; then 10 | echo "Error: No new version specified." 11 | exit 1 12 | fi 13 | 14 | RELEASE_BRANCH_PREFIX="${2}" 15 | 16 | if [[ -z $RELEASE_BRANCH_PREFIX ]]; then 17 | echo "Error: No release branch prefix specified." 18 | exit 1 19 | fi 20 | 21 | CREATED_PR_STATUS="${3}" 22 | 23 | if [[ -z $CREATED_PR_STATUS ]]; then 24 | echo "Error: No PR status specified." 25 | exit 1 26 | fi 27 | 28 | if [[ $CREATED_PR_STATUS != "draft" && $CREATED_PR_STATUS != "open" ]]; then 29 | echo "Error: Invalid PR status input. Must be one of 'draft' or 'open'. Received: ${CREATED_PR_STATUS}" 30 | exit 1 31 | fi 32 | 33 | ACTION_INITIATOR="${4}" 34 | ARTIFACTS_DIR_PATH="${5}" 35 | 36 | if [[ -n $ARTIFACTS_DIR_PATH && -z $ACTION_INITIATOR ]]; then 37 | echo "Error: Must specify action initiator if artifacts directory is specified." 38 | exit 1 39 | fi 40 | 41 | RELEASE_BRANCH_NAME="${RELEASE_BRANCH_PREFIX}${NEW_VERSION}" 42 | RELEASE_BODY="This is the release candidate for version ${NEW_VERSION}." 43 | 44 | git config user.name github-actions 45 | git config user.email github-actions@github.com 46 | 47 | git checkout -b "${RELEASE_BRANCH_NAME}" 48 | 49 | if ! (git add . && git commit -m "${NEW_VERSION}"); 50 | then 51 | echo "Error: No changes detected." 52 | exit 1 53 | fi 54 | 55 | git push --set-upstream origin "${RELEASE_BRANCH_NAME}" 56 | 57 | if [[ "$CREATED_PR_STATUS" = "draft" ]]; then 58 | gh pr create \ 59 | --draft \ 60 | --title "${NEW_VERSION}" \ 61 | --body "${RELEASE_BODY}" \ 62 | --head "${RELEASE_BRANCH_NAME}"; 63 | elif [[ "$CREATED_PR_STATUS" = "open" ]]; then 64 | gh pr create \ 65 | --title "${NEW_VERSION}" \ 66 | --body "${RELEASE_BODY}" \ 67 | --head "${RELEASE_BRANCH_NAME}"; 68 | fi 69 | 70 | if [[ -n $ARTIFACTS_DIR_PATH ]]; then 71 | # Write PR number to file so that it can be uploaded as an artifact 72 | 73 | # Retry getting the number on a timeout in case GitHub is slow to create the PR 74 | PR_NUMBER='' 75 | for i in {1..7} # 6 + 1 times total 76 | do 77 | PR_NUMBER=$(gh pr view --json number | jq '.number') 78 | if [[ -n $PR_NUMBER ]]; then 79 | break 80 | fi 81 | 82 | # sleep 6 times for 10 seconds each, for a total of 60 seconds 83 | if (( i < 7 )); then 84 | sleep 10 85 | fi 86 | done 87 | 88 | if [[ -z $PR_NUMBER ]]; then 89 | echo 'Error: "gh pr view" did not return a PR number.' 90 | exit 1 91 | fi 92 | 93 | # Write release author artifact to artifacts directory. 94 | mkdir -p "$ARTIFACTS_DIR_PATH" 95 | echo "${ACTION_INITIATOR}" > "${ARTIFACTS_DIR_PATH}/${PR_NUMBER}.txt" 96 | fi 97 | -------------------------------------------------------------------------------- /src/git-operations.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/first */ 2 | // This must be set before the import, so that the default root workspace is set 3 | process.env.GITHUB_WORKSPACE = 'root'; 4 | 5 | import execa from 'execa'; 6 | 7 | import { 8 | didPackageChange, 9 | getRepositoryHttpsUrl, 10 | getTags, 11 | } from './git-operations'; 12 | import type { PackageMetadata } from './package-operations'; 13 | 14 | jest.mock('execa'); 15 | const execaMock: jest.Mock = execa as any; 16 | 17 | enum VERSIONS { 18 | First = '1.0.0', 19 | Second = '1.0.1', 20 | Third = '1.1.0', 21 | } 22 | 23 | enum TAGS { 24 | First = 'v1.0.0', 25 | Second = 'v1.0.1', 26 | Third = 'v1.1.0', 27 | } 28 | 29 | type MockPackage = Readonly<{ name: string; dir: string }>; 30 | 31 | const PACKAGES: Readonly> = { 32 | A: { name: 'fooName', dir: 'foo' }, 33 | B: { name: 'barName', dir: 'bar' }, 34 | }; 35 | 36 | const RAW_MOCK_TAGS = `${Object.values(TAGS).join('\n')}\n`; 37 | 38 | const PARSED_MOCK_TAGS: ReadonlySet = new Set(Object.values(TAGS)); 39 | 40 | const RAW_DIFFS: Readonly> = { 41 | [TAGS.First]: `packages/${PACKAGES.A.dir}/file.txt\npackages/${PACKAGES.B.dir}/file.txt\n`, 42 | [TAGS.Second]: `packages/${PACKAGES.A.dir}/file.txt\n`, 43 | [TAGS.Third]: `packages/${PACKAGES.B.dir}/file.txt\n`, 44 | }; 45 | 46 | describe('getRepositoryHttpsUrl', () => { 47 | it('gets the repository https url (already https)', async () => { 48 | const repoHttpsUrl = 'https://github.com/Foo/Bar'; 49 | // execa('git', ['config', '--get', ...]) 50 | execaMock.mockImplementationOnce(async () => { 51 | return { stdout: repoHttpsUrl }; 52 | }); 53 | 54 | expect(await getRepositoryHttpsUrl()).toStrictEqual(repoHttpsUrl); 55 | expect(execaMock).toHaveBeenCalledTimes(1); 56 | }); 57 | 58 | it('gets the repository https url (ssh)', async () => { 59 | const repoHttpsUrl = 'https://github.com/Foo/Bar'; 60 | const repoSshUrl = 'git@github.com:Foo/Bar.git'; 61 | // execa('git', ['config', '--get', ...]) 62 | execaMock.mockImplementationOnce(async () => { 63 | return { stdout: repoSshUrl }; 64 | }); 65 | 66 | expect(await getRepositoryHttpsUrl()).toStrictEqual(repoHttpsUrl); 67 | expect(execaMock).toHaveBeenCalledTimes(1); 68 | }); 69 | 70 | it('throws on unrecognized urls', async () => { 71 | // execa('git', ['config', '--get', ...]) 72 | execaMock 73 | .mockImplementationOnce(async () => { 74 | return { stdout: 'foo' }; 75 | }) 76 | .mockImplementationOnce(async () => { 77 | return { stdout: 'http://github.com/Foo/Bar' }; 78 | }) 79 | .mockImplementationOnce(async () => { 80 | return { stdout: 'https://gitbar.foo/Foo/Bar' }; 81 | }) 82 | .mockImplementationOnce(async () => { 83 | return { stdout: 'git@gitbar.foo:Foo/Bar.git' }; 84 | }) 85 | .mockImplementationOnce(async () => { 86 | return { stdout: 'git@github.com:Foo/Bar.foo' }; 87 | }); 88 | 89 | await expect(getRepositoryHttpsUrl()).rejects.toThrow(/^Unrecognized URL/u); 90 | await expect(getRepositoryHttpsUrl()).rejects.toThrow(/^Unrecognized URL/u); 91 | await expect(getRepositoryHttpsUrl()).rejects.toThrow(/^Unrecognized URL/u); 92 | await expect(getRepositoryHttpsUrl()).rejects.toThrow(/^Unrecognized URL/u); 93 | await expect(getRepositoryHttpsUrl()).rejects.toThrow(/^Unrecognized URL/u); 94 | }); 95 | }); 96 | 97 | describe('getTags', () => { 98 | it('successfully parses tags', async () => { 99 | // execa('git', ['tag', ...]) 100 | execaMock.mockImplementationOnce(async () => { 101 | return { stdout: RAW_MOCK_TAGS }; 102 | }); 103 | 104 | expect(await getTags()).toStrictEqual([PARSED_MOCK_TAGS, TAGS.Third]); 105 | expect(execaMock).toHaveBeenCalledTimes(1); 106 | }); 107 | 108 | it('succeeds if repo has complete history and no tags', async () => { 109 | execaMock.mockImplementation(async (...args) => { 110 | const gitCommand = args[1][0]; 111 | if (gitCommand === 'tag') { 112 | return { stdout: '' }; 113 | } else if (gitCommand === 'rev-parse') { 114 | return { stdout: 'false' }; 115 | } 116 | throw new Error(`Unrecognized git command: ${gitCommand}`); 117 | }); 118 | 119 | expect(await getTags()).toStrictEqual([new Set(), null]); 120 | expect(execaMock).toHaveBeenCalledTimes(2); 121 | }); 122 | 123 | it('throws if repo has incomplete history and no tags', async () => { 124 | execaMock.mockImplementation(async (...args) => { 125 | const gitCommand = args[1][0]; 126 | if (gitCommand === 'tag') { 127 | return { stdout: '' }; 128 | } else if (gitCommand === 'rev-parse') { 129 | return { stdout: 'true' }; 130 | } 131 | throw new Error(`Unrecognized git command: ${gitCommand}`); 132 | }); 133 | 134 | await expect(getTags()).rejects.toThrow(/^"git tag" returned no tags/u); 135 | expect(execaMock).toHaveBeenCalledTimes(2); 136 | }); 137 | 138 | it('throws if repo has invalid tags', async () => { 139 | // execa('git', ['tag', ...]) 140 | execaMock.mockImplementationOnce(async () => { 141 | return { stdout: 'foo\nbar\n' }; 142 | }); 143 | 144 | await expect(getTags()).rejects.toThrow(/^Invalid latest tag/u); 145 | expect(execaMock).toHaveBeenCalledTimes(1); 146 | }); 147 | 148 | it('throws if git rev-parse returns unrecognized value', async () => { 149 | execaMock.mockImplementation(async (...args) => { 150 | const gitCommand = args[1][0]; 151 | if (gitCommand === 'tag') { 152 | return { stdout: '' }; 153 | } else if (gitCommand === 'rev-parse') { 154 | return { stdout: 'foo' }; 155 | } 156 | throw new Error(`Unrecognized git command: ${gitCommand}`); 157 | }); 158 | 159 | await expect(getTags()).rejects.toThrow( 160 | /^"git rev-parse --is-shallow-repository" returned unrecognized/u, 161 | ); 162 | expect(execaMock).toHaveBeenCalledTimes(2); 163 | }); 164 | }); 165 | 166 | describe('didPackageChange', () => { 167 | it('returns true if there are no tags', async () => { 168 | expect(await didPackageChange(new Set(), {} as any)).toBe(true); 169 | expect(execaMock).not.toHaveBeenCalled(); 170 | }); 171 | 172 | it('calls "git diff" with expected tag', async () => { 173 | execaMock.mockImplementationOnce(async () => { 174 | return { stdout: RAW_DIFFS[TAGS.First] }; 175 | }); 176 | 177 | expect( 178 | await didPackageChange(PARSED_MOCK_TAGS, { 179 | name: PACKAGES.A.name, 180 | manifest: { name: PACKAGES.A.name, version: VERSIONS.First }, 181 | dirName: PACKAGES.A.dir, 182 | dirPath: `packages/${PACKAGES.A.dir}`, 183 | }), 184 | ).toBe(true); 185 | 186 | expect( 187 | await didPackageChange(PARSED_MOCK_TAGS, { 188 | name: PACKAGES.B.name, 189 | manifest: { name: PACKAGES.B.name, version: VERSIONS.First }, 190 | dirName: PACKAGES.B.dir, 191 | dirPath: `packages/${PACKAGES.B.dir}`, 192 | }), 193 | ).toBe(true); 194 | expect(execaMock).toHaveBeenCalledTimes(1); 195 | }); 196 | 197 | it('repeat call for tag retrieves result from cache', async () => { 198 | expect( 199 | await didPackageChange(PARSED_MOCK_TAGS, { 200 | name: PACKAGES.A.name, 201 | manifest: { name: PACKAGES.A.name, version: VERSIONS.First }, 202 | dirName: PACKAGES.A.dir, 203 | dirPath: `packages/${PACKAGES.A.dir}`, 204 | }), 205 | ).toBe(true); 206 | expect(execaMock).not.toHaveBeenCalled(); 207 | }); 208 | 209 | it('only returns true for packages that actually changed', async () => { 210 | execaMock.mockImplementationOnce(async () => { 211 | return { stdout: RAW_DIFFS[TAGS.Second] }; 212 | }); 213 | 214 | expect( 215 | await didPackageChange(PARSED_MOCK_TAGS, { 216 | name: PACKAGES.A.name, 217 | manifest: { name: PACKAGES.A.name, version: VERSIONS.Second }, 218 | dirName: PACKAGES.A.dir, 219 | dirPath: `packages/${PACKAGES.A.dir}`, 220 | }), 221 | ).toBe(true); 222 | 223 | expect( 224 | await didPackageChange(PARSED_MOCK_TAGS, { 225 | name: PACKAGES.B.name, 226 | manifest: { name: PACKAGES.B.name, version: VERSIONS.Second }, 227 | dirName: PACKAGES.B.dir, 228 | dirPath: `packages/${PACKAGES.B.dir}`, 229 | }), 230 | ).toBe(false); 231 | expect(execaMock).toHaveBeenCalledTimes(1); 232 | }); 233 | 234 | it('throws if package manifest specifies version without tag', async () => { 235 | await expect( 236 | didPackageChange(PARSED_MOCK_TAGS, { 237 | name: PACKAGES.A.name, 238 | manifest: { name: PACKAGES.A.name, version: '2.0.0' }, 239 | dirName: PACKAGES.A.dir, 240 | dirPath: `packages/${PACKAGES.A.dir}`, 241 | }), 242 | ).rejects.toThrow(/no corresponding tag/u); 243 | expect(execaMock).not.toHaveBeenCalled(); 244 | }); 245 | 246 | it('throws if metadata is empty', async () => { 247 | await expect( 248 | didPackageChange(PARSED_MOCK_TAGS, { 249 | manifest: {}, 250 | dirName: PACKAGES.A.dir, 251 | dirPath: `packages/${PACKAGES.A.dir}`, 252 | } as PackageMetadata), 253 | ).rejects.toThrow(/undefined.*vundefined/u); 254 | expect(execaMock).not.toHaveBeenCalled(); 255 | }); 256 | }); 257 | -------------------------------------------------------------------------------- /src/git-operations.ts: -------------------------------------------------------------------------------- 1 | import { isValidSemver } from '@metamask/action-utils'; 2 | import execa from 'execa'; 3 | import semverClean from 'semver/functions/clean'; 4 | 5 | import type { PackageMetadata } from './package-operations'; 6 | import { WORKSPACE_ROOT } from './utils'; 7 | 8 | const HEAD = 'HEAD'; 9 | 10 | type DiffMap = Map; 11 | const DIFFS: DiffMap = new Map(); 12 | 13 | /** 14 | * Gets the HTTPS URL of the current GitHub remote repository. Assumes that 15 | * the git config remote.origin.url string matches one of: 16 | * 17 | * - https://github.com/OrganizationName/RepositoryName 18 | * - git@github.com:OrganizationName/RepositoryName.git 19 | * 20 | * If the URL of the "origin" remote matches neither pattern, an error is 21 | * thrown. 22 | * 23 | * @returns The HTTPS URL of the repository, e.g. 24 | * `https://github.com/OrganizationName/RepositoryName`. 25 | */ 26 | export async function getRepositoryHttpsUrl(): Promise { 27 | const httpsPrefix = 'https://github.com'; 28 | const sshPrefixRegex = /^git@github\.com:/u; 29 | const sshPostfixRegex = /\.git$/u; 30 | const gitConfigUrl = await performGitOperation( 31 | 'config', 32 | '--get', 33 | 'remote.origin.url', 34 | ); 35 | 36 | if (gitConfigUrl.startsWith(httpsPrefix)) { 37 | return gitConfigUrl; 38 | } 39 | 40 | // Extracts "OrganizationName/RepositoryName" from 41 | // "git@github.com:OrganizationName/RepositoryName.git" and returns the 42 | // corresponding HTTPS URL. 43 | if ( 44 | gitConfigUrl.match(sshPrefixRegex) && 45 | gitConfigUrl.match(sshPostfixRegex) 46 | ) { 47 | return `${httpsPrefix}/${gitConfigUrl 48 | .replace(sshPrefixRegex, '') 49 | .replace(sshPostfixRegex, '')}`; 50 | } 51 | throw new Error(`Unrecognized URL for git remote "origin": ${gitConfigUrl}`); 52 | } 53 | 54 | /** 55 | * Utility function for executing "git tag" and parsing the result. 56 | * An error is thrown if no tags are found and the local git history is 57 | * incomplete. 58 | * 59 | * @returns A tuple of all tags as a string array and the latest tag. 60 | * The tuple is populated by an empty array and null if there are no tags. 61 | */ 62 | export async function getTags(): Promise< 63 | Readonly<[ReadonlySet, string | null]> 64 | > { 65 | // The --merged flag ensures that we only get tags that are parents of or 66 | // equal to the current HEAD. 67 | const rawTags = await performGitOperation('tag', '--merged'); 68 | const allTags = rawTags.split('\n').filter((value) => value !== ''); 69 | 70 | if (allTags.length === 0) { 71 | if (await hasCompleteGitHistory()) { 72 | return [new Set(), null]; 73 | } 74 | throw new Error( 75 | `"git tag" returned no tags. Increase your git fetch depth.`, 76 | ); 77 | } 78 | 79 | const latestTag = allTags[allTags.length - 1]; 80 | if (!latestTag || !isValidSemver(semverClean(latestTag))) { 81 | throw new Error( 82 | `Invalid latest tag. Expected a valid SemVer version. Received: ${latestTag}`, 83 | ); 84 | } 85 | return [new Set(allTags), latestTag] as const; 86 | } 87 | 88 | /** 89 | * Check whether the local repository has a complete git history. 90 | * Implemented using "git rev-parse --is-shallow-repository". 91 | * 92 | * @returns Whether the local repository has a complete, as opposed to shallow, 93 | * git history. 94 | */ 95 | async function hasCompleteGitHistory(): Promise { 96 | const isShallow = await performGitOperation( 97 | 'rev-parse', 98 | '--is-shallow-repository', 99 | ); 100 | 101 | // We invert the meaning of these strings because we want to know if the 102 | // repository is NOT shallow. 103 | if (isShallow === 'true') { 104 | return false; 105 | } else if (isShallow === 'false') { 106 | return true; 107 | } 108 | throw new Error( 109 | `"git rev-parse --is-shallow-repository" returned unrecognized value: ${isShallow}`, 110 | ); 111 | } 112 | 113 | /** 114 | * ATTN: Only execute serially. Not safely parallelizable. 115 | * 116 | * Using git, checks whether the package changed since it was last released. 117 | * 118 | * Unless it's the first release of the package, assumes that: 119 | * 120 | * - The "version" field of the package's manifest corresponds to its latest 121 | * released version. 122 | * - The release commit of the package's most recent version is tagged with 123 | * "v", where is equal to the manifest's "version" field. 124 | * 125 | * @param tags - All tags for the release's base git branch. 126 | * @param packageData - The metadata of the package to diff. 127 | * @returns Whether the package changed since its last release. `true` is 128 | * returned if there are no releases in the repository's history. 129 | */ 130 | export async function didPackageChange( 131 | tags: ReadonlySet, 132 | packageData: PackageMetadata, 133 | ): Promise { 134 | // In this case, we assume that it's the first release, and every package 135 | // is implicitly considered to have "changed". 136 | if (tags.size === 0) { 137 | return true; 138 | } 139 | 140 | const { 141 | manifest: { name: packageName, version: currentVersion }, 142 | } = packageData; 143 | const tagOfCurrentVersion = versionToTag(currentVersion); 144 | 145 | if (!tags.has(tagOfCurrentVersion)) { 146 | throw new Error( 147 | `Package "${ 148 | packageName ?? 'undefined' 149 | }" has version "${currentVersion}" in its manifest, but no corresponding tag "${tagOfCurrentVersion}" exists.`, 150 | ); 151 | } 152 | return hasDiff(packageData, tagOfCurrentVersion); 153 | } 154 | 155 | /** 156 | * Retrieves the diff for the given tag from the cache or performs the git diff 157 | * operation, caching the result and returning it. 158 | * 159 | * @param packageData - The metadata of the package to diff. 160 | * @param tag - The tag corresponding to the package's latest release. 161 | * @returns Whether the package changed since its last release. 162 | */ 163 | async function hasDiff( 164 | packageData: PackageMetadata, 165 | tag: string, 166 | ): Promise { 167 | const { dirPath: packagePath } = packageData; 168 | 169 | let diff: string[]; 170 | if (DIFFS.has(tag)) { 171 | diff = DIFFS.get(tag) as string[]; 172 | } else { 173 | diff = await getDiff(tag); 174 | DIFFS.set(tag, diff); 175 | } 176 | 177 | return diff.some((diffPath) => diffPath.startsWith(packagePath)); 178 | } 179 | 180 | /** 181 | * Wrapper function for diffing the repository between a particular tag and the 182 | * current HEAD. 183 | * 184 | * @param tag - The tag to compare against HEAD. 185 | * @returns An array of paths to files that were between the given tag and the 186 | * current HEAD. 187 | */ 188 | async function getDiff(tag: string): Promise { 189 | return (await performGitOperation('diff', tag, HEAD, '--name-only')).split( 190 | '\n', 191 | ); 192 | } 193 | 194 | /** 195 | * Utility function for performing git operations via execa. 196 | * 197 | * @param command - The git command to execute. 198 | * @param args - The positional arguments to the git command. 199 | * @returns The result of the git command. 200 | */ 201 | async function performGitOperation( 202 | command: string, 203 | ...args: string[] 204 | ): Promise { 205 | return ( 206 | await execa('git', [command, ...args], { cwd: WORKSPACE_ROOT }) 207 | ).stdout.trim(); 208 | } 209 | 210 | /** 211 | * Takes a SemVer version string and prefixes it with "v". 212 | * 213 | * @param version - The SemVer version string to prefix. 214 | * @returns The "v"-prefixed SemVer version string. 215 | */ 216 | function versionToTag(version: string): string { 217 | return `v${version}`; 218 | } 219 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import * as actionsCore from '@actions/core'; 2 | 3 | import * as actionModule from './update'; 4 | import * as utils from './utils'; 5 | 6 | jest.mock('@actions/core', () => { 7 | return { 8 | error: jest.fn(), 9 | setFailed: jest.fn(), 10 | }; 11 | }); 12 | 13 | jest.mock('./update', () => { 14 | return { 15 | performUpdate: jest.fn(), 16 | }; 17 | }); 18 | 19 | jest.mock('./utils', () => { 20 | return { 21 | getActionInputs: jest.fn(), 22 | }; 23 | }); 24 | 25 | describe('main entry file', () => { 26 | it('calls performUpdate and catches thrown errors', async () => { 27 | const getActionInputsMock = jest 28 | .spyOn(utils, 'getActionInputs') 29 | .mockImplementationOnce(() => { 30 | return { ReleaseType: null, ReleaseVersion: '1.0.0' }; 31 | }); 32 | const performUpdateMock = jest 33 | .spyOn(actionModule, 'performUpdate') 34 | .mockImplementationOnce(async () => { 35 | throw new Error('error'); 36 | }); 37 | const logErrorMock = jest.spyOn(actionsCore, 'error'); 38 | const setFailedMock = jest.spyOn(actionsCore, 'setFailed'); 39 | 40 | import('.'); 41 | await new Promise((resolve) => { 42 | setImmediate(() => { 43 | expect(getActionInputsMock).toHaveBeenCalledTimes(1); 44 | expect(performUpdateMock).toHaveBeenCalledTimes(1); 45 | expect(logErrorMock).toHaveBeenCalledTimes(1); 46 | expect(setFailedMock).toHaveBeenCalledTimes(1); 47 | expect(setFailedMock).toHaveBeenCalledWith(new Error('error')); 48 | resolve(); 49 | }); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | error as logError, 3 | setFailed as setActionToFailed, 4 | } from '@actions/core'; 5 | 6 | import { performUpdate } from './update'; 7 | import { getActionInputs } from './utils'; 8 | 9 | performUpdate(getActionInputs()).catch((error) => { 10 | // istanbul ignore else 11 | if (error.stack) { 12 | logError(error.stack); 13 | } 14 | setActionToFailed(error); 15 | }); 16 | -------------------------------------------------------------------------------- /src/package-operations.test.ts: -------------------------------------------------------------------------------- 1 | import type { ManifestDependencyFieldNames } from '@metamask/action-utils'; 2 | import * as actionUtils from '@metamask/action-utils'; 3 | import { ManifestFieldNames } from '@metamask/action-utils'; 4 | import * as autoChangelog from '@metamask/auto-changelog'; 5 | import fs from 'fs'; 6 | import glob from 'glob'; 7 | import cloneDeep from 'lodash.clonedeep'; 8 | 9 | import * as gitOps from './git-operations'; 10 | import { 11 | formatChangelog, 12 | getMetadataForAllPackages, 13 | getPackagesToUpdate, 14 | updatePackage, 15 | updatePackages, 16 | } from './package-operations'; 17 | 18 | jest.mock('fs', () => ({ 19 | promises: { 20 | lstat: jest.fn(), 21 | readdir: jest.fn(), 22 | readFile: jest.fn(), 23 | writeFile: jest.fn(), 24 | }, 25 | existsSync: jest.fn(), 26 | })); 27 | 28 | jest.mock('glob'); 29 | 30 | jest.mock('@metamask/action-utils/dist/file-utils', () => { 31 | const actualModule = jest.requireActual( 32 | '@metamask/action-utils/dist/file-utils', 33 | ); 34 | return { 35 | ...actualModule, 36 | readJsonObjectFile: jest.fn(), 37 | }; 38 | }); 39 | 40 | jest.mock('@metamask/auto-changelog', () => { 41 | return { 42 | updateChangelog: jest.fn(), 43 | parseChangelog: jest.fn(), 44 | }; 45 | }); 46 | 47 | jest.mock('./git-operations', () => { 48 | const actualModule = jest.requireActual('./git-operations'); 49 | return { 50 | ...actualModule, 51 | didPackageChange: jest.fn(), 52 | }; 53 | }); 54 | 55 | jest.mock('./utils', () => { 56 | const actualModule = jest.requireActual('./utils'); 57 | return { 58 | ...actualModule, 59 | WORKSPACE_ROOT: 'root', 60 | }; 61 | }); 62 | 63 | const MOCK_PACKAGES_DIR = 'packages'; 64 | 65 | type DependencyFieldsDict = Partial< 66 | Record> 67 | >; 68 | 69 | // Convenience method to match behavior of utils.writeJsonFile 70 | const jsonStringify = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`; 71 | 72 | const getMockManifest = ( 73 | name: string, 74 | version: string, 75 | dependencyFields: DependencyFieldsDict = {}, 76 | ) => { 77 | return { name, version, ...dependencyFields }; 78 | }; 79 | 80 | describe('package-operations', () => { 81 | describe('getMetadataForAllPackages', () => { 82 | const names = ['name1', 'name2', 'name3']; 83 | const dirs = ['dir1', 'dir2', 'dir3']; 84 | const version = '1.0.0'; 85 | const SOME_FILE = 'someFile'; 86 | 87 | const getMockPackageMetadata = (index: number) => { 88 | return { 89 | dirName: dirs[index], 90 | manifest: getMockManifest(names[index], version), 91 | [ManifestFieldNames.Name]: names[index], 92 | dirPath: `${MOCK_PACKAGES_DIR}/${dirs[index]}`, 93 | }; 94 | }; 95 | 96 | /** 97 | * Returns a mock implementation for `readJsonObjectFile` which is used to 98 | * obtain a mock manifest for a package used within the tests in this test 99 | * group. 100 | * 101 | * @returns A function that returns a mock manifest. 102 | */ 103 | function getMockReadJsonFile() { 104 | let mockIndex = -1; 105 | return async () => { 106 | mockIndex += 1; 107 | return getMockManifest(names[mockIndex], version); 108 | }; 109 | } 110 | 111 | beforeEach(() => { 112 | jest.spyOn(fs.promises, 'lstat').mockImplementation((async ( 113 | path: string, 114 | ) => { 115 | return path.endsWith(SOME_FILE) 116 | ? { isDirectory: () => false } 117 | : { isDirectory: () => true }; 118 | }) as any); 119 | }); 120 | 121 | it('does not throw', async () => { 122 | (glob as jest.MockedFunction).mockImplementation( 123 | ( 124 | _pattern: string, 125 | _options: unknown, 126 | callback: (error: null, data: string[]) => void, 127 | ) => 128 | callback(null, [ 129 | 'packages/dir1', 130 | 'packages/dir2', 131 | 'packages/dir3', 132 | 'packages/someFile', 133 | ]), 134 | ); 135 | 136 | jest 137 | .spyOn(actionUtils, 'readJsonObjectFile') 138 | .mockImplementation(getMockReadJsonFile()); 139 | 140 | expect(await getMetadataForAllPackages(['packages/*'])).toStrictEqual({ 141 | [names[0]]: getMockPackageMetadata(0), 142 | [names[1]]: getMockPackageMetadata(1), 143 | [names[2]]: getMockPackageMetadata(2), 144 | }); 145 | }); 146 | 147 | it('resolves recursive workspaces', async () => { 148 | (glob as jest.MockedFunction) 149 | .mockImplementationOnce( 150 | ( 151 | _pattern: string, 152 | _options: unknown, 153 | callback: (error: null, data: string[]) => void, 154 | ) => callback(null, ['packages/dir1']), 155 | ) 156 | .mockImplementationOnce( 157 | ( 158 | _pattern: string, 159 | _options: unknown, 160 | callback: (error: null, data: string[]) => void, 161 | ) => callback(null, ['packages/dir2']), 162 | ); 163 | 164 | jest 165 | .spyOn(actionUtils, 'readJsonObjectFile') 166 | .mockImplementationOnce(async () => ({ 167 | ...getMockManifest(names[0], version), 168 | private: true, 169 | workspaces: ['packages/*'], 170 | })) 171 | .mockImplementationOnce(async () => getMockManifest(names[1], version)); 172 | 173 | expect(await getMetadataForAllPackages(['packages/*'])).toStrictEqual({ 174 | [names[0]]: { 175 | ...getMockPackageMetadata(0), 176 | manifest: { 177 | ...getMockManifest(names[0], version), 178 | private: true, 179 | workspaces: ['packages/*'], 180 | }, 181 | }, 182 | [names[1]]: { 183 | ...getMockPackageMetadata(1), 184 | dirPath: 'packages/dir1/packages/dir2', 185 | }, 186 | }); 187 | }); 188 | 189 | it('throws if a sub-workspace does not have a name', async () => { 190 | (glob as jest.MockedFunction).mockImplementationOnce( 191 | ( 192 | _pattern: string, 193 | _options: unknown, 194 | callback: (error: null, data: string[]) => void, 195 | ) => callback(null, ['packages/dir1']), 196 | ); 197 | 198 | jest 199 | .spyOn(actionUtils, 'readJsonObjectFile') 200 | .mockImplementationOnce(async () => ({ 201 | ...getMockManifest(names[0], version), 202 | private: true, 203 | workspaces: ['packages/*'], 204 | name: undefined, 205 | })); 206 | 207 | await expect(getMetadataForAllPackages(['packages/*'])).rejects.toThrow( 208 | 'Expected sub-workspace in "packages/dir1" to have a name.', 209 | ); 210 | }); 211 | }); 212 | 213 | describe('getPackagesToUpdate', () => { 214 | let didPackageChangeMock: jest.SpyInstance; 215 | 216 | const packageNames = ['name1', 'name2', 'name3']; 217 | 218 | const mockMetadataRecord = { 219 | [packageNames[0]]: {}, 220 | [packageNames[1]]: {}, 221 | [packageNames[2]]: {}, 222 | }; 223 | 224 | beforeEach(() => { 225 | didPackageChangeMock = jest.spyOn(gitOps, 'didPackageChange'); 226 | }); 227 | 228 | it('returns all packages if synchronizeVersions is true', async () => { 229 | expect( 230 | await getPackagesToUpdate(mockMetadataRecord as any, true, new Set()), 231 | ).toStrictEqual(new Set(packageNames)); 232 | expect(didPackageChangeMock).not.toHaveBeenCalled(); 233 | }); 234 | 235 | it('returns changed packages if synchronizeVersions is false', async () => { 236 | didPackageChangeMock 237 | .mockImplementationOnce(async () => false) 238 | .mockImplementationOnce(async () => true) 239 | .mockImplementationOnce(async () => false); 240 | 241 | expect( 242 | await getPackagesToUpdate(mockMetadataRecord as any, false, new Set()), 243 | ).toStrictEqual(new Set([packageNames[1]])); 244 | expect(didPackageChangeMock).toHaveBeenCalledTimes(3); 245 | }); 246 | 247 | it('throws an error if there are no packages to update', async () => { 248 | didPackageChangeMock.mockImplementation(async () => false); 249 | 250 | await expect( 251 | getPackagesToUpdate(mockMetadataRecord as any, false, new Set()), 252 | ).rejects.toThrow(/no packages to update/u); 253 | expect(didPackageChangeMock).toHaveBeenCalledTimes(3); 254 | }); 255 | }); 256 | 257 | describe('Updating packages', () => { 258 | const writeFileMock = jest 259 | .spyOn(fs.promises, 'writeFile') 260 | .mockImplementation(async () => Promise.resolve()); 261 | const readFileMock = jest.spyOn(fs.promises, 'readFile'); 262 | 263 | const updateChangelogMock = jest.spyOn(autoChangelog, 'updateChangelog'); 264 | const parseChangelogMock = jest.spyOn(autoChangelog, 'parseChangelog'); 265 | 266 | const getMockPackageMetadata = ( 267 | dirPath: string, 268 | manifest: ReturnType, 269 | ) => { 270 | return { 271 | dirPath, 272 | manifest, 273 | }; 274 | }; 275 | 276 | const getMockWritePath = (dirPath: string, fileName: string) => 277 | `root/${dirPath}/${fileName}`; 278 | 279 | const mockDirs = ['dir1', 'dir2', 'dir3']; 280 | const packageNames = ['name1', 'name2', 'name3']; 281 | 282 | describe('updatePackage (singular)', () => { 283 | it('updates a package without dependencies', async () => { 284 | const originalVersion = '1.0.0'; 285 | const newVersion = '1.0.1'; 286 | const dir = mockDirs[0]; 287 | const name = packageNames[0]; 288 | const manifest = getMockManifest(name, originalVersion); 289 | 290 | const packageMetadata = getMockPackageMetadata(dir, manifest); 291 | const updateSpecification = { 292 | newVersion, 293 | packagesToUpdate: new Set(packageNames), 294 | repositoryUrl: '', 295 | shouldUpdateChangelog: false, 296 | synchronizeVersions: false, 297 | }; 298 | 299 | await updatePackage(packageMetadata, updateSpecification); 300 | expect(writeFileMock).toHaveBeenCalledTimes(1); 301 | expect(writeFileMock).toHaveBeenCalledWith( 302 | getMockWritePath(dir, 'package.json'), 303 | jsonStringify({ 304 | ...cloneDeep(manifest), 305 | [ManifestFieldNames.Version]: newVersion, 306 | }), 307 | ); 308 | expect(updateChangelogMock).not.toHaveBeenCalled(); 309 | }); 310 | 311 | it('updates a package and its changelog', async () => { 312 | const originalVersion = '1.0.0'; 313 | const newVersion = '1.0.1'; 314 | const dir = mockDirs[0]; 315 | const name = packageNames[0]; 316 | const manifest = getMockManifest(name, originalVersion); 317 | 318 | const repoUrl = 'https://fake'; 319 | const changelogContent = 'I am a changelog.'; 320 | readFileMock.mockImplementationOnce(async () => changelogContent); 321 | 322 | const mockNewChangelog = 'I am a new changelog.'; 323 | updateChangelogMock.mockImplementation(async () => mockNewChangelog); 324 | 325 | const packageMetadata = getMockPackageMetadata(dir, manifest); 326 | const updateSpecification = { 327 | newVersion, 328 | packagesToUpdate: new Set(packageNames), 329 | repositoryUrl: repoUrl, 330 | shouldUpdateChangelog: true, 331 | synchronizeVersions: false, 332 | }; 333 | 334 | await updatePackage(packageMetadata, updateSpecification); 335 | expect(writeFileMock).toHaveBeenCalledTimes(2); 336 | expect(writeFileMock).toHaveBeenNthCalledWith( 337 | 1, 338 | getMockWritePath(dir, 'package.json'), 339 | jsonStringify({ 340 | ...cloneDeep(manifest), 341 | [ManifestFieldNames.Version]: newVersion, 342 | }), 343 | ); 344 | expect(updateChangelogMock).toHaveBeenCalledTimes(1); 345 | expect(updateChangelogMock).toHaveBeenCalledWith({ 346 | changelogContent, 347 | currentVersion: newVersion, 348 | isReleaseCandidate: true, 349 | projectRootDirectory: dir, 350 | repoUrl, 351 | formatter: expect.any(Function), 352 | }); 353 | 354 | expect(writeFileMock).toHaveBeenNthCalledWith( 355 | 2, 356 | getMockWritePath(dir, 'CHANGELOG.md'), 357 | mockNewChangelog, 358 | ); 359 | expect(parseChangelogMock).toHaveBeenCalledTimes(0); 360 | }); 361 | 362 | it('re-throws changelog read error', async () => { 363 | const originalVersion = '1.0.0'; 364 | const newVersion = '1.0.1'; 365 | const dir = mockDirs[0]; 366 | const name = packageNames[0]; 367 | const manifest = getMockManifest(name, originalVersion); 368 | 369 | readFileMock.mockImplementationOnce(async () => { 370 | throw new Error('readError'); 371 | }); 372 | 373 | const packageMetadata = getMockPackageMetadata(dir, manifest); 374 | const updateSpecification = { 375 | newVersion, 376 | packagesToUpdate: new Set(packageNames), 377 | repositoryUrl: 'https://fake', 378 | shouldUpdateChangelog: true, 379 | synchronizeVersions: false, 380 | }; 381 | 382 | const consoleErrorSpy = jest 383 | .spyOn(console, 'error') 384 | .mockImplementationOnce(() => undefined); 385 | 386 | await expect( 387 | updatePackage(packageMetadata, updateSpecification), 388 | ).rejects.toThrow(new Error('readError')); 389 | expect(updateChangelogMock).not.toHaveBeenCalled(); 390 | expect(consoleErrorSpy).toHaveBeenCalledTimes(1); 391 | expect(consoleErrorSpy).toHaveBeenCalledWith( 392 | expect.stringMatching(/^Failed to read changelog/u), 393 | ); 394 | }); 395 | 396 | it('does not throw if the file cannot be found', async () => { 397 | const originalVersion = '1.0.0'; 398 | const newVersion = '1.0.1'; 399 | const dir = mockDirs[0]; 400 | const name = packageNames[0]; 401 | const manifest = getMockManifest(name, originalVersion); 402 | 403 | readFileMock.mockImplementationOnce(async () => { 404 | const error = new Error('readError'); 405 | (error as any).code = 'ENOENT'; 406 | 407 | throw error; 408 | }); 409 | 410 | const packageMetadata = getMockPackageMetadata(dir, manifest); 411 | const updateSpecification = { 412 | newVersion, 413 | packagesToUpdate: new Set(packageNames), 414 | repositoryUrl: 'https://fake', 415 | shouldUpdateChangelog: true, 416 | synchronizeVersions: false, 417 | }; 418 | 419 | const consoleWarnSpy = jest 420 | .spyOn(console, 'warn') 421 | .mockImplementationOnce(() => undefined); 422 | 423 | await updatePackage(packageMetadata, updateSpecification); 424 | 425 | expect(updateChangelogMock).not.toHaveBeenCalled(); 426 | expect(consoleWarnSpy).toHaveBeenCalledTimes(1); 427 | expect(consoleWarnSpy).toHaveBeenCalledWith( 428 | expect.stringMatching(/^Failed to read changelog/u), 429 | ); 430 | }); 431 | 432 | it('throws if updated changelog is empty', async () => { 433 | const originalVersion = '1.0.0'; 434 | const newVersion = '1.0.1'; 435 | const dir = mockDirs[0]; 436 | const name = packageNames[0]; 437 | const manifest = getMockManifest(name, originalVersion); 438 | 439 | const repoUrl = 'https://fake'; 440 | const changelogContent = 'I am a changelog.'; 441 | readFileMock.mockImplementationOnce(async () => changelogContent); 442 | 443 | // no new changelog content and no unreleased changes will cause an error 444 | updateChangelogMock.mockImplementation(async () => ''); 445 | const actualChangelog = jest.requireActual( 446 | '@metamask/auto-changelog/dist/changelog', 447 | ); 448 | parseChangelogMock.mockImplementationOnce(() => { 449 | return { 450 | ...actualChangelog, 451 | getUnreleasedChanges() { 452 | return {}; 453 | }, 454 | }; 455 | }); 456 | 457 | const packageMetadata = getMockPackageMetadata(dir, manifest); 458 | const updateSpecification = { 459 | newVersion, 460 | packagesToUpdate: new Set(packageNames), 461 | repositoryUrl: repoUrl, 462 | shouldUpdateChangelog: true, 463 | synchronizeVersions: false, 464 | }; 465 | 466 | await expect( 467 | updatePackage(packageMetadata, updateSpecification), 468 | ).rejects.toThrow( 469 | '"updateChangelog" returned an empty value for package "name1".', 470 | ); 471 | expect(writeFileMock).toHaveBeenCalledTimes(1); 472 | expect(writeFileMock).toHaveBeenNthCalledWith( 473 | 1, 474 | getMockWritePath(dir, 'package.json'), 475 | jsonStringify({ 476 | ...cloneDeep(manifest), 477 | [ManifestFieldNames.Version]: newVersion, 478 | }), 479 | ); 480 | expect(updateChangelogMock).toHaveBeenCalledTimes(1); 481 | expect(updateChangelogMock).toHaveBeenCalledWith({ 482 | changelogContent, 483 | currentVersion: newVersion, 484 | isReleaseCandidate: true, 485 | projectRootDirectory: dir, 486 | repoUrl, 487 | formatter: expect.any(Function), 488 | }); 489 | expect(parseChangelogMock).toHaveBeenCalledTimes(1); 490 | expect(parseChangelogMock).toHaveBeenCalledWith({ 491 | changelogContent, 492 | repoUrl, 493 | formatter: expect.any(Function), 494 | }); 495 | }); 496 | 497 | it('succeeds if updated changelog is empty, but there are unreleased changes', async () => { 498 | const originalVersion = '1.0.0'; 499 | const newVersion = '1.0.1'; 500 | const dir = mockDirs[0]; 501 | const name = packageNames[0]; 502 | const manifest = getMockManifest(name, originalVersion); 503 | 504 | const repoUrl = 'https://fake'; 505 | const changelogContent = 'I am a changelog.'; 506 | readFileMock.mockImplementationOnce(async () => changelogContent); 507 | 508 | updateChangelogMock.mockImplementation(async () => ''); 509 | const actualChangelog = jest.requireActual( 510 | '@metamask/auto-changelog/dist/changelog', 511 | ); 512 | parseChangelogMock.mockImplementationOnce(() => { 513 | return { 514 | ...actualChangelog, 515 | getUnreleasedChanges() { 516 | return { 517 | Fixed: ['Something'], 518 | }; 519 | }, 520 | }; 521 | }); 522 | 523 | const packageMetadata = getMockPackageMetadata(dir, manifest); 524 | const updateSpecification = { 525 | newVersion, 526 | packagesToUpdate: new Set(packageNames), 527 | repositoryUrl: repoUrl, 528 | shouldUpdateChangelog: true, 529 | synchronizeVersions: false, 530 | }; 531 | 532 | await updatePackage(packageMetadata, updateSpecification); 533 | expect(writeFileMock).toHaveBeenCalledTimes(1); 534 | expect(writeFileMock).toHaveBeenNthCalledWith( 535 | 1, 536 | getMockWritePath(dir, 'package.json'), 537 | jsonStringify({ 538 | ...cloneDeep(manifest), 539 | [ManifestFieldNames.Version]: newVersion, 540 | }), 541 | ); 542 | expect(updateChangelogMock).toHaveBeenCalledTimes(1); 543 | expect(updateChangelogMock).toHaveBeenCalledWith({ 544 | changelogContent, 545 | currentVersion: newVersion, 546 | isReleaseCandidate: true, 547 | projectRootDirectory: dir, 548 | repoUrl, 549 | formatter: expect.any(Function), 550 | }); 551 | expect(parseChangelogMock).toHaveBeenCalledTimes(1); 552 | expect(parseChangelogMock).toHaveBeenCalledWith({ 553 | changelogContent, 554 | repoUrl, 555 | formatter: expect.any(Function), 556 | }); 557 | }); 558 | 559 | it('throws if updated changelog is empty, and handles missing package name', async () => { 560 | const originalVersion = '1.0.0'; 561 | const newVersion = '1.0.1'; 562 | const dir = mockDirs[0]; 563 | const name = packageNames[0]; 564 | const manifest = getMockManifest(name, originalVersion); 565 | delete (manifest as any).name; 566 | 567 | const repoUrl = 'https://fake'; 568 | const changelogContent = 'I am a changelog.'; 569 | readFileMock.mockImplementationOnce(async () => changelogContent); 570 | 571 | // no new changelog content and no unreleased changes will cause an error 572 | updateChangelogMock.mockImplementation(async () => ''); 573 | const actualChangelog = jest.requireActual( 574 | '@metamask/auto-changelog/dist/changelog', 575 | ); 576 | parseChangelogMock.mockImplementationOnce(() => { 577 | return { 578 | ...actualChangelog, 579 | getUnreleasedChanges() { 580 | return {}; 581 | }, 582 | }; 583 | }); 584 | 585 | const packageMetadata = getMockPackageMetadata(dir, manifest); 586 | const updateSpecification = { 587 | newVersion, 588 | packagesToUpdate: new Set(packageNames), 589 | repositoryUrl: repoUrl, 590 | shouldUpdateChangelog: true, 591 | synchronizeVersions: false, 592 | }; 593 | 594 | await expect( 595 | updatePackage(packageMetadata, updateSpecification), 596 | ).rejects.toThrow( 597 | '"updateChangelog" returned an empty value for package at "root/dir1".', 598 | ); 599 | expect(writeFileMock).toHaveBeenCalledTimes(1); 600 | expect(writeFileMock).toHaveBeenNthCalledWith( 601 | 1, 602 | getMockWritePath(dir, 'package.json'), 603 | jsonStringify({ 604 | ...cloneDeep(manifest), 605 | [ManifestFieldNames.Version]: newVersion, 606 | }), 607 | ); 608 | expect(updateChangelogMock).toHaveBeenCalledTimes(1); 609 | expect(updateChangelogMock).toHaveBeenCalledWith({ 610 | changelogContent, 611 | currentVersion: newVersion, 612 | isReleaseCandidate: true, 613 | projectRootDirectory: dir, 614 | repoUrl, 615 | formatter: expect.any(Function), 616 | }); 617 | expect(parseChangelogMock).toHaveBeenCalledTimes(1); 618 | expect(parseChangelogMock).toHaveBeenCalledWith({ 619 | changelogContent, 620 | repoUrl, 621 | formatter: expect.any(Function), 622 | }); 623 | }); 624 | 625 | it('updates a package without synchronizing dependency versions', async () => { 626 | const originalVersion = '1.0.0'; 627 | const newVersion = '1.0.1'; 628 | const dir = mockDirs[0]; 629 | const name = packageNames[0]; 630 | const manifest = getMockManifest(name, originalVersion, { 631 | dependencies: { foo: 'bar', [packageNames[1]]: originalVersion }, 632 | }); 633 | 634 | const packageMetadata = getMockPackageMetadata(dir, manifest); 635 | const updateSpecification = { 636 | newVersion, 637 | packagesToUpdate: new Set(packageNames), 638 | synchronizeVersions: false, 639 | repositoryUrl: '', 640 | shouldUpdateChangelog: false, 641 | }; 642 | 643 | await updatePackage(packageMetadata, updateSpecification); 644 | expect(writeFileMock).toHaveBeenCalledTimes(1); 645 | expect(writeFileMock).toHaveBeenCalledWith( 646 | getMockWritePath(dir, 'package.json'), 647 | jsonStringify({ 648 | ...cloneDeep(manifest), 649 | [ManifestFieldNames.Version]: newVersion, 650 | }), 651 | ); 652 | expect(updateChangelogMock).not.toHaveBeenCalled(); 653 | }); 654 | 655 | it('updates a package and synchronizes dependency versions', async () => { 656 | const originalVersion = '1.0.0'; 657 | const newVersion = '1.0.1'; 658 | const dir = mockDirs[0]; 659 | const name = packageNames[0]; 660 | 661 | const originalDependencies = { 662 | dependencies: { 663 | foo: 'bar', 664 | [packageNames[1]]: `^${originalVersion}`, 665 | }, 666 | devDependencies: { [packageNames[2]]: `^${originalVersion}` }, 667 | }; 668 | const expectedDependencies = { 669 | dependencies: { foo: 'bar', [packageNames[1]]: `^${newVersion}` }, 670 | devDependencies: { [packageNames[2]]: `^${newVersion}` }, 671 | }; 672 | 673 | const manifest = getMockManifest( 674 | name, 675 | originalVersion, 676 | originalDependencies, 677 | ); 678 | 679 | const packageMetadata = getMockPackageMetadata(dir, manifest); 680 | const updateSpecification = { 681 | newVersion, 682 | packagesToUpdate: new Set(packageNames), 683 | synchronizeVersions: true, 684 | repositoryUrl: '', 685 | shouldUpdateChangelog: false, 686 | }; 687 | 688 | await updatePackage(packageMetadata, updateSpecification); 689 | expect(writeFileMock).toHaveBeenCalledTimes(1); 690 | expect(writeFileMock).toHaveBeenCalledWith( 691 | getMockWritePath(dir, 'package.json'), 692 | jsonStringify( 693 | getMockManifest(name, newVersion, expectedDependencies), 694 | ), 695 | ); 696 | expect(updateChangelogMock).not.toHaveBeenCalled(); 697 | }); 698 | }); 699 | 700 | // This method just calls updatePackage in a loop. 701 | describe('updatePackages (plural)', () => { 702 | it('updates multiple packages', async () => { 703 | const originalVersion = '1.0.0'; 704 | const newVersion = '1.0.1'; 705 | const dir1 = mockDirs[0]; 706 | const dir2 = mockDirs[1]; 707 | const name1 = packageNames[0]; 708 | const name2 = packageNames[1]; 709 | const manifest1 = getMockManifest(name1, originalVersion); 710 | const manifest2 = getMockManifest(name2, originalVersion); 711 | 712 | const packageMetadata1 = getMockPackageMetadata(dir1, manifest1); 713 | const packageMetadata2 = getMockPackageMetadata(dir2, manifest2); 714 | 715 | const allPackages = { 716 | [name1]: packageMetadata1, 717 | [name2]: packageMetadata2, 718 | }; 719 | const updateSpecification = { 720 | newVersion, 721 | packagesToUpdate: new Set([name1, name2]), 722 | synchronizeVersions: false, 723 | repositoryUrl: '', 724 | shouldUpdateChangelog: false, 725 | }; 726 | 727 | await updatePackages(allPackages, updateSpecification); 728 | expect(writeFileMock).toHaveBeenCalledTimes(2); 729 | expect(writeFileMock).toHaveBeenNthCalledWith( 730 | 1, 731 | getMockWritePath(dir1, 'package.json'), 732 | jsonStringify({ 733 | ...cloneDeep(manifest1), 734 | [ManifestFieldNames.Version]: newVersion, 735 | }), 736 | ); 737 | 738 | expect(writeFileMock).toHaveBeenNthCalledWith( 739 | 2, 740 | getMockWritePath(dir2, 'package.json'), 741 | jsonStringify({ 742 | ...cloneDeep(manifest2), 743 | [ManifestFieldNames.Version]: newVersion, 744 | }), 745 | ); 746 | }); 747 | }); 748 | }); 749 | 750 | describe('formatChangelog', () => { 751 | it('formats a changelog', async () => { 752 | const unformattedChangelog = `# Changelog 753 | ## 1.0.0 754 | 755 | - Some change 756 | ## 0.0.1 757 | 758 | - Some other change 759 | `; 760 | 761 | expect(await formatChangelog(unformattedChangelog)) 762 | .toMatchInlineSnapshot(` 763 | "# Changelog 764 | 765 | ## 1.0.0 766 | 767 | - Some change 768 | 769 | ## 0.0.1 770 | 771 | - Some other change 772 | " 773 | `); 774 | }); 775 | }); 776 | }); 777 | -------------------------------------------------------------------------------- /src/package-operations.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | PackageManifest, 3 | MonorepoPackageManifest, 4 | } from '@metamask/action-utils'; 5 | import { 6 | getPackageManifest, 7 | getWorkspaceLocations, 8 | ManifestDependencyFieldNames, 9 | ManifestFieldNames, 10 | validateMonorepoPackageManifest, 11 | validatePackageManifestVersion, 12 | validatePolyrepoPackageManifest, 13 | writeJsonFile, 14 | } from '@metamask/action-utils'; 15 | import { parseChangelog, updateChangelog } from '@metamask/auto-changelog'; 16 | import { promises as fs } from 'fs'; 17 | import pathUtils from 'path'; 18 | import * as markdown from 'prettier/plugins/markdown'; 19 | import { format } from 'prettier/standalone'; 20 | 21 | import { didPackageChange } from './git-operations'; 22 | import { WORKSPACE_ROOT, isErrorWithCode } from './utils'; 23 | 24 | export interface PackageMetadata { 25 | readonly dirName: string; 26 | readonly manifest: PackageManifest | MonorepoPackageManifest; 27 | readonly name: string; 28 | readonly dirPath: string; 29 | } 30 | 31 | interface UpdateSpecification { 32 | readonly newVersion: string; 33 | readonly repositoryUrl: string; 34 | readonly shouldUpdateChangelog: boolean; 35 | } 36 | 37 | interface MonorepoUpdateSpecification extends UpdateSpecification { 38 | readonly packagesToUpdate: ReadonlySet; 39 | readonly synchronizeVersions: boolean; 40 | } 41 | 42 | const MANIFEST_FILE_NAME = 'package.json'; 43 | const CHANGELOG_FILE_NAME = 'CHANGELOG.md'; 44 | 45 | /** 46 | * Recursively finds the package manifest for each workspace, and collects 47 | * metadata for each package. 48 | * 49 | * @param workspaces - The list of workspace patterns given in the root manifest. 50 | * @param rootDir - The monorepo root directory. 51 | * @param parentDir - The parent directory of the current package. 52 | * @returns The metadata for all packages in the monorepo. 53 | */ 54 | export async function getMetadataForAllPackages( 55 | workspaces: string[], 56 | rootDir: string = WORKSPACE_ROOT, 57 | parentDir = '', 58 | ): Promise> { 59 | const workspaceLocations = await getWorkspaceLocations(workspaces, rootDir); 60 | 61 | return workspaceLocations.reduce>>( 62 | async (promise, workspaceDirectory) => { 63 | const result = await promise; 64 | 65 | const fullWorkspacePath = pathUtils.join(rootDir, workspaceDirectory); 66 | if ((await fs.lstat(fullWorkspacePath)).isDirectory()) { 67 | const rawManifest = await getPackageManifest(fullWorkspacePath); 68 | 69 | // If the package is a sub-workspace, resolve all packages in the sub-workspace and add them 70 | // to the result. 71 | if (ManifestFieldNames.Workspaces in rawManifest) { 72 | const rootManifest = validatePackageManifestVersion( 73 | rawManifest, 74 | workspaceDirectory, 75 | ); 76 | 77 | const manifest = validateMonorepoPackageManifest( 78 | rootManifest, 79 | workspaceDirectory, 80 | ); 81 | 82 | const name = manifest[ManifestFieldNames.Name]; 83 | if (!name) { 84 | throw new Error( 85 | `Expected sub-workspace in "${workspaceDirectory}" to have a name.`, 86 | ); 87 | } 88 | 89 | return { 90 | ...result, 91 | ...(await getMetadataForAllPackages( 92 | manifest.workspaces, 93 | workspaceDirectory, 94 | workspaceDirectory, 95 | )), 96 | [name]: { 97 | dirName: pathUtils.basename(workspaceDirectory), 98 | manifest, 99 | name, 100 | dirPath: pathUtils.join(parentDir, workspaceDirectory), 101 | }, 102 | }; 103 | } 104 | 105 | const manifest = validatePolyrepoPackageManifest( 106 | rawManifest, 107 | workspaceDirectory, 108 | ); 109 | 110 | return { 111 | ...result, 112 | [manifest.name]: { 113 | dirName: pathUtils.basename(workspaceDirectory), 114 | manifest, 115 | name: manifest.name, 116 | dirPath: pathUtils.join(parentDir, workspaceDirectory), 117 | }, 118 | }; 119 | } 120 | 121 | return result; 122 | }, 123 | Promise.resolve({}), 124 | ); 125 | } 126 | 127 | /** 128 | * Determines the set of packages whose versions should be bumped and whose 129 | * changelogs should be updated. 130 | * 131 | * @param allPackages - The metadata of all packages in the monorepo. 132 | * @param synchronizeVersions - Whether to synchronize the versions of all 133 | * packages. 134 | * @param tags - All tags for the release's base git branch. 135 | * @returns The names of the packages to update. 136 | */ 137 | export async function getPackagesToUpdate( 138 | allPackages: Record, 139 | synchronizeVersions: boolean, 140 | tags: ReadonlySet, 141 | ): Promise> { 142 | // In order to synchronize versions, we must update every package. 143 | if (synchronizeVersions) { 144 | return new Set(Object.keys(allPackages)); 145 | } 146 | 147 | // If we're not synchronizing versions, we only update changed packages. 148 | const shouldBeUpdated: Set = new Set(); 149 | // We use a for-loop here instead of Promise.all because didPackageChange 150 | // must be called serially. 151 | for (const packageName of Object.keys(allPackages)) { 152 | if (await didPackageChange(tags, allPackages[packageName])) { 153 | shouldBeUpdated.add(packageName); 154 | } 155 | } 156 | 157 | if (shouldBeUpdated.size === 0) { 158 | throw new Error(`There are no packages to update.`); 159 | } 160 | return shouldBeUpdated; 161 | } 162 | 163 | /** 164 | * Updates the manifests and changelogs of all packages in the monorepo per the 165 | * update specification. Writes the new manifests to disk. The following changes 166 | * are made to the new manifests: 167 | * 168 | * - The "version" field is replaced with the new version. 169 | * - If package versions are being synchronized, updates their version ranges 170 | * wherever they appear as dependencies. 171 | * 172 | * @param allPackages - The metadata of all monorepo packages. 173 | * @param updateSpecification - The update specification. 174 | */ 175 | export async function updatePackages( 176 | allPackages: Record>, 177 | updateSpecification: MonorepoUpdateSpecification, 178 | ): Promise { 179 | const { packagesToUpdate } = updateSpecification; 180 | await Promise.all( 181 | Array.from(packagesToUpdate.keys()).map(async (packageName) => 182 | updatePackage(allPackages[packageName], updateSpecification), 183 | ), 184 | ); 185 | } 186 | 187 | /** 188 | * Updates the manifest and changelog of the given package per the update 189 | * specification and writes the changes to disk. The following changes are made 190 | * to the manifest: 191 | * 192 | * - The "version" field is replaced with the new version. 193 | * - If package versions are being synchronized, updates their version ranges 194 | * wherever they appear as dependencies. 195 | * 196 | * @param packageMetadata - The metadata of the package to update. 197 | * @param packageMetadata.dirPath - The full path to the directory that holds 198 | * the package. 199 | * @param packageMetadata.manifest - The information within the `package.json` 200 | * file for the package. 201 | * @param updateSpecification - The update specification, which determines how 202 | * the update is performed. 203 | * @param rootDir - The full path to the project. 204 | */ 205 | export async function updatePackage( 206 | packageMetadata: { dirPath: string; manifest: Partial }, 207 | updateSpecification: UpdateSpecification | MonorepoUpdateSpecification, 208 | rootDir: string = WORKSPACE_ROOT, 209 | ): Promise { 210 | await Promise.all([ 211 | writeJsonFile( 212 | pathUtils.join(rootDir, packageMetadata.dirPath, MANIFEST_FILE_NAME), 213 | getUpdatedManifest(packageMetadata.manifest, updateSpecification), 214 | ), 215 | updateSpecification.shouldUpdateChangelog 216 | ? updatePackageChangelog(packageMetadata, updateSpecification) 217 | : Promise.resolve(), 218 | ]); 219 | } 220 | 221 | /** 222 | * Format the given changelog using Prettier. This is extracted into a separate 223 | * function for coverage purposes. 224 | * 225 | * @param changelog - The changelog to format. 226 | * @returns The formatted changelog. 227 | */ 228 | export async function formatChangelog(changelog: string) { 229 | return await format(changelog, { 230 | parser: 'markdown', 231 | plugins: [markdown], 232 | }); 233 | } 234 | 235 | /** 236 | * Updates the changelog file of the given package, using 237 | * `@metamask/auto-changelog`. Assumes that the changelog file is located at the 238 | * package root directory and named "CHANGELOG.md". 239 | * 240 | * @param packageMetadata - The metadata of the package to update. 241 | * @param packageMetadata.dirPath - The full path to the directory that holds 242 | * the package. 243 | * @param packageMetadata.manifest - The information within the `package.json` 244 | * file for the package. 245 | * @param updateSpecification - The update specification, which determines how 246 | * the update is performed. 247 | * @param rootDir - The full path to the project. 248 | * @returns The result of writing to the changelog. 249 | */ 250 | async function updatePackageChangelog( 251 | packageMetadata: { dirPath: string; manifest: Partial }, 252 | updateSpecification: UpdateSpecification | MonorepoUpdateSpecification, 253 | rootDir: string = WORKSPACE_ROOT, 254 | ): Promise { 255 | const { dirPath: projectRootDirectory } = packageMetadata; 256 | const { newVersion, repositoryUrl } = updateSpecification; 257 | 258 | let changelogContent: string; 259 | const packagePath = pathUtils.join(rootDir, projectRootDirectory); 260 | const changelogPath = pathUtils.join(packagePath, CHANGELOG_FILE_NAME); 261 | 262 | try { 263 | changelogContent = await fs.readFile(changelogPath, 'utf-8'); 264 | } catch (error) { 265 | // If the error is not a file not found error, throw it 266 | if (!isErrorWithCode(error) || error.code !== 'ENOENT') { 267 | console.error(`Failed to read changelog in "${projectRootDirectory}".`); 268 | throw error; 269 | } 270 | 271 | console.warn(`Failed to read changelog in "${projectRootDirectory}".`); 272 | return undefined; 273 | } 274 | 275 | const newChangelogContent = await updateChangelog({ 276 | changelogContent, 277 | currentVersion: newVersion, 278 | isReleaseCandidate: true, 279 | projectRootDirectory, 280 | repoUrl: repositoryUrl, 281 | formatter: formatChangelog, 282 | }); 283 | 284 | if (newChangelogContent) { 285 | return await fs.writeFile(changelogPath, newChangelogContent); 286 | } 287 | 288 | const hasUnReleased = hasUnreleasedChanges(changelogContent, repositoryUrl); 289 | if (!hasUnReleased) { 290 | const packageName = packageMetadata.manifest.name; 291 | throw new Error( 292 | `"updateChangelog" returned an empty value for package ${ 293 | packageName ? `"${packageName}"` : `at "${packagePath}"` 294 | }.`, 295 | ); 296 | } 297 | 298 | return undefined; 299 | } 300 | 301 | /** 302 | * Checks if there are unreleased changes in the changelog. 303 | * @param changelogContent - The string formatted changelog. 304 | * @param repositoryUrl - The repository url. 305 | * @returns The boolean true if there are unreleased changes, otherwise false. 306 | */ 307 | function hasUnreleasedChanges( 308 | changelogContent: string, 309 | repositoryUrl: string, 310 | ): boolean { 311 | const changelog = parseChangelog({ 312 | changelogContent, 313 | repoUrl: repositoryUrl, 314 | formatter: formatChangelog, 315 | }); 316 | 317 | return Object.keys(changelog.getUnreleasedChanges()).length !== 0; 318 | } 319 | 320 | /** 321 | * Updates the given manifest per the update specification as follows: 322 | * 323 | * - Updates the manifest's "version" field to the new version. 324 | * - If monorepo package versions are being synchronized, updates their version 325 | * ranges wherever they appear as dependencies. 326 | * 327 | * @param currentManifest - The package's current manifest, as read from disk. 328 | * @param updateSpecification - The update specification, which determines how 329 | * the update is performed. 330 | * @returns The updated manifest. 331 | */ 332 | function getUpdatedManifest( 333 | currentManifest: Partial, 334 | updateSpecification: UpdateSpecification | MonorepoUpdateSpecification, 335 | ) { 336 | const { newVersion } = updateSpecification; 337 | if ( 338 | isMonorepoUpdateSpecification(updateSpecification) && 339 | updateSpecification.synchronizeVersions 340 | ) { 341 | // If we're synchronizing the versions of our updated packages, we also 342 | // synchronize their versions whenever they appear as a dependency. 343 | return { 344 | ...currentManifest, 345 | ...getUpdatedDependencyFields(currentManifest, updateSpecification), 346 | version: newVersion, 347 | }; 348 | } 349 | 350 | // If we're not synchronizing versions, we leave all dependencies as they are. 351 | return { ...currentManifest, version: newVersion }; 352 | } 353 | 354 | /** 355 | * Gets the updated dependency fields of the given manifest per the given 356 | * update specification. 357 | * 358 | * @param manifest - The package's current manifest, as read from disk. 359 | * @param updateSpecification - The update specification, which determines how 360 | * the update is performed. 361 | * @returns The updated dependency fields of the manifest. 362 | */ 363 | function getUpdatedDependencyFields( 364 | manifest: Partial, 365 | updateSpecification: MonorepoUpdateSpecification, 366 | ): Partial> { 367 | const { newVersion, packagesToUpdate } = updateSpecification; 368 | return Object.values(ManifestDependencyFieldNames).reduce( 369 | (newDepsFields: Record, fieldName) => { 370 | if (fieldName in manifest) { 371 | newDepsFields[fieldName] = getUpdatedDependencyField( 372 | manifest[fieldName] as Record, 373 | packagesToUpdate, 374 | newVersion, 375 | ); 376 | } 377 | 378 | return newDepsFields; 379 | }, 380 | {}, 381 | ); 382 | } 383 | 384 | /** 385 | * Updates the version range of every package in the list that's present in the 386 | * dependency object to "^", where is the specified new 387 | * version. 388 | * 389 | * @param dependencyObject - The package.json dependency object to update. 390 | * @param packagesToUpdate - The packages to update the version of. 391 | * @param newVersion - The new version of the given packages. 392 | * @returns The updated dependency object. 393 | */ 394 | function getUpdatedDependencyField( 395 | dependencyObject: Record, 396 | packagesToUpdate: ReadonlySet, 397 | newVersion: string, 398 | ): Record { 399 | const newVersionRange = `^${newVersion}`; 400 | return Object.keys(dependencyObject).reduce( 401 | (newDeps: Record, packageName) => { 402 | newDeps[packageName] = 403 | packagesToUpdate.has(packageName) && 404 | !dependencyObject[packageName].startsWith('workspace:') 405 | ? newVersionRange 406 | : dependencyObject[packageName]; 407 | 408 | return newDeps; 409 | }, 410 | {}, 411 | ); 412 | } 413 | 414 | /** 415 | * Type guard for checking if an update specification is a monorepo update 416 | * specification. 417 | * 418 | * @param specification - The update specification object to check. 419 | * @returns Whether the given specification object is a monorepo update 420 | * specification. 421 | */ 422 | function isMonorepoUpdateSpecification( 423 | specification: UpdateSpecification | MonorepoUpdateSpecification, 424 | ): specification is MonorepoUpdateSpecification { 425 | return ( 426 | 'packagesToUpdate' in specification && 427 | 'synchronizeVersions' in specification 428 | ); 429 | } 430 | -------------------------------------------------------------------------------- /src/update.test.ts: -------------------------------------------------------------------------------- 1 | import * as actionsCore from '@actions/core'; 2 | import * as actionUtils from '@metamask/action-utils'; 3 | 4 | import * as gitOperations from './git-operations'; 5 | import * as packageOperations from './package-operations'; 6 | import { performUpdate } from './update'; 7 | import * as utils from './utils'; 8 | 9 | jest.mock('@actions/core', () => { 10 | return { 11 | setOutput: jest.fn(), 12 | }; 13 | }); 14 | 15 | jest.mock('@metamask/action-utils', () => { 16 | const actualModule = jest.requireActual('@metamask/action-utils'); 17 | return { 18 | ...actualModule, 19 | getPackageManifest: jest.fn(), 20 | }; 21 | }); 22 | 23 | jest.mock('./git-operations', () => { 24 | return { 25 | getRepositoryHttpsUrl: jest.fn(), 26 | getTags: jest.fn(), 27 | }; 28 | }); 29 | 30 | jest.mock('./package-operations', () => { 31 | const actualModule = jest.requireActual('./package-operations'); 32 | return { 33 | ...actualModule, 34 | getMetadataForAllPackages: jest.fn(), 35 | getPackagesToUpdate: jest.fn(), 36 | updatePackage: jest.fn(), 37 | updatePackages: jest.fn(), 38 | }; 39 | }); 40 | 41 | jest.mock('./utils', () => { 42 | const actualModule = jest.requireActual('./utils'); 43 | return { 44 | ...actualModule, 45 | WORKSPACE_ROOT: 'rootDir', 46 | }; 47 | }); 48 | 49 | describe('performUpdate', () => { 50 | const mockRepoUrl = 'https://fake'; 51 | 52 | let getRepositoryHttpsUrlMock: jest.SpyInstance; 53 | let getTagsMock: jest.SpyInstance; 54 | let consoleLogMock: jest.SpyInstance; 55 | let getPackageManifestMock: jest.SpyInstance; 56 | let setActionOutputMock: jest.SpyInstance; 57 | 58 | beforeEach(() => { 59 | getRepositoryHttpsUrlMock = jest 60 | .spyOn(gitOperations, 'getRepositoryHttpsUrl') 61 | .mockImplementationOnce(async () => mockRepoUrl); 62 | getTagsMock = jest.spyOn(gitOperations, 'getTags'); 63 | consoleLogMock = jest 64 | .spyOn(console, 'log') 65 | .mockImplementation(() => undefined); 66 | getPackageManifestMock = jest.spyOn(actionUtils, 'getPackageManifest'); 67 | setActionOutputMock = jest.spyOn(actionsCore, 'setOutput'); 68 | }); 69 | 70 | it('updates a polyrepo with release-version input', async () => { 71 | const packageName = 'A'; 72 | const oldVersion = '1.1.0'; 73 | const newVersion = '2.0.0'; 74 | 75 | getTagsMock.mockImplementationOnce(async () => [ 76 | new Set(['v1.0.0', 'v1.1.0']), 77 | 'v1.1.0', 78 | ]); 79 | 80 | getPackageManifestMock.mockImplementationOnce(async () => { 81 | return { 82 | name: packageName, 83 | version: oldVersion, 84 | }; 85 | }); 86 | 87 | await performUpdate({ ReleaseType: null, ReleaseVersion: newVersion }); 88 | expect(getRepositoryHttpsUrlMock).toHaveBeenCalledTimes(1); 89 | expect(getTagsMock).toHaveBeenCalledTimes(1); 90 | expect(consoleLogMock).toHaveBeenCalledTimes(1); 91 | expect(consoleLogMock).toHaveBeenCalledWith( 92 | expect.stringMatching(/Applying polyrepo workflow/u), 93 | ); 94 | expect(packageOperations.updatePackage).toHaveBeenCalledTimes(1); 95 | expect(packageOperations.updatePackage).toHaveBeenCalledWith( 96 | { 97 | dirPath: './', 98 | manifest: { name: packageName, version: oldVersion }, 99 | }, 100 | { newVersion, repositoryUrl: mockRepoUrl, shouldUpdateChangelog: true }, 101 | ); 102 | expect(setActionOutputMock).toHaveBeenCalledTimes(1); 103 | expect(setActionOutputMock).toHaveBeenCalledWith('NEW_VERSION', newVersion); 104 | }); 105 | 106 | it('updates a polyrepo with release-type input', async () => { 107 | const packageName = 'A'; 108 | const oldVersion = '1.1.0'; 109 | const newVersion = '2.0.0'; 110 | 111 | getTagsMock.mockImplementationOnce(async () => [ 112 | new Set(['v1.0.0', 'v1.1.0']), 113 | 'v1.1.0', 114 | ]); 115 | 116 | getPackageManifestMock.mockImplementationOnce(async () => { 117 | return { 118 | name: packageName, 119 | version: oldVersion, 120 | }; 121 | }); 122 | 123 | await performUpdate({ 124 | ReleaseType: utils.AcceptedSemverReleaseTypes.Major, 125 | ReleaseVersion: null, 126 | }); 127 | expect(getRepositoryHttpsUrlMock).toHaveBeenCalledTimes(1); 128 | expect(getTagsMock).toHaveBeenCalledTimes(1); 129 | expect(consoleLogMock).toHaveBeenCalledTimes(1); 130 | expect(consoleLogMock).toHaveBeenCalledWith( 131 | expect.stringMatching(/Applying polyrepo workflow/u), 132 | ); 133 | expect(packageOperations.updatePackage).toHaveBeenCalledTimes(1); 134 | expect(packageOperations.updatePackage).toHaveBeenCalledWith( 135 | { 136 | dirPath: './', 137 | manifest: { name: packageName, version: oldVersion }, 138 | }, 139 | { newVersion, repositoryUrl: mockRepoUrl, shouldUpdateChangelog: true }, 140 | ); 141 | expect(setActionOutputMock).toHaveBeenCalledTimes(1); 142 | expect(setActionOutputMock).toHaveBeenCalledWith('NEW_VERSION', newVersion); 143 | }); 144 | 145 | it('updates a monorepo (major version bump)', async () => { 146 | const rootManifestName = 'root'; 147 | const oldVersion = '1.1.0'; 148 | const newVersion = '2.0.0'; 149 | const workspaces: readonly string[] = ['a', 'b', 'c']; 150 | 151 | getTagsMock.mockImplementationOnce(async () => [ 152 | new Set(['v1.0.0', 'v1.1.0']), 153 | 'v1.1.0', 154 | ]); 155 | 156 | getPackageManifestMock.mockImplementationOnce(async () => { 157 | return { 158 | name: rootManifestName, 159 | version: oldVersion, 160 | private: true, 161 | workspaces: [...workspaces], 162 | }; 163 | }); 164 | 165 | const getPackagesMetadataMock = jest 166 | .spyOn(packageOperations, 'getMetadataForAllPackages') 167 | .mockImplementationOnce(async () => { 168 | return { a: {}, b: {}, c: {} } as any; 169 | }); 170 | 171 | const getPackagesToUpdateMock = jest 172 | .spyOn(packageOperations, 'getPackagesToUpdate') 173 | .mockImplementationOnce(async () => new Set(workspaces)); 174 | 175 | await performUpdate({ ReleaseType: null, ReleaseVersion: newVersion }); 176 | 177 | expect(getRepositoryHttpsUrlMock).toHaveBeenCalledTimes(1); 178 | expect(getTagsMock).toHaveBeenCalledTimes(1); 179 | expect(consoleLogMock).toHaveBeenCalledTimes(1); 180 | expect(consoleLogMock).toHaveBeenCalledWith( 181 | expect.stringMatching(/Applying monorepo workflow/u), 182 | ); 183 | expect(getPackagesMetadataMock).toHaveBeenCalledTimes(1); 184 | 185 | expect(getPackagesToUpdateMock).toHaveBeenCalledTimes(1); 186 | expect(getPackagesToUpdateMock).toHaveBeenCalledWith( 187 | { a: {}, b: {}, c: {} }, 188 | true, 189 | new Set(['v1.0.0', 'v1.1.0']), 190 | ); 191 | 192 | expect(packageOperations.updatePackages).toHaveBeenCalledTimes(1); 193 | expect(packageOperations.updatePackages).toHaveBeenCalledWith( 194 | { a: {}, b: {}, c: {} }, 195 | { 196 | newVersion, 197 | packagesToUpdate: new Set(workspaces), 198 | repositoryUrl: mockRepoUrl, 199 | shouldUpdateChangelog: true, 200 | synchronizeVersions: true, 201 | }, 202 | ); 203 | 204 | expect(packageOperations.updatePackage).toHaveBeenCalledTimes(1); 205 | expect(packageOperations.updatePackage).toHaveBeenCalledWith( 206 | { 207 | dirPath: './', 208 | manifest: { 209 | name: rootManifestName, 210 | private: true, 211 | version: oldVersion, 212 | workspaces: [...workspaces], 213 | }, 214 | }, 215 | { 216 | newVersion, 217 | packagesToUpdate: new Set(workspaces), 218 | repositoryUrl: mockRepoUrl, 219 | shouldUpdateChangelog: false, 220 | synchronizeVersions: true, 221 | }, 222 | ); 223 | 224 | expect(setActionOutputMock).toHaveBeenCalledTimes(1); 225 | expect(setActionOutputMock).toHaveBeenCalledWith('NEW_VERSION', newVersion); 226 | }); 227 | 228 | it('updates a monorepo (non-major version bump)', async () => { 229 | const rootManifestName = 'root'; 230 | const oldVersion = '1.1.0'; 231 | const newVersion = '1.2.0'; 232 | const workspaces: readonly string[] = ['a', 'b', 'c']; 233 | 234 | getTagsMock.mockImplementationOnce(async () => [ 235 | new Set(['v1.0.0', 'v1.1.0']), 236 | 'v1.1.0', 237 | ]); 238 | 239 | getPackageManifestMock.mockImplementationOnce(async () => { 240 | return { 241 | name: rootManifestName, 242 | version: oldVersion, 243 | private: true, 244 | workspaces: [...workspaces], 245 | }; 246 | }); 247 | 248 | const getPackagesMetadataMock = jest 249 | .spyOn(packageOperations, 'getMetadataForAllPackages') 250 | .mockImplementationOnce(async () => { 251 | return { a: {}, b: {}, c: {} } as any; 252 | }); 253 | 254 | const getPackagesToUpdateMock = jest 255 | .spyOn(packageOperations, 'getPackagesToUpdate') 256 | .mockImplementationOnce(async () => new Set(workspaces)); 257 | 258 | await performUpdate({ ReleaseType: null, ReleaseVersion: newVersion }); 259 | 260 | expect(getRepositoryHttpsUrlMock).toHaveBeenCalledTimes(1); 261 | expect(getTagsMock).toHaveBeenCalledTimes(1); 262 | expect(consoleLogMock).toHaveBeenCalledTimes(1); 263 | expect(consoleLogMock).toHaveBeenCalledWith( 264 | expect.stringMatching(/Applying monorepo workflow/u), 265 | ); 266 | expect(getPackagesMetadataMock).toHaveBeenCalledTimes(1); 267 | 268 | expect(getPackagesToUpdateMock).toHaveBeenCalledTimes(1); 269 | expect(getPackagesToUpdateMock).toHaveBeenCalledWith( 270 | { a: {}, b: {}, c: {} }, 271 | false, 272 | new Set(['v1.0.0', 'v1.1.0']), 273 | ); 274 | 275 | expect(packageOperations.updatePackages).toHaveBeenCalledTimes(1); 276 | expect(packageOperations.updatePackages).toHaveBeenCalledWith( 277 | { a: {}, b: {}, c: {} }, 278 | { 279 | newVersion, 280 | packagesToUpdate: new Set(workspaces), 281 | repositoryUrl: mockRepoUrl, 282 | shouldUpdateChangelog: true, 283 | synchronizeVersions: false, 284 | }, 285 | ); 286 | 287 | expect(packageOperations.updatePackage).toHaveBeenCalledTimes(1); 288 | expect(packageOperations.updatePackage).toHaveBeenCalledWith( 289 | { 290 | dirPath: './', 291 | manifest: { 292 | name: rootManifestName, 293 | private: true, 294 | version: oldVersion, 295 | workspaces: [...workspaces], 296 | }, 297 | }, 298 | { 299 | newVersion, 300 | packagesToUpdate: new Set(workspaces), 301 | repositoryUrl: mockRepoUrl, 302 | shouldUpdateChangelog: false, 303 | synchronizeVersions: false, 304 | }, 305 | ); 306 | 307 | expect(setActionOutputMock).toHaveBeenCalledTimes(1); 308 | expect(setActionOutputMock).toHaveBeenCalledWith('NEW_VERSION', newVersion); 309 | }); 310 | 311 | it('updates a monorepo (within major version 0)', async () => { 312 | const rootManifestName = 'root'; 313 | const oldVersion = '0.1.0'; 314 | const newVersion = '0.2.0'; 315 | const workspaces: readonly string[] = ['a', 'b', 'c']; 316 | 317 | getTagsMock.mockImplementationOnce(async () => [ 318 | new Set(['v0.0.0', 'v0.1.0']), 319 | 'v0.1.0', 320 | ]); 321 | 322 | getPackageManifestMock.mockImplementationOnce(async () => { 323 | return { 324 | name: rootManifestName, 325 | version: oldVersion, 326 | private: true, 327 | workspaces: [...workspaces], 328 | }; 329 | }); 330 | 331 | const getPackagesMetadataMock = jest 332 | .spyOn(packageOperations, 'getMetadataForAllPackages') 333 | .mockImplementationOnce(async () => { 334 | return { a: {}, b: {}, c: {} } as any; 335 | }); 336 | 337 | const getPackagesToUpdateMock = jest 338 | .spyOn(packageOperations, 'getPackagesToUpdate') 339 | .mockImplementationOnce(async () => new Set(workspaces)); 340 | 341 | await performUpdate({ ReleaseType: null, ReleaseVersion: newVersion }); 342 | 343 | expect(getRepositoryHttpsUrlMock).toHaveBeenCalledTimes(1); 344 | expect(getTagsMock).toHaveBeenCalledTimes(1); 345 | expect(consoleLogMock).toHaveBeenCalledTimes(1); 346 | expect(consoleLogMock).toHaveBeenCalledWith( 347 | expect.stringMatching(/Applying monorepo workflow/u), 348 | ); 349 | expect(getPackagesMetadataMock).toHaveBeenCalledTimes(1); 350 | 351 | expect(getPackagesToUpdateMock).toHaveBeenCalledTimes(1); 352 | expect(getPackagesToUpdateMock).toHaveBeenCalledWith( 353 | { a: {}, b: {}, c: {} }, 354 | true, 355 | new Set(['v0.0.0', 'v0.1.0']), 356 | ); 357 | 358 | expect(packageOperations.updatePackages).toHaveBeenCalledTimes(1); 359 | expect(packageOperations.updatePackages).toHaveBeenCalledWith( 360 | { a: {}, b: {}, c: {} }, 361 | { 362 | newVersion, 363 | packagesToUpdate: new Set(workspaces), 364 | repositoryUrl: mockRepoUrl, 365 | shouldUpdateChangelog: true, 366 | synchronizeVersions: true, 367 | }, 368 | ); 369 | 370 | expect(packageOperations.updatePackage).toHaveBeenCalledTimes(1); 371 | expect(packageOperations.updatePackage).toHaveBeenCalledWith( 372 | { 373 | dirPath: './', 374 | manifest: { 375 | name: rootManifestName, 376 | private: true, 377 | version: oldVersion, 378 | workspaces: [...workspaces], 379 | }, 380 | }, 381 | { 382 | newVersion, 383 | packagesToUpdate: new Set(workspaces), 384 | repositoryUrl: mockRepoUrl, 385 | shouldUpdateChangelog: false, 386 | synchronizeVersions: true, 387 | }, 388 | ); 389 | 390 | expect(setActionOutputMock).toHaveBeenCalledTimes(1); 391 | expect(setActionOutputMock).toHaveBeenCalledWith('NEW_VERSION', newVersion); 392 | }); 393 | 394 | it('throws if the new version is less than the current version', async () => { 395 | const packageName = 'A'; 396 | const oldVersion = '1.1.0'; 397 | const newVersion = '1.0.0'; 398 | 399 | getTagsMock.mockImplementationOnce(async () => [ 400 | new Set(['v1.1.0']), 401 | 'v1.1.0', 402 | ]); 403 | 404 | getPackageManifestMock.mockImplementationOnce(async () => { 405 | return { 406 | name: packageName, 407 | version: oldVersion, 408 | }; 409 | }); 410 | 411 | await expect( 412 | performUpdate({ ReleaseType: null, ReleaseVersion: newVersion }), 413 | ).rejects.toThrow(/^The new version "1\.0\.0" is not greater than/u); 414 | }); 415 | 416 | it('throws if the new version is equal to the current version', async () => { 417 | const packageName = 'A'; 418 | const oldVersion = '1.1.0'; 419 | const newVersion = '1.1.0'; 420 | 421 | getTagsMock.mockImplementationOnce(async () => [ 422 | new Set(['v1.1.0']), 423 | 'v1.1.0', 424 | ]); 425 | 426 | getPackageManifestMock.mockImplementationOnce(async () => { 427 | return { 428 | name: packageName, 429 | version: oldVersion, 430 | }; 431 | }); 432 | 433 | await expect( 434 | performUpdate({ ReleaseType: null, ReleaseVersion: newVersion }), 435 | ).rejects.toThrow(/^The new version "1\.1\.0" is not greater than/u); 436 | }); 437 | 438 | it('throws if there is already a tag for the new version', async () => { 439 | const packageName = 'A'; 440 | const oldVersion = '1.1.0'; 441 | const newVersion = '2.0.0'; 442 | 443 | getTagsMock.mockImplementationOnce(async () => [ 444 | new Set(['v1.0.0', 'v1.1.0', 'v2.0.0']), 445 | 'v2.0.0', 446 | ]); 447 | 448 | getPackageManifestMock.mockImplementationOnce(async () => { 449 | return { 450 | name: packageName, 451 | version: oldVersion, 452 | }; 453 | }); 454 | 455 | await expect( 456 | performUpdate({ ReleaseType: null, ReleaseVersion: newVersion }), 457 | ).rejects.toThrow( 458 | /^Tag "v2\.0\.0" for new version "2\.0\.0" already exists\.$/u, 459 | ); 460 | }); 461 | }); 462 | -------------------------------------------------------------------------------- /src/update.ts: -------------------------------------------------------------------------------- 1 | import { setOutput as setActionOutput } from '@actions/core'; 2 | import type { 3 | MonorepoPackageManifest, 4 | PolyrepoPackageManifest, 5 | } from '@metamask/action-utils'; 6 | import { 7 | getPackageManifest, 8 | isMajorSemverDiff, 9 | ManifestFieldNames, 10 | validateMonorepoPackageManifest, 11 | validatePackageManifestName, 12 | validatePackageManifestVersion, 13 | } from '@metamask/action-utils'; 14 | import type { ReleaseType as SemverReleaseType } from 'semver'; 15 | import semverDiff from 'semver/functions/diff'; 16 | import semverGt from 'semver/functions/gt'; 17 | import semverIncrement from 'semver/functions/inc'; 18 | import semverMajor from 'semver/functions/major'; 19 | 20 | import { getRepositoryHttpsUrl, getTags } from './git-operations'; 21 | import { 22 | getMetadataForAllPackages, 23 | getPackagesToUpdate, 24 | updatePackage, 25 | updatePackages, 26 | } from './package-operations'; 27 | import type { ActionInputs } from './utils'; 28 | import { WORKSPACE_ROOT } from './utils'; 29 | 30 | /** 31 | * Action entry function. Gets git tags, reads the work space root package.json, 32 | * and updates the package(s) of the repository per the Action inputs. 33 | * 34 | * @param actionInputs - The inputs to this action. 35 | */ 36 | export async function performUpdate(actionInputs: ActionInputs): Promise { 37 | const repositoryUrl = await getRepositoryHttpsUrl(); 38 | 39 | // Get all git tags. An error is thrown if "git tag" returns no tags and the 40 | // local git history is incomplete. 41 | const [tags] = await getTags(); 42 | 43 | const rawRootManifest = await getPackageManifest(WORKSPACE_ROOT); 44 | const rootManifest = validatePackageManifestVersion( 45 | rawRootManifest, 46 | WORKSPACE_ROOT, 47 | ); 48 | 49 | const { version: currentVersion } = rootManifest; 50 | 51 | // Compute the new version and version diff from the inputs and root manifest 52 | let newVersion: string, versionDiff: SemverReleaseType; 53 | if (actionInputs.ReleaseType) { 54 | newVersion = semverIncrement( 55 | currentVersion, 56 | actionInputs.ReleaseType, 57 | ) as string; 58 | versionDiff = actionInputs.ReleaseType; 59 | } else { 60 | newVersion = actionInputs.ReleaseVersion as string; 61 | versionDiff = semverDiff(currentVersion, newVersion) as SemverReleaseType; 62 | } 63 | 64 | // Ensure that the new version is greater than the current version, and that 65 | // there's no existing tag for it. 66 | validateVersion(currentVersion, newVersion, tags); 67 | 68 | if (ManifestFieldNames.Workspaces in rootManifest) { 69 | console.log( 70 | 'Project appears to have workspaces. Applying monorepo workflow.', 71 | ); 72 | 73 | await updateMonorepo( 74 | newVersion, 75 | versionDiff, 76 | validateMonorepoPackageManifest(rootManifest, WORKSPACE_ROOT), 77 | repositoryUrl, 78 | tags, 79 | ); 80 | } else { 81 | console.log( 82 | 'Project does not appear to have any workspaces. Applying polyrepo workflow.', 83 | ); 84 | 85 | await updatePolyrepo( 86 | newVersion, 87 | validatePackageManifestName(rootManifest, WORKSPACE_ROOT), 88 | repositoryUrl, 89 | ); 90 | } 91 | setActionOutput('NEW_VERSION', newVersion); 92 | } 93 | 94 | /** 95 | * Given that checked out git repository is a polyrepo (i.e., a "normal", 96 | * single-package repo), updates the repository's package and its changelog. 97 | * 98 | * @param newVersion - The package's new version. 99 | * @param manifest - The package's parsed package.json file. 100 | * @param repositoryUrl - The HTTPS URL of the repository. 101 | */ 102 | async function updatePolyrepo( 103 | newVersion: string, 104 | manifest: PolyrepoPackageManifest, 105 | repositoryUrl: string, 106 | ): Promise { 107 | await updatePackage( 108 | { dirPath: './', manifest }, 109 | { newVersion, repositoryUrl, shouldUpdateChangelog: true }, 110 | ); 111 | } 112 | 113 | /** 114 | * Given that the checked out repository is a monorepo: 115 | * 116 | * If the semver diff is "major" or if it's the first release of the monorepo 117 | * (inferred from the complete absence of tags), updates all packages. 118 | * Otherwise, updates packages that changed since their previous release. 119 | * The changelog of any updated package will also be updated. 120 | * 121 | * @param newVersion - The new version of the package(s) to update. 122 | * @param versionDiff - A SemVer version diff, e.g. "major" or "prerelease". 123 | * @param rootManifest - The parsed root package.json file of the monorepo. 124 | * @param repositoryUrl - The HTTPS URL of the repository. 125 | * @param tags - All tags reachable from the current git HEAD, as from "git 126 | * tag --merged". 127 | */ 128 | async function updateMonorepo( 129 | newVersion: string, 130 | versionDiff: SemverReleaseType, 131 | rootManifest: MonorepoPackageManifest, 132 | repositoryUrl: string, 133 | tags: ReadonlySet, 134 | ): Promise { 135 | // If the version bump is major or the new major version is still "0", we 136 | // synchronize the versions of all monorepo packages, meaning the "version" 137 | // field of their manifests and their version range specified wherever they 138 | // appear as a dependency. 139 | const synchronizeVersions = 140 | isMajorSemverDiff(versionDiff) || semverMajor(newVersion) === 0; 141 | 142 | // Collect required information to perform updates 143 | const allPackages = await getMetadataForAllPackages(rootManifest.workspaces); 144 | const packagesToUpdate = await getPackagesToUpdate( 145 | allPackages, 146 | synchronizeVersions, 147 | tags, 148 | ); 149 | const updateSpecification = { 150 | newVersion, 151 | packagesToUpdate, 152 | repositoryUrl, 153 | synchronizeVersions, 154 | shouldUpdateChangelog: true, 155 | }; 156 | 157 | // Finally, bump the version of all packages and the root manifest, update the 158 | // changelogs of all updated packages, and add the new version as an output of 159 | // this Action. 160 | await updatePackages(allPackages, updateSpecification); 161 | await updatePackage( 162 | { dirPath: './', manifest: rootManifest }, 163 | { ...updateSpecification, shouldUpdateChangelog: false }, 164 | ); 165 | } 166 | 167 | /** 168 | * Throws an error if the current version is equal to the new version, if a 169 | * tag for the new version already exists, or if the new version is less than 170 | * the current version. 171 | * 172 | * @param currentVersion - The most recently released version. 173 | * @param newVersion - The new version to be released. 174 | * @param tags - All tags reachable from the current git HEAD, as from "git 175 | * tag --merged". 176 | */ 177 | function validateVersion( 178 | currentVersion: string, 179 | newVersion: string, 180 | tags: ReadonlySet, 181 | ) { 182 | if (!semverGt(newVersion, currentVersion)) { 183 | throw new Error( 184 | `The new version "${newVersion}" is not greater than the current version "${currentVersion}".`, 185 | ); 186 | } 187 | 188 | if (tags.has(`v${newVersion}`)) { 189 | throw new Error( 190 | `Tag "v${newVersion}" for new version "${newVersion}" already exists.`, 191 | ); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AcceptedSemverReleaseTypes, 3 | getActionInputs, 4 | InputKeys, 5 | } from './utils'; 6 | 7 | jest.mock('fs', () => ({ 8 | promises: { 9 | readFile: jest.fn(), 10 | writeFile: jest.fn(), 11 | }, 12 | })); 13 | 14 | const mockProcessEnv = ({ 15 | releaseType, 16 | releaseVersion, 17 | }: { 18 | releaseType?: string; 19 | releaseVersion?: string; 20 | }) => { 21 | if (releaseType !== undefined) { 22 | process.env[InputKeys.ReleaseType] = releaseType; 23 | } 24 | 25 | if (releaseVersion !== undefined) { 26 | process.env[InputKeys.ReleaseVersion] = releaseVersion; 27 | } 28 | }; 29 | 30 | const unmockProcessEnv = () => { 31 | Object.values(InputKeys).forEach((key) => delete process.env[key]); 32 | }; 33 | 34 | describe('getActionInputs', () => { 35 | afterEach(() => { 36 | unmockProcessEnv(); 37 | }); 38 | 39 | it('correctly parses valid input: release-type', () => { 40 | for (const releaseType of Object.values(AcceptedSemverReleaseTypes)) { 41 | mockProcessEnv({ releaseType }); 42 | expect(getActionInputs()).toStrictEqual({ 43 | ReleaseType: releaseType, 44 | ReleaseVersion: null, 45 | }); 46 | } 47 | }); 48 | 49 | it('correctly parses valid input: release-version', () => { 50 | const versions = ['1.0.0', '2.0.0', '1.0.1', '0.1.0']; 51 | for (const releaseVersion of versions) { 52 | mockProcessEnv({ releaseVersion }); 53 | expect(getActionInputs()).toStrictEqual({ 54 | ReleaseType: null, 55 | ReleaseVersion: releaseVersion, 56 | }); 57 | } 58 | }); 59 | 60 | it('throws if neither "release-type" nor "release-version" are specified', () => { 61 | mockProcessEnv({}); 62 | expect(() => getActionInputs()).toThrow(/^Must specify either/u); 63 | }); 64 | 65 | it('throws if both "release-type" and "release-version" are specified', () => { 66 | mockProcessEnv({ releaseType: 'patch', releaseVersion: '1.0.0' }); 67 | expect(() => getActionInputs()).toThrow(/not both.$/u); 68 | }); 69 | 70 | it('throws if "release-type" is not an accepted SemVer release type', () => { 71 | const invalidTypes = ['v1.0.0', '1.0.0', 'premajor', 'foo']; 72 | for (const releaseType of invalidTypes) { 73 | mockProcessEnv({ releaseType }); 74 | expect(() => getActionInputs()).toThrow(/^Unrecognized/u); 75 | } 76 | }); 77 | 78 | it('throws if neither "release-version" is not a valid SemVer version', () => { 79 | const invalidVersions = ['v1.0.0', 'major', 'premajor', 'foo']; 80 | for (const releaseVersion of invalidVersions) { 81 | mockProcessEnv({ releaseVersion }); 82 | expect(() => getActionInputs()).toThrow(/a plain SemVer version string/u); 83 | } 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { isValidSemver, tabs } from '@metamask/action-utils'; 2 | 3 | // Our custom input env keys 4 | export enum InputKeys { 5 | ReleaseType = 'RELEASE_TYPE', 6 | ReleaseVersion = 'RELEASE_VERSION', 7 | } 8 | 9 | /** 10 | * SemVer release types that are accepted by this Action. 11 | */ 12 | export enum AcceptedSemverReleaseTypes { 13 | Major = 'major', 14 | Minor = 'minor', 15 | Patch = 'patch', 16 | } 17 | 18 | /** 19 | * Add missing properties to "process.env" interface. 20 | */ 21 | declare global { 22 | // eslint-disable-next-line @typescript-eslint/no-namespace 23 | namespace NodeJS { 24 | interface ProcessEnv { 25 | // The root of the workspace running this action 26 | GITHUB_WORKSPACE: string; 27 | [InputKeys.ReleaseType]: string; 28 | [InputKeys.ReleaseVersion]: string; 29 | } 30 | } 31 | } 32 | 33 | /** 34 | * The names of the inputs to the Action, per action.yml. 35 | */ 36 | export enum InputNames { 37 | ReleaseType = 'release-type', 38 | ReleaseVersion = 'release-version', 39 | } 40 | 41 | export interface ActionInputs { 42 | readonly ReleaseType: AcceptedSemverReleaseTypes | null; 43 | readonly ReleaseVersion: string | null; 44 | } 45 | 46 | export const WORKSPACE_ROOT = process.env.GITHUB_WORKSPACE; 47 | 48 | /** 49 | * Validates and returns the inputs to the Action. 50 | * We perform additional validation because the GitHub Actions configuration 51 | * syntax is insufficient to express the requirements we have of our inputs. 52 | * 53 | * @returns The parsed and validated inputs to the Action. 54 | */ 55 | export function getActionInputs(): ActionInputs { 56 | const inputs: ActionInputs = { 57 | ReleaseType: 58 | (getProcessEnvValue( 59 | InputKeys.ReleaseType, 60 | ) as AcceptedSemverReleaseTypes) || null, 61 | ReleaseVersion: getProcessEnvValue(InputKeys.ReleaseVersion) || null, 62 | }; 63 | validateActionInputs(inputs); 64 | return inputs; 65 | } 66 | 67 | /** 68 | * Utility function to get the trimmed value of a particular key of process.env. 69 | * 70 | * @param key - The key of process.env to access. 71 | * @returns The trimmed string value of the process.env key. Returns an empty 72 | * string if the key is not set. 73 | */ 74 | function getProcessEnvValue(key: string): string { 75 | return process.env[key]?.trim() ?? ''; 76 | } 77 | 78 | /** 79 | * Validates the inputs to the Action, defined earlier in this file. 80 | * Throws an error if validation fails. 81 | * 82 | * @param inputs - The inputs to this action. 83 | */ 84 | function validateActionInputs(inputs: ActionInputs): void { 85 | if (!inputs.ReleaseType && !inputs.ReleaseVersion) { 86 | throw new Error( 87 | `Must specify either "${InputNames.ReleaseType}" or "${InputNames.ReleaseVersion}".`, 88 | ); 89 | } 90 | 91 | if (inputs.ReleaseType && inputs.ReleaseVersion) { 92 | throw new Error( 93 | `Must specify either "${InputNames.ReleaseType}" or "${InputNames.ReleaseVersion}", not both.`, 94 | ); 95 | } 96 | 97 | if ( 98 | inputs.ReleaseType && 99 | !Object.values(AcceptedSemverReleaseTypes).includes(inputs.ReleaseType) 100 | ) { 101 | const tab = tabs(1, '\n'); 102 | throw new Error( 103 | `Unrecognized "${ 104 | InputNames.ReleaseType 105 | }". Must be one of:${tab}${Object.keys(AcceptedSemverReleaseTypes).join( 106 | tab, 107 | )}`, 108 | ); 109 | } 110 | 111 | if (inputs.ReleaseVersion) { 112 | if (!isValidSemver(inputs.ReleaseVersion)) { 113 | throw new Error( 114 | `"${ 115 | InputNames.ReleaseVersion as string 116 | }" must be a plain SemVer version string. Received: ${ 117 | inputs.ReleaseVersion as string 118 | }`, 119 | ); 120 | } 121 | } 122 | } 123 | 124 | /** 125 | * Type guard for determining whether the given value is an error object with a 126 | * `code` property, such as the kind of error that Node throws for filesystem 127 | * operations. 128 | * 129 | * @param error - The object to check. 130 | * @returns True or false, depending on the result. 131 | */ 132 | export function isErrorWithCode(error: unknown): error is { code: string } { 133 | return typeof error === 'object' && error !== null && 'code' in error; 134 | } 135 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "inlineSources": true, 6 | "module": "ES6", 7 | "noEmit": false, 8 | "outDir": "lib", 9 | "rootDir": "src", 10 | "sourceMap": true 11 | }, 12 | "include": ["src/**/*.ts"], 13 | "exclude": ["src/**/*.test.ts"] 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "lib": ["ES2020"], 5 | "module": "CommonJS", 6 | "moduleResolution": "node", 7 | "noEmit": true, 8 | "strict": true, 9 | "target": "ES2019" 10 | }, 11 | "exclude": ["./dist/**/*", "./lib/**/*"] 12 | } 13 | --------------------------------------------------------------------------------