From b1cc6a409816174101bb739c544b61bedcc964da Mon Sep 17 00:00:00 2001 From: drlkf Date: Mon, 17 Nov 2025 22:31:37 +0100 Subject: [PATCH] feat: allow disabling push to remote --- README.md | 45 ++++++------- index.js | 6 +- lib/prepare.js | 9 ++- lib/verify.js | 1 + test/integration.test.js | 142 ++++++++++++++------------------------- test/prepare.test.js | 29 ++++++-- 6 files changed, 107 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index 077e2567..b17b9e26 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,11 @@ > Please consider our [recommendation against making commits during your release](https://semantic-release.gitbook.io/semantic-release/support/faq#making-commits-during-the-release-process-adds-significant-complexity) to avoid unnecessary headaches. [![Build Status](https://github.com/semantic-release/git/workflows/Test/badge.svg)](https://github.com/semantic-release/git/actions?query=workflow%3ATest+branch%3Amaster) [![npm latest version](https://img.shields.io/npm/v/@semantic-release/git/latest.svg)](https://www.npmjs.com/package/@semantic-release/git) +[![npm next version](https://img.shields.io/npm/v/@semantic-release/git/next.svg)](https://www.npmjs.com/package/@semantic-release/git) [![npm beta version](https://img.shields.io/npm/v/@semantic-release/git/beta.svg)](https://www.npmjs.com/package/@semantic-release/git) | Step | Description | -| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | +|--------------------|------------------------------------------------------------------------------------------------------------------------------------| | `verifyConditions` | Verify the access to the remote Git repository, the commit [`message`](#message) and the [`assets`](#assets) option configuration. | | `prepare` | Create a release commit, including configurable file assets. | @@ -33,7 +34,8 @@ The plugin can be configured in the [**semantic-release** configuration file](ht "@semantic-release/git", { "assets": ["dist/**/*.{js,css}", "docs", "package.json"], - "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}", + "push_remote": true } ] ] @@ -41,7 +43,6 @@ The plugin can be configured in the [**semantic-release** configuration file](ht ``` With this example, for each release a release commit will be pushed to the remote Git repository with: - - a message formatted like `chore(release): [skip ci]\n\n` - the `.js` and `.css` files in the `dist` directory, the files in the `docs` directory and the `package.json` @@ -64,7 +65,7 @@ When configuring branches permission on a Git hosting service (e.g. [GitHub prot ### Environment variables | Variable | Description | Default | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------ | +|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------| | `GIT_AUTHOR_NAME` | The author name associated with the release commit. See [Git environment variables](https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables#_committing). | @semantic-release-bot. | | `GIT_AUTHOR_EMAIL` | The author email associated with the release commit. See [Git environment variables](https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables#_committing). | @semantic-release-bot email address. | | `GIT_COMMITTER_NAME` | The committer name associated with the release commit. See [Git environment variables](https://git-scm.com/book/en/v2/Git-Internals-Environment-Variables#_committing). | @semantic-release-bot. | @@ -72,25 +73,26 @@ When configuring branches permission on a Git hosting service (e.g. [GitHub prot ### Options -| Options | Description | Default | -| --------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -| `message` | The message for the release commit. See [message](#message). | `chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}` | -| `assets` | Files to include in the release commit. Set to `false` to disable adding files to the release commit. See [assets](#assets). | `['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json']` | +| Options | Description | Default | +|---------------|------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------| +| `message` | The message for the release commit. See [message](#message). | `chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}` | +| `assets` | Files to include in the release commit. Set to `false` to disable adding files to the release commit. See [assets](#assets). | `['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json']` | +| `push_remote` | Whether to push the release to the remote repository. Set to `false` to generate the changes locally only. | `true` | #### `message` The message for the release commit is generated with [Lodash template](https://lodash.com/docs#template). The following variables are available: -| Parameter | Description | -| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| `branch` | The branch from which the release is done. | -| `branch.name` | The branch name. | +| Parameter | Description | +|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------| +| `branch` | The branch from which the release is done. | +| `branch.name` | The branch name. | | `branch.type` | The [type of branch](https://github.com/semantic-release/semantic-release/blob/master/docs/usage/workflow-configuration.md#branch-types). | -| `branch.channel` | The distribution channel on which to publish releases from this branch. | -| `branch.range` | The range of [semantic versions](https://semver.org) to support on this branch. | -| `branch.prerelease` | The pre-release detonation to append to [semantic versions](https://semver.org) released from this branch. | -| `lastRelease` | `Object` with `version`, `gitTag` and `gitHead` of the last release. | -| `nextRelease` | `Object` with `version`, `gitTag`, `gitHead` and `notes` of the release being done. | +| `branch.channel` | The distribution channel on which to publish releases from this branch. | +| `branch.range` | The range of [semantic versions](https://semver.org) to support on this branch. | +| `branch.prerelease` | The pre-release detonation to append to [semantic versions](https://semver.org) released from this branch. | +| `lastRelease` | `Object` with `version`, `gitTag` and `gitHead` of the last release. | +| `nextRelease` | `Object` with `version`, `gitTag`, `gitHead` and `notes` of the release being done. | **Note**: It is recommended to include `[skip ci]` in the commit message to not trigger a new build. Some CI service support the `[skip ci]` keyword only in the subject of the message. @@ -98,12 +100,11 @@ The message for the release commit is generated with [Lodash template](https://l The `message` `Release <%= nextRelease.version %> - <%= new Date().toLocaleDateString('en-US', {year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: 'numeric' }) %> [skip ci]\n\n<%= nextRelease.notes %>` will generate the commit message: -> Release v1.0.0 - Oct. 21, 2015 1:24 AM \[skip ci\]

## 1.0.0

### Features
\* Generate 1.21 gigawatts of electricity
... +> Release v1.0.0 - Oct. 21, 2015 1:24 AM \[skip ci\]

## 1.0.0

### Features
* Generate 1.21 gigawatts of electricity
... #### `assets` Can be an `Array` or a single entry. Each entry can be either: - - a [glob](https://github.com/micromatch/micromatch#matching-features) - or an `Object` with a `path` property containing a [glob](https://github.com/micromatch/micromatch#matching-features). @@ -128,7 +129,6 @@ If a directory is configured, all the files under this directory and its childre ### Examples When used with the [@semantic-release/changelog](https://github.com/semantic-release/changelog) or [@semantic-release/npm](https://github.com/semantic-release/npm) plugins: - - The [@semantic-release/changelog](https://github.com/semantic-release/changelog) plugin must be called first in order to update the changelog file so the `@semantic-release/git` and [@semantic-release/npm](https://github.com/semantic-release/npm) plugins can include it in the release. - The [@semantic-release/npm](https://github.com/semantic-release/npm) plugin must be called second in order to update the `package.json` file so the `@semantic-release/git` plugin can include it in the release commit. @@ -140,7 +140,7 @@ When used with the [@semantic-release/changelog](https://github.com/semantic-rel "@semantic-release/changelog", "@semantic-release/npm", "@semantic-release/git" - ] + ], } ``` @@ -174,7 +174,6 @@ sec rsa4096/XXXXXXXXXXXXXXXX 2017-12-01 [SC] uid ssb rsa4096/YYYYYYYYYYYYYYYY 2017-12-01 [E] ``` - the GPG key ID is the 16 character string, on the `sec` line, after `rsa4096`. In this example, the GPG key ID is `XXXXXXXXXXXXXXXX`. Export the public key (replace XXXXXXXXXXXXXXXX with your key ID): @@ -219,7 +218,6 @@ $ travis login ``` Add the following [environment](https://github.com/travis-ci/travis.rb#env) variables to Travis: - - `GPG_PASSPHRASE` to Travis with the value set during the [GPG keys generation](#generate-the-gpg-keys) step - `GPG_KEY_ID` to Travis with the value of your GPG key ID retrieved during the [GPG keys generation](#generate-the-gpg-keys) (replace XXXXXXXXXXXXXXXX with your key ID) - `GIT_EMAIL` with the email address you set during the [GPG keys generation](#generate-the-gpg-keys) step @@ -244,7 +242,6 @@ $ gpg --export-secret-key -a XXXXXXXXXXXXXXXX >> git_gpg_keys.asc ```bash $ travis encrypt-file git_gpg_keys.asc ``` - The `travis encrypt-file` will encrypt the keys into the `git_gpg_keys.asc.enc` file and output in the console the command to add to your `.travis.yml` file. It should look like `openssl aes-256-cbc -K $encrypted_AAAAAAAAAAAA_key -iv $encrypted_BBBBBBBBBBBB_iv -in git_gpg_keys.asc.enc -out git_gpg_keys.asc -d`. Copy this command to your `.travis.yml` file in the `before_install` step. Change the output path to write the unencrypted key in `/tmp`: `-out git_gpg_keys.asc` => `/tmp/git_gpg_keys.asc`. This will avoid to commit / modify / delete the unencrypted keys by mistake on the CI. Then add the commands to decrypt the GPG keys and make it available to `git`: diff --git a/index.js b/index.js index 14be7bcd..0b6bb41d 100644 --- a/index.js +++ b/index.js @@ -18,13 +18,17 @@ export function verifyConditions(pluginConfig, context) { pluginConfig.message, preparePlugin.message, ); + pluginConfig.push_remote = defaultTo( + pluginConfig.push, + preparePlugin.push_remote, + ); } verifyGit(pluginConfig); verified = true; } -export async function prepare(pluginConfig, context) { +async function prepare(pluginConfig, context) { if (!verified) { verifyGit(pluginConfig); verified = true; diff --git a/lib/prepare.js b/lib/prepare.js index b0bb6595..a84bedb2 100644 --- a/lib/prepare.js +++ b/lib/prepare.js @@ -14,6 +14,7 @@ const debug = debugFactory("semantic-release:git"); * @param {Object} pluginConfig The plugin configuration. * @param {String|Array} [pluginConfig.assets] Files to include in the release commit. Can be files path or globs. * @param {String} [pluginConfig.message] The message for the release commit. + * @param {Boolean} [pluginConfig.push_remote] Whether to push to the remote repository. * @param {Object} context semantic-release context. * @param {Object} context.options `semantic-release` configuration. * @param {Object} context.lastRelease The last release. @@ -30,7 +31,7 @@ export default async function prepare(pluginConfig, context) { nextRelease, logger, } = context; - const { message, assets } = resolveConfig(pluginConfig, logger); + const { message, assets, push_remote } = resolveConfig(pluginConfig, logger); const modifiedFiles = await getModifiedFiles({ env, cwd }); @@ -75,7 +76,11 @@ export default async function prepare(pluginConfig, context) { : `chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}`, { env, cwd }, ); - await push(repositoryUrl, branch.name, { env, cwd }); logger.log("Prepared Git release: %s", nextRelease.gitTag); + + if (push_remote) { + await push(repositoryUrl, branch.name, { env, cwd }); + logger.log("Pushed Git release %s to remote", nextRelease.gitTag); + } } } diff --git a/lib/verify.js b/lib/verify.js index 865bb927..b10b184d 100644 --- a/lib/verify.js +++ b/lib/verify.js @@ -25,6 +25,7 @@ const VALIDATORS = { ), ), message: isNonEmptyString, + push_remote: isBoolean, }; /** diff --git a/test/integration.test.js b/test/integration.test.js index 6434c760..d0fd1070 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -55,137 +55,93 @@ test("Prepare from a shallow clone", async (t) => { }; const pluginConfig = { message: `Release version \${nextRelease.version} from branch \${branch}\n\n\${nextRelease.notes}`, - assets: "**/*.{js,json}", + assets: '**/*.{js,json}', + push_remote: true, }; await t.context.m.prepare(pluginConfig, { cwd, branch, - options: { repositoryUrl }, + options: {repositoryUrl}, nextRelease, logger: t.context.logger, }); - t.deepEqual( - (await gitCommitedFiles("HEAD", { cwd })).sort(), - ["dist/file.js", "package.json"].sort(), - ); - const [commit] = await gitGetCommits(undefined, { cwd }); - t.is( - commit.subject, - `Release version ${nextRelease.version} from branch ${branch.name}`, - ); + t.deepEqual((await gitCommitedFiles('HEAD', {cwd})).sort(), ['dist/file.js', 'package.json'].sort()); + const [commit] = await gitGetCommits(undefined, {cwd}); + t.is(commit.subject, `Release version ${nextRelease.version} from branch ${branch.name}`); t.is(commit.body, `${nextRelease.notes}\n`); t.is(commit.gitTags, `(HEAD -> ${branch.name})`); }); -test("Prepare from a detached head repository", async (t) => { - const branch = { name: "master" }; - let { cwd, repositoryUrl } = await gitRepo(true); - await outputFile( - path.resolve(cwd, "package.json"), - "{name: 'test-package', version: '1.0.0'}", - ); - await outputFile(path.resolve(cwd, "dist/file.js"), "Initial content"); - await outputFile(path.resolve(cwd, "dist/file.css"), "Initial content"); - await add(".", { cwd }); - const [{ hash }] = await gitCommits(["First"], { cwd }); - await gitTagVersion("v1.0.0", undefined, { cwd }); - await push(repositoryUrl, branch.name, { cwd }); +test('Prepare from a detached head repository', async (t) => { + const branch = {name: 'master'}; + let {cwd, repositoryUrl} = await gitRepo(true); + await outputFile(path.resolve(cwd, 'package.json'), "{name: 'test-package', version: '1.0.0'}"); + await outputFile(path.resolve(cwd, 'dist/file.js'), 'Initial content'); + await outputFile(path.resolve(cwd, 'dist/file.css'), 'Initial content'); + await add('.', {cwd}); + const [{hash}] = await gitCommits(['First'], {cwd}); + await gitTagVersion('v1.0.0', undefined, {cwd}); + await push(repositoryUrl, branch.name, {cwd}); cwd = await gitDetachedHead(repositoryUrl, hash); - await outputFile( - path.resolve(cwd, "package.json"), - "{name: 'test-package', version: '2.0.0'}", - ); - await outputFile(path.resolve(cwd, "dist/file.js"), "Updated content"); - await outputFile(path.resolve(cwd, "dist/file.css"), "Updated content"); + await outputFile(path.resolve(cwd, 'package.json'), "{name: 'test-package', version: '2.0.0'}"); + await outputFile(path.resolve(cwd, 'dist/file.js'), 'Updated content'); + await outputFile(path.resolve(cwd, 'dist/file.css'), 'Updated content'); - const nextRelease = { - version: "2.0.0", - gitTag: "v2.0.0", - notes: "Version 2.0.0 changelog", - }; + const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0', notes: 'Version 2.0.0 changelog'}; const pluginConfig = { message: `Release version \${nextRelease.version} from branch \${branch}\n\n\${nextRelease.notes}`, - assets: "**/*.{js,json}", + assets: '**/*.{js,json}', + push_remote: true, }; await t.context.m.prepare(pluginConfig, { cwd, branch, - options: { repositoryUrl }, + options: {repositoryUrl}, nextRelease, logger: t.context.logger, }); - t.deepEqual( - (await gitCommitedFiles("HEAD", { cwd })).sort(), - ["dist/file.js", "package.json"].sort(), - ); - const [commit] = await gitGetCommits(undefined, { cwd }); - t.is( - commit.subject, - `Release version ${nextRelease.version} from branch ${branch.name}`, - ); + t.deepEqual((await gitCommitedFiles('HEAD', {cwd})).sort(), ['dist/file.js', 'package.json'].sort()); + const [commit] = await gitGetCommits(undefined, {cwd}); + t.is(commit.subject, `Release version ${nextRelease.version} from branch ${branch.name}`); t.is(commit.body, `${nextRelease.notes}\n`); t.is(commit.gitTags, `(HEAD)`); }); -test("Verify authentication only on the fist call", async (t) => { - const branch = { name: "master" }; - const { cwd, repositoryUrl } = await gitRepo(true); - const nextRelease = { version: "2.0.0", gitTag: "v2.0.0" }; - const options = { repositoryUrl, prepare: ["@semantic-release/npm"] }; - - t.notThrows(() => - t.context.m.verifyConditions( - {}, - { cwd, options, logger: t.context.logger }, - ), - ); - await t.context.m.prepare( - {}, - { - cwd, - options: { repositoryUrl }, - branch, - nextRelease, - logger: t.context.logger, - }, - ); +test('Verify authentication only on the fist call', async (t) => { + const branch = {name: 'master'}; + const {cwd, repositoryUrl} = await gitRepo(true); + const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0'}; + const options = {repositoryUrl, prepare: ['@semantic-release/npm']}; + + t.notThrows(() => t.context.m.verifyConditions({}, {cwd, options, logger: t.context.logger})); + await t.context.m.prepare({}, {cwd, options: {repositoryUrl}, branch, nextRelease, logger: t.context.logger}); }); -test("Throw SemanticReleaseError if prepare config is invalid", (t) => { +test('Throw SemanticReleaseError if prepare config is invalid', (t) => { const message = 42; const assets = true; - const options = { - prepare: [ - "@semantic-release/npm", - { path: "@semantic-release/git", message, assets }, - ], - }; + const options = {prepare: ['@semantic-release/npm', {path: '@semantic-release/git', message, assets}]}; - const { errors } = t.throws(() => - t.context.m.verifyConditions({}, { options, logger: t.context.logger }), - ); + const errors = [...t.throws(() => t.context.m.verifyConditions({}, {options, logger: t.context.logger}))]; - t.is(errors[0].name, "SemanticReleaseError"); - t.is(errors[0].code, "EINVALIDASSETS"); - t.is(errors[1].name, "SemanticReleaseError"); - t.is(errors[1].code, "EINVALIDMESSAGE"); + t.is(errors[0].name, 'SemanticReleaseError'); + t.is(errors[0].code, 'EINVALIDASSETS'); + t.is(errors[1].name, 'SemanticReleaseError'); + t.is(errors[1].code, 'EINVALIDMESSAGE'); }); -test("Throw SemanticReleaseError if config is invalid", (t) => { +test('Throw SemanticReleaseError if config is invalid', (t) => { const message = 42; const assets = true; - const { errors } = t.throws(() => - t.context.m.verifyConditions( - { message, assets }, - { options: {}, logger: t.context.logger }, - ), - ); + const errors = [ + ...t.throws(() => t.context.m.verifyConditions({message, assets}, {options: {}, logger: t.context.logger})), + ]; - t.is(errors[0].name, "SemanticReleaseError"); - t.is(errors[0].code, "EINVALIDASSETS"); - t.is(errors[1].name, "SemanticReleaseError"); - t.is(errors[1].code, "EINVALIDMESSAGE"); + t.is(errors[0].name, 'SemanticReleaseError'); + t.is(errors[0].code, 'EINVALIDASSETS'); + t.is(errors[1].name, 'SemanticReleaseError'); + t.is(errors[1].code, 'EINVALIDMESSAGE'); }); diff --git a/test/prepare.test.js b/test/prepare.test.js index 8ceddf20..3a9c3a24 100644 --- a/test/prepare.test.js +++ b/test/prepare.test.js @@ -70,11 +70,10 @@ test("Commit CHANGELOG.md, package.json, package-lock.json, and npm-shrinkwrap.j t.is(commit.subject, `chore(release): ${nextRelease.version} [skip ci]`); t.is(commit.body, `${nextRelease.notes}\n`); t.is(commit.gitTags, `(HEAD -> ${branch.name})`); - t.deepEqual(t.context.log.args[0], ["Found %d file(s) to commit", 4]); - t.deepEqual(t.context.log.args[1], [ - "Prepared Git release: %s", - nextRelease.gitTag, - ]); + t.deepEqual(t.context.log.args[0], ['Found %d file(s) to commit', 4]); + t.deepEqual(t.context.log.args[1], ['Prepared Git release: %s', nextRelease.gitTag]); + // Verify push has occurred + t.deepEqual(t.context.log.args[2], ['Pushed Git release %s to remote', nextRelease.gitTag]); }); test('Exclude CHANGELOG.md, package.json, package-lock.json, and npm-shrinkwrap.json if "assets" is defined without it', async (t) => { @@ -436,3 +435,23 @@ test("Skip commit if there is no files to commit", async (t) => { // Verify the files that have been commited t.deepEqual(await gitCommitedFiles("HEAD", { cwd, env }), []); }); + +test('Skip push if `push_remote` is configured to `false`', async (t) => { + const {cwd, repositoryUrl} = await gitRepo(true); + const pluginConfig = {assets: ['!**/*', 'file.js'], push_remote: false}; + const branch = {name: 'master'}; + const options = {repositoryUrl}; + const env = {}; + const lastRelease = {}; + const nextRelease = {version: '2.0.0', gitTag: 'v2.0.0'}; + await outputFile(path.resolve(cwd, 'file.js'), 'Test content'); + + await prepare(pluginConfig, {cwd, env, options, branch, lastRelease, nextRelease, logger: t.context.logger}); + + t.deepEqual(await gitCommitedFiles('HEAD', {cwd, env}), ['file.js']); + t.deepEqual(t.context.log.args[0], ['Found %d file(s) to commit', 1]); + + // Verify push has not occurred + const [commit] = await gitGetCommits(undefined, {cwd}); + t.notDeepEqual(await gitRemoteHead(repositoryUrl, {cwd}), commit.commit.long); +});