├── .github ├── dependabot.yml └── workflows │ ├── pr-builder.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── commitlint.config.cjs ├── husky.config.cjs ├── index.js ├── index.test.js ├── lint-staged.config.cjs ├── package.json ├── prettier.config.cjs ├── release.config.js └── yarn.lock /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | registries: 3 | npm-registry-npm-pkg-github-com: 4 | type: npm-registry 5 | url: https://npm.pkg.github.com 6 | token: '${{secrets.PRIVATE_PACKAGE_TOKEN}}' 7 | 8 | updates: 9 | - package-ecosystem: npm 10 | directory: '/' 11 | schedule: 12 | interval: daily 13 | time: '13:00' 14 | ignore: 15 | - dependency-name: husky 16 | pull-request-branch-name: 17 | separator: '-' 18 | open-pull-requests-limit: 10 19 | commit-message: 20 | prefix: feat 21 | prefix-development: chore 22 | include: scope 23 | registries: 24 | - npm-registry-npm-pkg-github-com 25 | -------------------------------------------------------------------------------- /.github/workflows/pr-builder.yml: -------------------------------------------------------------------------------- 1 | name: Build PR 2 | on: pull_request 3 | jobs: 4 | lint: 5 | name: Lint 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout 9 | uses: actions/checkout@v1 10 | - name: Setup Node.js 11 | uses: actions/setup-node@v4 12 | with: 13 | node-version: latest 14 | - name: Install dependencies 15 | run: yarn --frozen-lockfile --ignore-scripts 16 | - name: Lint 17 | run: yarn prettier --check "./**/*.{js,md}" 18 | - name: Jest 19 | run: NODE_OPTIONS=--experimental-vm-modules yarn jest 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v1 13 | - name: Setup Node.js 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: latest 17 | - name: Install dependencies 18 | run: yarn --frozen-lockfile --ignore-scripts 19 | - name: Lint 20 | run: yarn prettier --check "./**/*.{js,md}" 21 | - name: Jest 22 | run: NODE_OPTIONS=--experimental-vm-modules yarn jest 23 | - name: Release 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | GITHUB_NPM_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | GITHUB_NPM_CONFIG_REGISTRY: https://npm.pkg.github.com/ 28 | PUBLIC_NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 29 | PUBLIC_NPM_CONFIG_REGISTRY: https://registry.npmjs.org/ 30 | run: yarn semantic-release 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Amanda Mitchell 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 | # @amanda-mitchell/semantic-release-npm-multiple 2 | 3 | This is a thin wrapper around the [@semantic-release/npm](https://github.com/semantic-release/npm) plugin for [Semantic Release](https://semantic-release.gitbook.io/semantic-release/) that allows it to be called multiple times, which can be useful if you need to publish to multiple NPM registries simultaneously. 4 | 5 | ## Installation 6 | 7 | ``` 8 | yarn add --dev @amanda-mitchell/semantic-release-npm-multiple 9 | ``` 10 | 11 | ## Usage 12 | 13 | The plugin can be configured in the [**semantic-release** configuration file](https://github.com/semantic-release/semantic-release/blob/master/docs/usage/configuration.md#configuration): 14 | 15 | ```json 16 | { 17 | "plugins": [ 18 | "@semantic-release/commit-analyzer", 19 | "@semantic-release/release-notes-generator", 20 | [ 21 | "@amanda-mitchell/semantic-release-npm-multiple", 22 | { 23 | "registries": { 24 | "registryName1": { 25 | "npmPublish": true 26 | }, 27 | "registryName2": { 28 | "npmPublish": true 29 | } 30 | } 31 | } 32 | ] 33 | ] 34 | } 35 | ``` 36 | 37 | ## Configuration 38 | 39 | Each of the keys in `registries` refers to a specific registry that should be used and may be any value that is meaningful to you. 40 | The object associated with that key is a set of options that should be passed to the `@semantic-release/npm` plugin when calling it. 41 | 42 | `@amanda-mitchell/semantic-release-npm-multiple` also looks at a number of environment variables for its configuration: 43 | 44 | - `NPM_TOKEN` 45 | - `NPM_USERNAME` 46 | - `NPM_PASSWORD` 47 | - `NPM_EMAIL` 48 | - `NPM_CONFIG_REGISTRY` 49 | - `NPM_CONFIG_USERCONFIG` 50 | 51 | For any of these variables, if you define a `{UPPER_CASE_REGISTRY_NAME}_{VARIABLE}` environment variable, it will be used instead. 52 | 53 | For example, if you wanted to publish a package to both a GitHub private registry and the public NPM registry, you would first create a configuration file like this: 54 | 55 | ```json 56 | { 57 | "plugins": [ 58 | "@semantic-release/commit-analyzer", 59 | "@semantic-release/release-notes-generator", 60 | [ 61 | "@amanda-mitchell/semantic-release-npm-multiple", 62 | { 63 | "registries": { 64 | "github": {}, 65 | "public": {} 66 | } 67 | } 68 | ] 69 | ] 70 | } 71 | ``` 72 | 73 | And then, when running `semantic-release`, set these environment variables: 74 | 75 | - `GITHUB_NPM_CONFIG_REGISTRY=https://npm.pkg.github.com/` 76 | - `GITHUB_NPM_TOKEN=XXXXX` 77 | - `PUBLIC_NPM_CONFIG_REGISTRY=https://registry.npmjs.org` 78 | - `PUBLIC_NPM_TOKEN=XXXXX` 79 | -------------------------------------------------------------------------------- /commitlint.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'subject-full-stop': [2, 'always', '.'], 5 | 'subject-case': [2, 'always', 'sentence-case'], 6 | 'body-max-line-length': [0], 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /husky.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | hooks: { 3 | 'commit-msg': 'commitlint -E HUSKY_GIT_PARAMS', 4 | 'pre-commit': 5 | 'lint-staged && NODE_OPTIONS=--experimental-vm-modules jest --passWithNoTests -o', 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const underlyingPluginSpecifier = '@semantic-release/npm'; 2 | 3 | // We use the safe navigation operator because when this module 4 | // is executed by jest, import.meta.resolve is unavailable. 5 | const resolvedNpm = import.meta.resolve?.(underlyingPluginSpecifier); 6 | 7 | const registryPlugins = {}; 8 | async function getChildPlugin(registryName) { 9 | let plugin = registryPlugins[registryName]; 10 | if (!plugin) { 11 | // What's going on here? 12 | // 13 | // @semantic-release/npm maintains some module-level state (specifically, 14 | // a temporary file that acts as an npmrc), which means that we can't just 15 | // call the same plugin lifecycle methods repeatedly because they would 16 | // stomp on the shared state. 17 | // 18 | // Fortunately, node has a way to deliberately suppress its normal module 19 | // caching behavior, which allows us to load multiple copies of a single module: 20 | // by appending a query string to the specifier, node will load one copy of the 21 | // module per query string. 22 | // 23 | // But there's a twist: node *doesn't* support this behavior on "bare specifiers", 24 | // which is how we usually import other packages. In order to get around this, 25 | // we must use import.meta.resolve in order to transform the bare specifier into 26 | // a file url specifier. 27 | // 28 | // But then there's a double twist! Jest doesn't support import.meta.resolve, but 29 | // it *does* support query strings on bare specifiers. So in order to be compatible 30 | // with both tests and actual usage, we have this unsightly kludge. 31 | const importSpecifier = `${resolvedNpm || underlyingPluginSpecifier}?registry=${encodeURIComponent(registryName)}`; 32 | 33 | plugin = import(importSpecifier); 34 | registryPlugins[registryName] = plugin; 35 | } 36 | 37 | return await plugin; 38 | } 39 | 40 | function createCallbackWrapper(callbackName) { 41 | return async ({ registries, ...pluginConfig }, context) => { 42 | for (const [registryName, childConfig] of Object.entries( 43 | registries || {}, 44 | )) { 45 | const plugin = await getChildPlugin(registryName); 46 | 47 | const callback = plugin[callbackName]; 48 | if (!callback) { 49 | return; 50 | } 51 | 52 | context.logger.log( 53 | `Performing ${callbackName} for registry ${registryName}`, 54 | ); 55 | 56 | const environmentVariablePrefix = `${registryName.toUpperCase()}_`; 57 | const { env } = context; 58 | const childEnv = { ...env }; 59 | 60 | for (const variableName of [ 61 | 'NPM_TOKEN', 62 | 'NPM_USERNAME', 63 | 'NPM_PASSWORD', 64 | 'NPM_EMAIL', 65 | 'NPM_CONFIG_REGISTRY', 66 | 'NPM_CONFIG_USERCONFIG', 67 | ]) { 68 | const overridenValue = env[environmentVariablePrefix + variableName]; 69 | if (overridenValue) { 70 | childEnv[variableName] = overridenValue; 71 | } 72 | } 73 | 74 | await callback( 75 | { ...childConfig, ...pluginConfig }, 76 | { ...context, env: childEnv }, 77 | ); 78 | } 79 | }; 80 | } 81 | 82 | export default { 83 | addChannel: createCallbackWrapper('addChannel'), 84 | prepare: createCallbackWrapper('prepare'), 85 | publish: createCallbackWrapper('publish'), 86 | verifyConditions: createCallbackWrapper('verifyConditions'), 87 | }; 88 | -------------------------------------------------------------------------------- /index.test.js: -------------------------------------------------------------------------------- 1 | import { mkdtemp, rmdir } from 'fs/promises'; 2 | import plugin from './index.js'; 3 | import * as underlyingPlugin from '@semantic-release/npm'; 4 | 5 | let workingDirectory; 6 | 7 | beforeAll(async () => { 8 | workingDirectory = await mkdtemp('.'); 9 | }); 10 | 11 | afterAll(async () => { 12 | await rmdir(workingDirectory, { recursive: true }); 13 | }); 14 | 15 | const createPluginConfig = () => ({ 16 | registries: { github: {}, public: {} }, 17 | npmPublish: false, 18 | }); 19 | 20 | const createContext = () => ({ 21 | logger: console, 22 | env: {}, 23 | nextRelease: { version: '1.0' }, 24 | cwd: workingDirectory, 25 | }); 26 | 27 | describe('addChannel', () => { 28 | it('does not crash', async () => { 29 | await plugin.addChannel(createPluginConfig, createContext); 30 | }); 31 | }); 32 | 33 | describe('underlying plugin endpoints', () => { 34 | it('has the expected set of keys', () => { 35 | expect(new Set(Object.keys(underlyingPlugin))).toEqual( 36 | new Set(['addChannel', 'prepare', 'publish', 'verifyConditions']), 37 | ); 38 | }); 39 | }); 40 | 41 | describe('prepare', () => { 42 | it('does not crash', async () => { 43 | await plugin.prepare(createPluginConfig, createContext); 44 | }); 45 | }); 46 | 47 | describe('publish', () => { 48 | it('does not crash', async () => { 49 | await plugin.publish(createPluginConfig, createContext); 50 | }); 51 | }); 52 | 53 | describe('verifyConditions', () => { 54 | it('does not crash', async () => { 55 | await plugin.verifyConditions(createPluginConfig, createContext); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /lint-staged.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.js': ['prettier --write'], 3 | '*.md': ['prettier --write'], 4 | '*.json': ['prettier --write'], 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@amanda-mitchell/semantic-release-npm-multiple", 3 | "version": "1.0.0", 4 | "description": "A semantic release plugin to publish an NPM package to multiple registries.", 5 | "main": "index.js", 6 | "repository": "https://github.com/amanda-mitchell/semantic-release-npm-multiple", 7 | "author": "Amanda Mitchell ", 8 | "license": "MIT", 9 | "type": "module", 10 | "files": [ 11 | "index.js" 12 | ], 13 | "publishConfig": { 14 | "access": "public" 15 | }, 16 | "keywords": [ 17 | "npm", 18 | "publish", 19 | "registry", 20 | "semantic-release", 21 | "version" 22 | ], 23 | "devDependencies": { 24 | "@commitlint/config-conventional": "^19.0.0", 25 | "commitlint": "^19.0.0", 26 | "husky": "4.x", 27 | "jest": "^30.0.0", 28 | "lint-staged": "^16.0.0", 29 | "prettier": "^3.0.2", 30 | "semantic-release": "^24.2.0" 31 | }, 32 | "dependencies": { 33 | "@semantic-release/npm": "^12.0.1" 34 | }, 35 | "peerDependencies": { 36 | "semantic-release": "^19.x" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', 3 | singleQuote: true, 4 | }; 5 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | branches: ['main'], 3 | plugins: [ 4 | '@semantic-release/commit-analyzer', 5 | '@semantic-release/release-notes-generator', 6 | [ 7 | (await import('./index.js')).default, 8 | { registries: { github: {}, public: {} } }, 9 | ], 10 | '@semantic-release/github', 11 | ], 12 | }; 13 | --------------------------------------------------------------------------------