feat: get last release with git tags

- Remove the `getLastRelease` plugin type
- Retrieve the last release based on Git tags
- Create the next release Git tag before calling the `publish` plugins

BREAKING CHANGE: Remove the `getLastRelease` plugin type

The `getLastRelease` plugins will not be called anymore.

BREAKING CHANGE: Git repository authentication is now mandatory

The Git authentication is now mandatory and must be set via `GH_TOKEN`, `GITHUB_TOKEN`,  `GL_TOKEN`, `GITLAB_TOKEN` or `GIT_CREDENTIALS` as described in [CI configuration](https://github.com/semantic-release/semantic-release/blob/caribou/docs/usage/ci-configuration.md#authentication).
This commit is contained in:
Pierre Vanduynslager 2018-01-23 01:41:37 -05:00
parent fb0caa005b
commit d0b304e240
29 changed files with 1069 additions and 1179 deletions

View File

@ -80,10 +80,11 @@ After running the tests the command `semantic-release` will execute the followin
| Step | Description |
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Verify Conditions | Verify all the conditions to proceed with the release with the [verify conditions plugins](docs/usage/plugins.md#verifyconditions-plugin). |
| Get last release | Obtain last release with the [get last release plugin](docs/usage/plugins.md#getlastrelease-plugin). |
| Get last release | Obtain the commit corresponding to the last release by analyzing [Git tags](https://git-scm.com/book/en/v2/Git-Basics-Tagging). |
| Analyze commits | Determine the type of release to do with the [analyze commits plugin](docs/usage/plugins.md#analyzecommits-plugin) based on the commits added since the last release. |
| Verify release | Verify the release conformity with the [verify release plugins](docs/usage/plugins.md#verifyrelease-plugin). |
| Generate notes | Generate release notes with the [generate notes plugin](docs/usage/plugins.md#generatenotes-plugin) for the commits added since the last release. |
| Create Git tag | Create a Git tag corresponding the new release version |
| Publish | Publish the release with the [publish plugins](docs/usage/plugins.md#publish-plugin). |
## Documentation

1
cli.js
View File

@ -17,7 +17,6 @@ module.exports = async () => {
'Comma separated list of paths or packages name for the verifyConditions plugin(s)',
list
)
.option('--get-last-release <path>', 'Path or package name for the getLastRelease plugin')
.option('--analyze-commits <path>', 'Path or package name for the analyzeCommits plugin')
.option(
'--verify-release <paths>',

View File

@ -7,7 +7,6 @@
- [publish](https://github.com/semantic-release/github#publish): Publish a [GitHub release](https://help.github.com/articles/about-releases)
- [@semantic-release/npm](https://github.com/semantic-release/npm)
- [verifyConditions](https://github.com/semantic-release/npm#verifyconditions): Verify the presence and the validity of the npm authentication and release configuration
- [getLastRelease](https://github.com/semantic-release/npm#getlastrelease): Determine the last release of the package on the npm registry
- [publish](https://github.com/semantic-release/npm#publish): Publish the package on the npm registry
## Official plugins
@ -17,14 +16,12 @@
- [publish](https://github.com/semantic-release/gitlab#publish): Publish a [GitLab release](https://docs.gitlab.com/ce/workflow/releases.html)
- [@semantic-release/git](https://github.com/semantic-release/git)
- [verifyConditions](https://github.com/semantic-release/git#verifyconditions): Verify the presence and the validity of the Git authentication and release configuration
- [getLastRelease](https://github.com/semantic-release/git#getlastrelease): Determine the last release via Git tags on the repository
- [publish](https://github.com/semantic-release/git#publish): Push a release commit and tag, including configurable files
- [@semantic-release/changelog](https://github.com/semantic-release/changelog)
- [verifyConditions](https://github.com/semantic-release/changelog#verifyconditions): Verify the presence and the validity of the configuration
- [publish](https://github.com/semantic-release/changelog#publish): Create or update the changelog file in the local project repository
- [@semantic-release/exec](https://github.com/semantic-release/exec)
- [verifyConditions](https://github.com/semantic-release/exec#verifyconditions): Execute a shell command to verify if the release should happen
- [getLastRelease](https://github.com/semantic-release/exec#getlastrelease): Execute a shell command to determine the last release
- [analyzeCommits](https://github.com/semantic-release/exec#analyzecommits): Execute a shell command to determine the type of release
- [verifyRelease](https://github.com/semantic-release/exec#verifyrelease): Execute a shell command to verifying a release that was determined before and is about to be published.
- [generateNotes](https://github.com/semantic-release/exec#analyzecommits): Execute a shell command to generate the release note

View File

@ -6,4 +6,7 @@
- [Travis CI with build stages](travis-build-stages.md)
- [GitLab CI](gitlab-ci.md)
## Git hosted services
- [Git authentication with SSH keys](git-auth-ssh-keys.md)
## Package managers and languages

View File

@ -0,0 +1,161 @@
# Git authentication with SSH keys
When using [environment variables](../usage/ci-configuration.md#authentication) to set up the Git authentication, the remote Git repository will automatically be accessed via [https](https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols#_the_http_protocols), independently of the [`repositoryUrl`](../usage/configuration.md#repositoryurl) format configured in the **semantic-release** [Configuration](../usage/configuration.md#configuration) (the format will be automatically converted as needed).
Alternatively the Git repository can be accessed via [SSH](https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols#_the_ssh_protocol) by creating SSH keys, adding the public one to your Git hosted account and making the private one available on the CI environment.
## Generating the SSH keys
In your local repository root:
```bash
$ ssh-keygen -t rsa -b 4096 -C "<your_email>" -f git_deploy_key -N "<ssh_passphrase>"
```
`your_email` must be the email associated with your Git hosted account. `ssh_passphrase` must be a long and hard to guess string. It will be used later.
This will generate a public key in `git_deploy_key.pub` and a private key in `git_deploy_key`.
## Adding the SSH public key to the Git hosted account
Step by step instructions are provided for the following Git hosted services:
- [GitHub](#adding-the-ssh-public-key-to-github)
### Adding the SSH public key to GitHub
Open the `git_deploy_key.pub` file (public key) and copy the entire content.
In GitHub **Settings**, click on **SSH and GPG keys** in the sidebar, then on the **New SSH Key** button.
Paste the entire content of `git_deploy_key.pub` file (public key) and click the **Add SSH Key** button.
Delete the `git_deploy_key.pub` file:
```bash
$ rm git_deploy_key.pub
```
See [Adding a new SSH key to your GitHub account](https://help.github.com/articles/adding-a-new-ssh-key-to-your-github-account/) for more details.
## Adding the SSH private key to the CI environment
In order to be available on the CI environment, the SSH private key must be encrypted, committed to the Git repository and decrypted by the CI service.
Step by step instructions are provided for the following environments:
- [Travis CI](#adding-the-ssh-private-key-to-travis-ci)
- [Circle CI](#adding-the-ssh-private-key-to-circle-ci)
### Adding the SSH private key to Travis CI
Install the [Travis CLI](https://github.com/travis-ci/travis.rb#installation):
```bash
$ gem install travis
```
[Login](https://github.com/travis-ci/travis.rb#login) to Travis with the CLI:
```bash
$ travis login
```
Add the [environment](https://github.com/travis-ci/travis.rb#env) variable `SSH_PASSPHRASE` to Travis with the value set during the [SSH keys generation](#generating-the-ssh-keys) step:
```bash
$ travis env set SSH_PASSPHRASE <ssh_passphrase>
```
[Encrypt](https://github.com/travis-ci/travis.rb#encrypt) the `git_deploy_key` (private key) using a symmetric encryption (AES-256), and store the secret in a secure environment variable in the Travis environment:
```bash
$ travis encrypt-file git_deploy_key
```
The `travis encrypt-file` will encrypt the private key into the `git_deploy_key.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_KKKKKKKKKKKK_key -iv $encrypted_VVVVVVVVVVVV_iv -in git_deploy_key.enc -out git_deploy_key -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_deploy_key` => `/tmp/git_deploy_key`. This will avoid to commit / modify / delete the unencrypted key by mistake on the CI. Then add the commands to decrypt the ssh private key and make it available to `git`:
```yaml
before_install:
# Decrypt the git_deploy_key.enc key into /tmp/git_deploy_key
- openssl aes-256-cbc -K $encrypted_KKKKKKKKKKKK_key -iv $encrypted_VVVVVVVVVVVV_iv -in git_deploy_key.enc -out /tmp/git_deploy_key -d
# Make sure only the current user can read the private key
- chmod 600 /tmp/git_deploy_key
# Create a script to return the passphrase environment variable to ssh-add
- echo 'echo ${SSH_PASSPHRASE}' > /tmp/askpass && chmod +x /tmp/askpass
# Start the authentication agent
- eval "$(ssh-agent -s)"
# Add the key to the authentication agent
- DISPLAY=":0.0" SSH_ASKPASS="/tmp/askpass" setsid ssh-add /tmp/git_deploy_key </dev/null
```
See [Encrypting Files](https://docs.travis-ci.com/user/encrypting-files) for more details.
Delete the local private key as it won't be used anymore:
```bash
$ rm git_deploy_key
```
Commit the encrypted private key and the `.travis.yml` file to your repository:
```bash
$ git add git_deploy_key.enc .travis.yml
$ git commit -m "ci(travis): Add the encrypted private ssh key"
$ git push
```
### Adding the SSH private key to Circle CI
First we encrypt the `git_deploy_key` (private key) using a symmetric encryption (AES-256). Run the following `openssl` command and *make sure to note the output which we'll need later*:
```bash
$ openssl aes-256-cbc -e -p -in git_deploy_key -out git_deploy_key.enc -K `openssl rand -hex 32` -iv `openssl rand -hex 16`
salt=SSSSSSSSSSSSSSSS
key=KKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKKK
iv =VVVVVVVVVVVVVVVVVVVVVVVVVVVVVVVV
```
Add the following [environment variables](https://circleci.com/docs/2.0/env-vars/#adding-environment-variables-in-the-app) to Circle CI:
- `SSL_PASSPHRASE` - the value set during the [SSH keys generation](#generating-the-ssh-keys) step.
- `REPO_ENC_KEY` - the `key` (KKK) value from the `openssl` step above.
- `REPO_ENC_IV` - the `iv` (VVV) value from the `openssl` step above.
Then add to your `.circleci/config.yml` the commands to decrypt the ssh private key and make it available to `git`:
```yaml
version: 2
jobs:
coverage_test_publish:
# docker, working_dir, etc
steps:
- run:
# Decrypt the git_deploy_key.enc key into /tmp/git_deploy_key
- openssl aes-256-cbc -d -K $REPO_ENC_KEY -iv $REPO_ENC_IV -in git_deploy_key.enc -out /tmp/git_deploy_key
# Make sure only the current user can read the private key
- chmod 600 /tmp/git_deploy_key
# Create a script to return the passphrase environment variable to ssh-add
- echo 'echo ${SSH_PASSPHRASE}' > /tmp/askpass && chmod +x /tmp/askpass
# Start the authentication agent
- eval "$(ssh-agent -s)"
# Add the key to the authentication agent
- DISPLAY=":0.0" SSH_ASKPASS="/tmp/askpass" setsid ssh-add /tmp/git_deploy_key </dev/null
# checkout, restore_cache, run: yarn install, save_cache, etc.
# Run semantic-release after all the above is set.
```
The unencrypted key is written to `/tmp` to avoid to commit / modify / delete the unencrypted key by mistake on the CI environment.
Delete the local private key as it won't be used anymore:
```bash
$ rm git_deploy_key
```
Commit the encrypted private key and the `.circleci/config.yml` file to your repository:
```bash
$ git add git_deploy_key.enc .circleci/config.yml
$ git commit -m "ci(cicle): Add the encrypted private ssh key"
$ git push
```

View File

@ -8,17 +8,29 @@ See [CI configuration recipes](../recipes/README.md#ci-configurations) for more
## Authentication
Most **semantic-release** [plugins](plugins.md) require to set up authentication in order to publish to your package manager's registry or to access your project's Git hosted service. The authentication token/credentials have to be made available in the CI serice via environment variables.
**semantic-release** requires push access to the project Git repository in order to create [Git tags](https://git-scm.com/book/en/v2/Git-Basics-Tagging). The Git authentication can be set with one of the following environment variables:
See each plugin documentation for the environment variable to set up.
| Variable | Description |
|------------------------------|-------------------------------------------------------------------------------------------------------------------------------|
| `GH_TOKEN` or `GITHUB_TOKEN` | A GitHub [personal access token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line). |
| `GL_TOKEN` or `GITLAB_TOKEN` | A GitLab [personal access token](https://docs.gitlab.com/ce/user/profile/personal_access_tokens.html). |
| `GIT_CREDENTIALS` | [URL encoded basic HTTP Authentication](https://en.wikipedia.org/wiki/Basic_access_authentication#URL_encoding) credentials). |
The default [npm](https://github.com/semantic-release/npm#environment-variables) and [github](https://github.com/semantic-release/github#environment-variables) plugins require the following environment variables:
`GIT_CREDENTIALS` can be the Git username and password in the format `<username>:<password>` or a token for certain Git providers like [Bitbucket](https://confluence.atlassian.com/bitbucketserver/personal-access-tokens-939515499.html).
Alternatively the Git authentication can be set up via [SSH keys](../recipes/git-auth-ssh-keys.md).
Most **semantic-release** [plugins](plugins.md) require to set up authentication in order to publish to a package manager registry. The default [npm](https://github.com/semantic-release/npm#environment-variables) and [github](https://github.com/semantic-release/github#environment-variables) plugins require the following environment variables:
| Variable | Description |
|-------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `NPM_TOKEN` | npm token created via [npm token create](https://docs.npmjs.com/getting-started/working_with_tokens#how-to-create-new-tokens).<br/>**Note**: Only the `auth-only` [level of npm two-factor authentication](https://docs.npmjs.com/getting-started/using-two-factor-authentication#levels-of-authentication) is supported. |
| `GH_TOKEN` | GitHub authentication token.<br/>**Note**: Only the [personal token](https://help.github.com/articles/creating-a-personal-access-token-for-the-command-line) authentication is supported. |
See each plugin documentation for the environment variables to set up.
The authentication token/credentials have to be made available in the CI service via environment variables.
See [CI configuration recipes](../recipes/README.md#ci-configurations) for more details on how to configure environment variables in your CI service.
## Automatic setup with `semantic-release-cli`

View File

@ -107,18 +107,6 @@ Define the list of [verify conditions plugins](plugins.md#verifyconditions-plugi
See [Plugins configuration](plugins.md#configuration) for more details.
### getLastRelease
Type: `String`, `Object`
Default: `['@semantic-release/npm']`
CLI argument: `--get-last-release`
Define the [get last release plugin](plugins.md#getlastrelease-plugin).
See [Plugins configuration](plugins.md#configuration) for more details.
### analyzeCommits
Type: `String`, `Object`

View File

@ -10,12 +10,6 @@ Plugin responsible for verifying all the conditions to proceed with the release:
Default implementation: [npm](https://github.com/semantic-release/npm#verifyconditions) and [github](https://github.com/semantic-release/github#verifyconditions).
### getLastRelease plugin
Plugin responsible for determining the version of the package last release.
Default implementation: [@semantic-release/npm](https://github.com/semantic-release/npm#getlastrelease).
### analyzeCommits plugin
Plugin responsible for determining the type of the next release (`major`, `minor` or `patch`).

View File

@ -4,10 +4,12 @@ const envCi = require('env-ci');
const hookStd = require('hook-std');
const hideSensitive = require('./lib/hide-sensitive');
const getConfig = require('./lib/get-config');
const verify = require('./lib/verify');
const getNextVersion = require('./lib/get-next-version');
const getCommits = require('./lib/get-commits');
const getLastRelease = require('./lib/get-last-release');
const logger = require('./lib/logger');
const {gitHead: getGitHead, isGitRepo} = require('./lib/git');
const {unshallow, gitHead: getGitHead, tag, push, deleteTag} = require('./lib/git');
async function run(opts) {
const {isCi, branch, isPr} = envCi();
@ -24,17 +26,7 @@ async function run(opts) {
return;
}
if (!await isGitRepo()) {
logger.error('Semantic-release must run from a git repository.');
return;
}
if (branch !== options.branch) {
logger.log(
`This test run was triggered on the branch ${branch}, while semantic-release is configured to only publish from ${
options.branch
}, therefore a new version wont be published.`
);
if (!await verify(options, branch, logger)) {
return;
}
@ -43,12 +35,11 @@ async function run(opts) {
logger.log('Call plugin %s', 'verify-conditions');
await plugins.verifyConditions({options, logger}, true);
logger.log('Call plugin %s', 'get-last-release');
const {commits, lastRelease} = await getCommits(
await plugins.getLastRelease({options, logger}),
options.branch,
logger
);
// Unshallow the repo in order to get all the tags
await unshallow();
const lastRelease = await getLastRelease(logger);
const commits = await getCommits(lastRelease.gitHead, options.branch, logger);
logger.log('Call plugin %s', 'analyze-commits');
const type = await plugins.analyzeCommits({
@ -79,11 +70,23 @@ async function run(opts) {
logger.log('Call plugin %s', 'generateNotes');
nextRelease.notes = await plugins.generateNotes(generateNotesParam);
// Create the tag before calling the publish plugins as some require the tag to exists
logger.log('Create tag %s', nextRelease.gitTag);
await tag(nextRelease.gitTag);
await push(options.repositoryUrl, branch);
logger.log('Call plugin %s', 'publish');
await plugins.publish({options, logger, lastRelease, commits, nextRelease}, false, async prevInput => {
const newGitHead = await getGitHead();
// If previous publish plugin has created a commit (gitHead changed)
if (prevInput.nextRelease.gitHead !== newGitHead) {
// Delete the previously created tag
await deleteTag(options.repositoryUrl, nextRelease.gitTag);
// Recreate the tag, referencing the new gitHead
logger.log('Create tag %s', nextRelease.gitTag);
await tag(nextRelease.gitTag);
await push(options.repositoryUrl, branch);
nextRelease.gitHead = newGitHead;
// Regenerate the release notes
logger.log('Call plugin %s', 'generateNotes');

View File

@ -1,9 +0,0 @@
function debugShell(message, shell, debug) {
debug(message);
debug('cmd: %O', shell.cmd);
debug('stdout: %O', shell.stdout);
debug('stderr: %O', shell.stderr);
debug('code: %O', shell.code);
}
module.exports = {debugShell};

View File

@ -1,75 +1,21 @@
const gitLogParser = require('git-log-parser');
const getStream = require('get-stream');
const debug = require('debug')('semantic-release:get-commits');
const SemanticReleaseError = require('@semantic-release/error');
const {unshallow, gitCommitTag, gitTagHead, isCommitInHistory} = require('./git');
/**
* Commit message.
* Retrieve the list of commits on the current branch since the commit sha associated with the last release, or all the commits of the current branch if there is no last released version.
*
* @typedef {Object} Commit
* @property {string} hash The commit hash.
* @property {string} message The commit message.
*/
/**
* Last release.
*
* @typedef {Object} LastRelease
* @property {string} version The version number of the last release.
* @property {string} [gitHead] The commit sha used to make the last release.
* @property {string} [gitTag] The tag used to make the last release.
*/
/**
* Result object.
*
* @typedef {Object} Result
* @property {Array<Commit>} commits The list of commits since the last release.
* @property {LastRelease} lastRelease The updated lastRelease.
*/
/**
* Retrieve the list of commits on the current branch since the last released version, or all the commits of the current branch if there is no last released version.
*
* The commit correspoding to the last released version is determined as follow:
* - Use `lastRelease.gitHead` if defined and present in `branch` history.
* - If `lastRelease.gitHead` is not in the `branch` history, unshallow the repository and try again.
* - If `lastRelease.gitHead` is still not in the `branch` history, search for a tag named `v<version>` or `<version>` and verify if it's associated commit sha is present in `branch` history.
*
* @param {LastRelease} lastRelease The lastRelease object obtained from the getLastRelease plugin.
* @param {string} branch The branch to release from.
* @param {String} gitHead The commit sha associated with the last release.
* @param {String} branch The branch to release from.
* @param {Object} logger Global logger.
*
* @return {Promise<Result>} The list of commits on the branch `branch` since the last release and the updated lastRelease with the gitHead used to retrieve the commits.
*
* @throws {SemanticReleaseError} with code `ENOTINHISTORY` if `lastRelease.gitHead` or the commit sha derived from `config.lastRelease.version` is not in the direct history of `branch`.
* @return {Promise<Array<Object>>} The list of commits on the branch `branch` since the last release.
*/
module.exports = async ({version, gitHead} = {}, branch, logger) => {
module.exports = async (gitHead, branch, logger) => {
if (gitHead) {
// If gitHead doesn't exists in release branch
if (!await isCommitInHistory(gitHead)) {
// Unshallow the repository
await unshallow();
}
// If gitHead still doesn't exists in release branch
if (!await isCommitInHistory(gitHead)) {
// Try to find the commit corresponding to the version, using got tags
const tagHead = (await gitTagHead(`v${version}`)) || (await gitTagHead(version));
// If tagHead doesn't exists in release branch
if (!tagHead || !await isCommitInHistory(tagHead)) {
// Then the commit corresponding to the version cannot be found in the bracnh hsitory
logger.error(notInHistoryMessage(gitHead, branch, version));
throw new SemanticReleaseError('Commit not in history', 'ENOTINHISTORY');
}
gitHead = tagHead;
}
debug('Use gitHead: %s', gitHead);
} else {
logger.log('No previous release found, retrieving all commits');
// If there is no gitHead nor a version, there is no previous release. Unshallow the repo in order to retrieve all commits
await unshallow();
}
Object.assign(gitLogParser.fields, {hash: 'H', message: 'B', gitTags: 'd', committerDate: {key: 'ci', type: Date}});
@ -82,16 +28,5 @@ module.exports = async ({version, gitHead} = {}, branch, logger) => {
);
logger.log('Found %s commits since last release', commits.length);
debug('Parsed commits: %o', commits);
return {commits, lastRelease: {version, gitHead, gitTag: await gitCommitTag(gitHead)}};
return commits;
};
function notInHistoryMessage(gitHead, branch, version) {
return `The commit the last release of this package was derived from is not in the direct history of the "${branch}" branch.
This means semantic-release can not extract the commits between now and then.
This is usually caused by force pushing, releasing from an unrelated branch, or using an already existing package name.
You can recover from this error by restoring the commit "${gitHead}" or by creating a tag for the version "${version}" on the commit corresponding to this release:
$ git tag -f v${version || '<version>'} <commit sha1 corresponding to last release>
$ git push -f --tags origin ${branch}
`;
}

View File

@ -7,6 +7,7 @@ const debug = require('debug')('semantic-release:config');
const {repoUrl} = require('./git');
const PLUGINS_DEFINITION = require('./plugins/definitions');
const plugins = require('./plugins');
const getGitAuthUrl = require('./get-git-auth-url');
module.exports = async (opts, logger) => {
const {config} = (await cosmiconfig('release', {rcExtensions: true}).load(process.cwd())) || {};
@ -64,6 +65,8 @@ module.exports = async (opts, logger) => {
throw new SemanticReleaseError('The repositoryUrl option is required', 'ENOREPOURL');
}
options.repositoryUrl = getGitAuthUrl(options.repositoryUrl);
return {options, plugins: await plugins(options, pluginsPath, logger)};
};

29
lib/get-git-auth-url.js Normal file
View File

@ -0,0 +1,29 @@
const {parse, format} = require('url');
const {isUndefined} = require('lodash');
const gitUrlParse = require('git-url-parse');
const GIT_TOKENS = ['GH_TOKEN', 'GITHUB_TOKEN', 'GL_TOKEN', 'GITLAB_TOKEN', 'GIT_CREDENTIALS'];
/**
* Generate the git repository URL with creadentials.
* If the `gitCredentials` is defined, returns a http or https URL with Basic Authentication (`https://username:passowrd@hostname:port/path.git`).
* If the `gitCredentials` is undefined, returns the `repositoryUrl`. In that case it's expected for the user to have setup the Git authentication on the CI (for example via SSH keys).
*
* @param {String} gitCredentials Basic HTTP Authentication credentials, can be `username:password` or a token for certain Git providers.
* @param {String} repositoryUrl The git repository URL.
* @return {String} The formatted Git repository URL.
*/
module.exports = repositoryUrl => {
const envVar = GIT_TOKENS.find(envVar => !isUndefined(process.env[envVar]));
const gitCredentials = ['GL_TOKEN', 'GITLAB_TOKEN'].includes(envVar)
? `gitlab-ci-token:${process.env[envVar]}`
: process.env[envVar];
if (!gitCredentials) {
return repositoryUrl;
}
const {protocols} = gitUrlParse(repositoryUrl);
const protocol = protocols.includes('https') ? 'https' : protocols.includes('http') ? 'http' : 'https';
return format({...parse(`${gitUrlParse(repositoryUrl).toString(protocol)}.git`), ...{auth: gitCredentials}});
};

37
lib/get-last-release.js Normal file
View File

@ -0,0 +1,37 @@
const semver = require('semver');
const pLocate = require('p-locate');
const debug = require('debug')('semantic-release:get-last-release');
const {gitTags, isRefInHistory, gitTagHead} = require('./git');
/**
* Last release.
*
* @typedef {Object} LastRelease
* @property {string} version The version number of the last release.
* @property {string} [gitHead] The Git reference used to make the last release.
*/
/**
* Determine the Git tag and version of the last tagged release.
*
* - Obtain all the tags referencing commits in the current branch history
* - Filter out the ones that are not valid semantic version
* - Sort the tags
* - Retrive the highest tag
*
* @param {Object} logger Global logger.
* @return {Promise<LastRelease>} The last tagged release or `undefined` if none is found.
*/
module.exports = async logger => {
const tags = (await gitTags()).filter(tag => semver.valid(semver.clean(tag))).sort(semver.rcompare);
debug('found tags: %o', tags);
if (tags.length > 0) {
const gitTag = await pLocate(tags, tag => isRefInHistory(tag), {concurrency: 1, preserveOrder: true});
logger.log('Found git tag version %s', gitTag);
return {gitTag, gitHead: await gitTagHead(gitTag), version: semver.valid(semver.clean(gitTag))};
}
logger.log('No git tag version found');
return {};
};

View File

@ -1,6 +1,5 @@
const execa = require('execa');
const debug = require('debug')('semantic-release:get-version-head');
const {debugShell} = require('./debug');
/**
* Get the commit sha for a given tag.
@ -10,45 +9,29 @@ const {debugShell} = require('./debug');
* @return {string} The commit sha of the tag in parameter or `null`.
*/
async function gitTagHead(tagName) {
try {
const shell = await execa('git', ['rev-list', '-1', tagName]);
debugShell('Get git tag head', shell, debug);
return shell.stdout;
} catch (err) {
debug(err);
return null;
}
return execa.stdout('git', ['rev-list', '-1', tagName], {reject: false});
}
/**
* Get the tag associated with a commit sha.
*
* @param {string} gitHead The commit sha for which to retrieve the associated tag.
*
* @return {string} The tag associatedwith the sha in parameter or `undefined`.
* @return {Array<String>} List of git tags.
* @throws {Error} If the `git` command fails.
*/
async function gitCommitTag(gitHead) {
try {
const shell = await execa('git', ['describe', '--tags', '--exact-match', gitHead]);
debugShell('Get git commit tag', shell, debug);
return shell.stdout;
} catch (err) {
debug(err);
return undefined;
}
async function gitTags() {
return (await execa.stdout('git', ['tag']))
.split('\n')
.map(tag => tag.trim())
.filter(tag => Boolean(tag));
}
/**
* Verify if the commit `sha` is in the direct history of the current branch.
* Verify if the `ref` is in the direct history of the current branch.
*
* @param {string} sha The sha of the commit to look for.
* @param {string} ref The reference to look for.
*
* @return {boolean} `true` if the commit `sha` is in the history of the current branch, `false` otherwise.
* @return {boolean} `true` if the reference is in the history of the current branch, `false` otherwise.
*/
async function isCommitInHistory(sha) {
const shell = await execa('git', ['merge-base', '--is-ancestor', sha, 'HEAD'], {reject: false});
debugShell('Check if commit is in history', shell, debug);
return shell.code === 0;
async function isRefInHistory(ref) {
return (await execa('git', ['merge-base', '--is-ancestor', ref, 'HEAD'], {reject: false})).code === 0;
}
/**
@ -62,30 +45,83 @@ async function unshallow() {
* @return {string} the sha of the HEAD commit.
*/
async function gitHead() {
try {
const shell = await execa('git', ['rev-parse', 'HEAD']);
debugShell('Get git head', shell, debug);
return shell.stdout;
} catch (err) {
debug(err);
throw new Error(err.stderr);
}
return execa.stdout('git', ['rev-parse', 'HEAD']);
}
/**
* @return {string|undefined} The value of the remote git URL.
* @return {string} The value of the remote git URL.
*/
async function repoUrl() {
return (await execa.stdout('git', ['remote', 'get-url', 'origin'], {reject: false})) || undefined;
return execa.stdout('git', ['remote', 'get-url', 'origin'], {reject: false});
}
/**
* @return {Boolean} `true` if the current working directory is in a git repository, `false` otherwise.
*/
async function isGitRepo() {
const shell = await execa('git', ['rev-parse', '--git-dir'], {reject: false});
debugShell('Check if the current working directory is a git repository', shell, debug);
return shell.code === 0;
return (await execa('git', ['rev-parse', '--git-dir'], {reject: false})).code === 0;
}
module.exports = {gitTagHead, gitCommitTag, isCommitInHistory, unshallow, gitHead, repoUrl, isGitRepo};
/**
* Verify the write access authorization to remote repository with push dry-run.
*
* @param {String} origin The remote repository URL.
* @param {String} branch The repositoru branch for which to verify write access.
*
* @return {Boolean} `true` is authorized to push, `false` otherwise.
*/
async function verifyAuth(origin, branch) {
return (await execa('git', ['push', '--dry-run', origin, `HEAD:${branch}`], {reject: false})).code === 0;
}
/**
* Tag the commit head on the local repository.
*
* @param {String} tagName The name of the tag.
* @throws {Error} if the tag creation failed.
*/
async function tag(tagName) {
await execa('git', ['tag', tagName]);
}
/**
* Push to the remote repository.
*
* @param {String} origin The remote repository URL.
* @param {String} branch The branch to push.
* @throws {Error} if the push failed.
*/
async function push(origin, branch) {
await execa('git', ['push', '--tags', origin, `HEAD:${branch}`]);
}
/**
* Delete a tag locally and remotely.
*
* @param {String} origin The remote repository URL.
* @param {String} tagName The tag name to delete.
* @throws {SemanticReleaseError} if the remote tag exists and references a commit that is not the local head commit.
*/
async function deleteTag(origin, tagName) {
// Delete the local tag
let shell = await execa('git', ['tag', '-d', tagName], {reject: false});
debug('delete local tag', shell);
// Delete the tag remotely
shell = await execa('git', ['push', '-d', origin, tagName], {reject: false});
debug('delete remote tag', shell);
}
module.exports = {
gitTagHead,
gitTags,
isRefInHistory,
unshallow,
gitHead,
repoUrl,
isGitRepo,
verifyAuth,
tag,
push,
deleteTag,
};

View File

@ -1,5 +1,4 @@
const {isString, isObject, isFunction, isArray} = require('lodash');
const semver = require('semver');
const {isString, isFunction, isArray} = require('lodash');
const RELEASE_TYPE = ['major', 'premajor', 'minor', 'preminor', 'patch', 'prepatch', 'prerelease'];
const validatePluginConfig = conf => isString(conf) || isString(conf.path) || isFunction(conf);
@ -13,22 +12,6 @@ module.exports = {
'The "verifyConditions" plugin, if defined, must be a single or an array of plugins definition. A plugin definition is either a string or an object with a path property.',
},
},
getLastRelease: {
default: '@semantic-release/npm',
config: {
validator: conf => Boolean(conf) && validatePluginConfig(conf),
message:
'The "getLastRelease" plugin is mandatory, and must be a single plugin definition. A plugin definition is either a string or an object with a path property.',
},
output: {
validator: output =>
!output ||
(isObject(output) && !output.version) ||
(isString(output.version) && Boolean(semver.valid(semver.clean(output.version))) && Boolean(output.gitHead)),
message:
'The "getLastRelease" plugin output if defined, must be an object with a valid semver version in the "version" property and the corresponding git reference in "gitHead" property.',
},
},
analyzeCommits: {
default: '@semantic-release/commit-analyzer',
config: {

27
lib/verify.js Normal file
View File

@ -0,0 +1,27 @@
const SemanticReleaseError = require('@semantic-release/error');
const {isGitRepo, verifyAuth} = require('./git');
module.exports = async (options, branch, logger) => {
if (!await isGitRepo()) {
logger.error('Semantic-release must run from a git repository.');
return false;
}
if (!await verifyAuth(options.repositoryUrl, options.branch)) {
throw new SemanticReleaseError(
`The git credentials doesn't allow to push on the branch ${options.branch}.`,
'EGITNOPERMISSION'
);
}
if (branch !== options.branch) {
logger.log(
`This test run was triggered on the branch ${branch}, while semantic-release is configured to only publish from ${
options.branch
}, therefore a new version wont be published.`
);
return false;
}
return true;
};

View File

@ -21,8 +21,8 @@
"dependencies": {
"@semantic-release/commit-analyzer": "^5.0.0",
"@semantic-release/error": "^2.1.0",
"@semantic-release/github": "^3.0.1",
"@semantic-release/npm": "^2.0.0",
"@semantic-release/github": "^4.0.0",
"@semantic-release/npm": "^3.0.0",
"@semantic-release/release-notes-generator": "^6.0.0",
"aggregate-error": "^1.0.0",
"chalk": "^2.3.0",
@ -33,10 +33,12 @@
"execa": "^0.9.0",
"get-stream": "^3.0.0",
"git-log-parser": "^1.2.0",
"git-url-parse": "^8.0.0",
"hook-std": "^0.4.0",
"lodash": "^4.17.4",
"marked": "^0.3.9",
"marked-terminal": "^2.0.0",
"p-locate": "^2.0.0",
"p-reduce": "^1.0.0",
"p-reflect": "^1.0.0",
"read-pkg-up": "^3.0.0",

View File

@ -1,16 +1,7 @@
import test from 'ava';
import {stub} from 'sinon';
import getCommits from '../lib/get-commits';
import {
gitRepo,
gitCommits,
gitCheckout,
gitTagVersion,
gitShallowClone,
gitTags,
gitLog,
gitDetachedHead,
} from './helpers/git-utils';
import {gitRepo, gitCommits, gitDetachedHead} from './helpers/git-utils';
// Save the current working diretory
const cwd = process.cwd();
@ -34,86 +25,11 @@ test.serial('Get all commits when there is no last release', async t => {
const commits = await gitCommits(['First', 'Second']);
// Retrieve the commits with the commits module
const result = await getCommits({}, 'master', t.context.logger);
const result = await getCommits(undefined, 'master', t.context.logger);
// Verify the commits created and retrieved by the module are identical
t.is(result.commits.length, 2);
t.is(result.commits[0].hash.substring(0, 7), commits[0].hash);
t.is(result.commits[0].message, commits[0].message);
t.truthy(result.commits[0].committerDate);
t.truthy(result.commits[0].author.name);
t.truthy(result.commits[0].committer.name);
t.is(result.commits[1].hash.substring(0, 7), commits[1].hash);
t.is(result.commits[1].message, commits[1].message);
t.truthy(result.commits[1].committerDate);
t.truthy(result.commits[1].author.name);
t.truthy(result.commits[1].committer.name);
// Verify the last release is returned and updated
t.truthy(result.lastRelease);
t.falsy(result.lastRelease.gitHead);
t.falsy(result.lastRelease.version);
t.falsy(result.lastRelease.gitTag);
});
test.serial('Get all commits with gitTags', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
// Add commits to the master branch
let commits = await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
await gitTagVersion('v1.0.0');
// Add new commits to the master branch
commits = (await gitCommits(['Second'])).concat(commits);
// Retrieve the commits with the commits module
const result = await getCommits({}, 'master', t.context.logger);
// Verify the commits created and retrieved by the module are identical
t.is(result.commits.length, 2);
t.is(result.commits[0].hash.substring(0, 7), commits[0].hash);
t.is(result.commits[0].message, commits[0].message);
t.truthy(result.commits[0].committerDate);
t.truthy(result.commits[0].author.name);
t.truthy(result.commits[0].committer.name);
t.is(result.commits[0].gitTags, '(HEAD -> master)');
t.is(result.commits[1].hash.substring(0, 7), commits[1].hash);
t.is(result.commits[1].message, commits[1].message);
t.truthy(result.commits[1].committerDate);
t.truthy(result.commits[1].author.name);
t.truthy(result.commits[1].committer.name);
t.is(result.commits[1].gitTags, '(tag: v1.0.0)');
});
test.serial('Get all commits when there is no last release, including the ones not in the shallow clone', async t => {
// Create a git repository, set the current working directory at the root of the repo
const repo = await gitRepo();
// Add commits to the master branch
const commits = await gitCommits(['First', 'Second']);
// Create a shallow clone with only 1 commit
await gitShallowClone(repo);
// Verify the shallow clone contains only one commit
t.is((await gitLog()).length, 1);
// Retrieve the commits with the commits module
const result = await getCommits({}, 'master', t.context.logger);
// Verify the commits created and retrieved by the module are identical
t.is(result.commits.length, 2);
t.is(result.commits[0].hash.substring(0, 7), commits[0].hash);
t.is(result.commits[0].message, commits[0].message);
t.truthy(result.commits[0].committerDate);
t.truthy(result.commits[0].author.name);
t.truthy(result.commits[0].committer.name);
t.is(result.commits[1].hash.substring(0, 7), commits[1].hash);
t.is(result.commits[1].message, commits[1].message);
t.truthy(result.commits[1].committerDate);
t.truthy(result.commits[1].author.name);
t.truthy(result.commits[1].committer.name);
// Verify the last release is returned and updated
t.truthy(result.lastRelease);
t.falsy(result.lastRelease.gitHead);
t.falsy(result.lastRelease.version);
t.falsy(result.lastRelease.gitTag);
t.is(result.length, 2);
t.deepEqual(result, commits);
});
test.serial('Get all commits since gitHead (from lastRelease)', async t => {
@ -123,25 +39,11 @@ test.serial('Get all commits since gitHead (from lastRelease)', async t => {
const commits = await gitCommits(['First', 'Second', 'Third']);
// Retrieve the commits with the commits module, since commit 'First'
const result = await getCommits({gitHead: commits[commits.length - 1].hash}, 'master', t.context.logger);
const result = await getCommits(commits[commits.length - 1].hash, 'master', t.context.logger);
// Verify the commits created and retrieved by the module are identical
t.is(result.commits.length, 2);
t.is(result.commits[0].hash.substring(0, 7), commits[0].hash);
t.is(result.commits[0].message, commits[0].message);
t.truthy(result.commits[0].committerDate);
t.truthy(result.commits[0].author.name);
t.truthy(result.commits[0].committer.name);
t.is(result.commits[1].hash.substring(0, 7), commits[1].hash);
t.is(result.commits[1].message, commits[1].message);
t.truthy(result.commits[1].committerDate);
t.truthy(result.commits[1].author.name);
t.truthy(result.commits[1].committer.name);
// Verify the last release is returned and updated
t.truthy(result.lastRelease);
t.is(result.lastRelease.gitHead, commits[commits.length - 1].hash);
t.falsy(result.lastRelease.version);
t.falsy(result.lastRelease.gitTag);
t.is(result.length, 2);
t.deepEqual(result, commits.slice(0, 2));
});
test.serial('Get all commits since gitHead (from lastRelease) on a detached head repo', async t => {
@ -153,183 +55,15 @@ test.serial('Get all commits since gitHead (from lastRelease) on a detached head
await gitDetachedHead(repo, commits[1].hash);
// Retrieve the commits with the commits module, since commit 'First'
const result = await getCommits({gitHead: commits[commits.length - 1].hash}, 'master', t.context.logger);
const result = await getCommits(commits[commits.length - 1].hash, 'master', t.context.logger);
// Verify the module retrieved only the commit 'feat: Second' (included in the detached and after 'fix: First')
t.is(result.commits.length, 1);
t.is(result.commits[0].hash.substring(0, 7), commits[1].hash);
t.is(result.commits[0].message, commits[1].message);
t.truthy(result.commits[0].committerDate);
t.truthy(result.commits[0].author.name);
t.truthy(result.commits[0].committer.name);
// Verify the last release is returned and updated
t.truthy(result.lastRelease);
t.is(result.lastRelease.gitHead, commits[commits.length - 1].hash);
t.falsy(result.lastRelease.version);
t.falsy(result.lastRelease.gitTag);
});
test.serial('Get all commits since gitHead (from tag) ', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
// Add commits to the master branch
let commits = await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
await gitTagVersion('1.0.0');
// Add new commits to the master branch
commits = (await gitCommits(['Second', 'Third'])).concat(commits);
// Retrieve the commits with the commits module, since commit 'First' (associated with tag v1.0.0)
const result = await getCommits({version: '1.0.0', gitHead: 'missing_ref'}, 'master', t.context.logger);
// Verify the commits created and retrieved by the module are identical
t.is(result.commits.length, 2);
t.is(result.commits[0].hash.substring(0, 7), commits[0].hash);
t.is(result.commits[0].message, commits[0].message);
t.truthy(result.commits[0].committerDate);
t.truthy(result.commits[0].author.name);
t.truthy(result.commits[0].committer.name);
t.is(result.commits[1].hash.substring(0, 7), commits[1].hash);
t.is(result.commits[1].message, commits[1].message);
t.truthy(result.commits[1].committerDate);
t.truthy(result.commits[1].author.name);
t.truthy(result.commits[1].committer.name);
// Verify the last release is returned and updated
t.truthy(result.lastRelease);
t.is(result.lastRelease.gitHead.substring(0, 7), commits[commits.length - 1].hash);
t.is(result.lastRelease.gitTag, '1.0.0');
t.is(result.lastRelease.version, '1.0.0');
});
test.serial('Get all commits since gitHead (from tag) on a detached head repo', async t => {
// Create a git repository, set the current working directory at the root of the repo
const repo = await gitRepo();
// Add commits to the master branch
let commits = await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
await gitTagVersion('1.0.0');
// Add new commits to the master branch
commits = (await gitCommits(['Second', 'Third'])).concat(commits);
// Create a detached head repo at commit 'feat: Second'
await gitDetachedHead(repo, commits[1].hash);
// Retrieve the commits with the commits module, since commit 'First' (associated with tag 1.0.0)
const result = await getCommits({version: '1.0.0', gitHead: 'missing_ref'}, 'master', t.context.logger);
// Verify the module retrieved only the commit 'feat: Second' (included in the detached and after 'fix: First')
t.is(result.commits.length, 1);
t.is(result.commits[0].hash.substring(0, 7), commits[1].hash);
t.is(result.commits[0].message, commits[1].message);
t.truthy(result.commits[0].committerDate);
t.truthy(result.commits[0].author.name);
t.truthy(result.commits[0].committer.name);
// Verify the last release is returned and updated
t.truthy(result.lastRelease);
t.is(result.lastRelease.gitHead.substring(0, 7), commits[commits.length - 1].hash);
t.is(result.lastRelease.gitTag, '1.0.0');
t.is(result.lastRelease.version, '1.0.0');
});
test.serial('Get all commits since gitHead (from tag formatted like v<version>) ', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
// Add commits to the master branch
let commits = await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
await gitTagVersion('v1.0.0');
// Add new commits to the master branch
commits = (await gitCommits(['Second', 'Third'])).concat(commits);
// Retrieve the commits with the commits module, since commit 'First' (associated with tag v1.0.0)
const result = await getCommits({version: '1.0.0', gitHead: 'missing_ref'}, 'master', t.context.logger);
// Verify the commits created and retrieved by the module are identical
t.is(result.commits.length, 2);
t.is(result.commits[0].hash.substring(0, 7), commits[0].hash);
t.is(result.commits[0].message, commits[0].message);
t.truthy(result.commits[0].committerDate);
t.truthy(result.commits[0].author.name);
t.truthy(result.commits[0].committer.name);
t.is(result.commits[1].hash.substring(0, 7), commits[1].hash);
t.is(result.commits[1].message, commits[1].message);
t.truthy(result.commits[1].committerDate);
t.truthy(result.commits[1].author.name);
t.truthy(result.commits[1].committer.name);
// Verify the last release is returned and updated
t.truthy(result.lastRelease);
t.is(result.lastRelease.gitHead.substring(0, 7), commits[commits.length - 1].hash);
t.is(result.lastRelease.gitTag, 'v1.0.0');
t.is(result.lastRelease.version, '1.0.0');
});
test.serial('Get all commits since gitHead, when gitHead is missing from the shallow clone', async t => {
// Create a git repository, set the current working directory at the root of the repo
const repo = await gitRepo();
// Add commits to the master branch
const commits = await gitCommits(['First', 'Second', 'Third']);
// Create a shallow clone with only 1 commit and no tags
await gitShallowClone(repo);
// Retrieve the commits with the commits module, since commit 'First'
const result = await getCommits(
{version: '1.0.0', gitHead: commits[commits.length - 1].hash},
'master',
t.context.logger
);
// Verify the commits created and retrieved by the module are identical
t.is(result.commits.length, 2);
t.is(result.commits[0].hash.substring(0, 7), commits[0].hash);
t.is(result.commits[0].message, commits[0].message);
t.truthy(result.commits[0].committerDate);
t.truthy(result.commits[0].author.name);
t.truthy(result.commits[0].committer.name);
t.is(result.commits[1].hash.substring(0, 7), commits[1].hash);
t.is(result.commits[1].message, commits[1].message);
t.truthy(result.commits[1].committerDate);
t.truthy(result.commits[1].author.name);
t.truthy(result.commits[1].committer.name);
// Verify the last release is returned and updated
t.truthy(result.lastRelease);
t.is(result.lastRelease.gitHead.substring(0, 7), commits[commits.length - 1].hash);
t.is(result.lastRelease.version, '1.0.0');
t.falsy(result.lastRelease.gitTag);
});
test.serial('Get all commits since gitHead from tag, when tags is missing from the shallow clone', async t => {
// Create a git repository, set the current working directory at the root of the repo
const repo = await gitRepo();
// Add commits to the master branch
let commits = await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
await gitTagVersion('v1.0.0');
// Add new commits to the master branch
commits = (await gitCommits(['Second', 'Third'])).concat(commits);
// Create a shallow clone with only 1 commit and no tags
await gitShallowClone(repo);
// Verify the shallow clone does not contains any tags
t.is((await gitTags()).length, 0);
// Retrieve the commits with the commits module, since commit 'First' (associated with tag v1.0.0)
const result = await getCommits({version: '1.0.0', gitHead: 'missing_ref'}, 'master', t.context.logger);
// Verify the commits created and retrieved by the module are identical
t.is(result.commits.length, 2);
t.is(result.commits[0].hash.substring(0, 7), commits[0].hash);
t.is(result.commits[0].message, commits[0].message);
t.truthy(result.commits[0].committerDate);
t.truthy(result.commits[0].author.name);
t.truthy(result.commits[0].committer.name);
t.is(result.commits[1].hash.substring(0, 7), commits[1].hash);
t.is(result.commits[1].message, commits[1].message);
t.truthy(result.commits[1].committerDate);
t.truthy(result.commits[1].author.name);
t.truthy(result.commits[1].committer.name);
// Verify the last release is returned and updated
t.truthy(result.lastRelease);
t.is(result.lastRelease.gitHead.substring(0, 7), commits[commits.length - 1].hash);
t.is(result.lastRelease.gitTag, 'v1.0.0');
t.is(result.lastRelease.version, '1.0.0');
t.is(result.length, 1);
t.is(result[0].hash, commits[1].hash);
t.is(result[0].message, commits[1].message);
t.truthy(result[0].committerDate);
t.truthy(result[0].author.name);
t.truthy(result[0].committer.name);
});
test.serial('Return empty array if lastRelease.gitHead is the last commit', async t => {
@ -339,15 +73,10 @@ test.serial('Return empty array if lastRelease.gitHead is the last commit', asyn
const commits = await gitCommits(['First', 'Second']);
// Retrieve the commits with the commits module, since commit 'Second' (therefore none)
const result = await getCommits({gitHead: commits[0].hash, version: '1.0.0'}, 'master', t.context.logger);
const result = await getCommits(commits[0].hash, 'master', t.context.logger);
// Verify no commit is retrieved
t.deepEqual(result.commits, []);
// Verify the last release is returned and updated
t.truthy(result.lastRelease);
t.is(result.lastRelease.gitHead.substring(0, 7), commits[0].hash);
t.is(result.lastRelease.version, '1.0.0');
t.falsy(result.lastRelease.gitTag);
t.deepEqual(result, []);
});
test.serial('Return empty array if there is no commits', async t => {
@ -355,110 +84,8 @@ test.serial('Return empty array if there is no commits', async t => {
await gitRepo();
// Retrieve the commits with the commits module
const result = await getCommits({}, 'master', t.context.logger);
const result = await getCommits(undefined, 'master', t.context.logger);
// Verify no commit is retrieved
t.deepEqual(result.commits, []);
// Verify the last release is returned and updated
t.truthy(result.lastRelease);
t.falsy(result.lastRelease.gitHead);
t.falsy(result.lastRelease.version);
});
test.serial('Throws ENOTINHISTORY error if gitHead is not in history', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
// Add commits to the master branch
await gitCommits(['First', 'Second']);
// Retrieve the commits with the commits module
const error = await t.throws(getCommits({gitHead: 'notinhistory'}, 'master', t.context.logger));
// Verify error code and type
t.is(error.code, 'ENOTINHISTORY');
t.is(error.name, 'SemanticReleaseError');
// Verify the log function has been called with a message mentionning the branch
t.regex(t.context.error.args[0][0], /history of the "master" branch/);
// Verify the log function has been called with a message mentionning the missing gitHead
t.regex(t.context.error.args[0][0], /restoring the commit "notinhistory"/);
});
test.serial('Throws ENOTINHISTORY error if gitHead is not in branch history but present in others', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
// Add commits to the master branch
await gitCommits(['First', 'Second']);
// Create the new branch 'other-branch' from master
await gitCheckout('other-branch');
// Add commits to the 'other-branch' branch
const commitsBranch = await gitCommits(['Third', 'Fourth']);
await gitCheckout('master', false);
// Retrieve the commits with the commits module
const error = await t.throws(
getCommits({version: '1.0.1', gitHead: commitsBranch[0].hash}, 'master', t.context.logger)
);
// Verify error code and type
t.is(error.code, 'ENOTINHISTORY');
t.is(error.name, 'SemanticReleaseError');
// Verify the log function has been called with a message mentionning the branch
t.regex(t.context.error.args[0][0], /history of the "master" branch/);
// Verify the log function has been called with a message mentionning the missing gitHead
t.regex(t.context.error.args[0][0], new RegExp(`restoring the commit "${commitsBranch[0].hash}"`));
});
test.serial('Throws ENOTINHISTORY error if gitHead is not in detached head but present in other branch', async t => {
// Create a git repository, set the current working directory at the root of the repo
const repo = await gitRepo();
// Add commit to the master branch
await gitCommits(['First']);
// Create the new branch 'other-branch' from master
await gitCheckout('other-branch');
// Add commits to the 'other-branch' branch
const commitsBranch = await gitCommits(['Second', 'Third']);
await gitCheckout('master', false);
// Add new commit to master branch
const commitsMaster = await gitCommits(['Fourth']);
// Create a detached head repo at commit 'Fourth'
await gitDetachedHead(repo, commitsMaster[0].hash);
// Retrieve the commits with the commits module, since commit 'Second'
const error = await t.throws(
getCommits({version: '1.0.1', gitHead: commitsBranch[0].hash}, 'master', t.context.logger)
);
// Verify error code and type
t.is(error.code, 'ENOTINHISTORY');
t.is(error.name, 'SemanticReleaseError');
// Verify the log function has been called with a message mentionning the branch
t.regex(t.context.error.args[0][0], /history of the "master" branch/);
// Verify the log function has been called with a message mentionning the missing gitHead
t.regex(t.context.error.args[0][0], new RegExp(`restoring the commit "${commitsBranch[0].hash}"`));
});
test.serial('Throws ENOTINHISTORY error when a tag is not in branch history but present in others', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
// Add commits to the master branch
await gitCommits(['First', 'Second']);
// Create the new branch 'other-branch' from master
await gitCheckout('other-branch');
// Add commits to the 'other-branch' branch
await gitCommits(['Third']);
// Create the tag corresponding to version 1.0.0
const shaTag = await gitTagVersion('v1.0.0');
await gitCheckout('master', false);
// Add new commit to the master branch
await gitCommits(['Forth']);
// Retrieve the commits with the commits module
const error = await t.throws(getCommits({version: '1.0.0', gitHead: shaTag}, 'master', t.context.logger));
// Verify error code and type
t.is(error.code, 'ENOTINHISTORY');
t.is(error.name, 'SemanticReleaseError');
// Verify the log function has been called with a message mentionning the branch
t.regex(t.context.error.args[0][0], /history of the "master" branch/);
// Verify the log function has been called with a message mentionning the missing gitHead
t.regex(t.context.error.args[0][0], new RegExp(`restoring the commit "${shaTag}"`));
t.deepEqual(result, []);
});

View File

@ -13,6 +13,12 @@ const envBackup = Object.assign({}, process.env);
const cwd = process.cwd();
test.beforeEach(t => {
// Delete environment variables that could have been set on the machine running the tests
delete process.env.GIT_CREDENTIALS;
delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.GL_TOKEN;
delete process.env.GITLAB_TOKEN;
t.context.plugins = stub().returns({});
t.context.getConfig = proxyquire('../lib/get-config', {'./plugins': t.context.plugins});
});
@ -70,9 +76,8 @@ test.serial('Default values, reading repositoryUrl (http url) from package.json
test.serial('Read options from package.json', async t => {
const release = {
analyzeCommits: 'analyzeCommits',
analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'},
generateNotes: 'generateNotes',
getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_param'},
branch: 'test_branch',
repositoryUrl: 'git+https://hostname.com/owner/module.git',
};
@ -92,7 +97,7 @@ test.serial('Read options from package.json', async t => {
test.serial('Read options from .releaserc.yml', async t => {
const release = {
getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_param'},
analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'},
branch: 'test_branch',
repositoryUrl: 'git+https://hostname.com/owner/module.git',
};
@ -112,7 +117,7 @@ test.serial('Read options from .releaserc.yml', async t => {
test.serial('Read options from .releaserc.json', async t => {
const release = {
getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_param'},
analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'},
branch: 'test_branch',
repositoryUrl: 'git+https://hostname.com/owner/module.git',
};
@ -132,7 +137,7 @@ test.serial('Read options from .releaserc.json', async t => {
test.serial('Read options from .releaserc.js', async t => {
const release = {
getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_param'},
analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'},
branch: 'test_branch',
repositoryUrl: 'git+https://hostname.com/owner/module.git',
};
@ -152,7 +157,7 @@ test.serial('Read options from .releaserc.js', async t => {
test.serial('Read options from release.config.js', async t => {
const release = {
getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_param'},
analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'},
branch: 'test_branch',
repositoryUrl: 'git+https://hostname.com/owner/module.git',
};
@ -172,11 +177,11 @@ test.serial('Read options from release.config.js', async t => {
test.serial('Prioritise CLI/API parameters over file configuration and git repo', async t => {
const release = {
getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_pkg'},
analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_pkg'},
branch: 'branch_pkg',
};
const options = {
getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_cli'},
analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_cli'},
branch: 'branch_cli',
repositoryUrl: 'http://cli-url.com/owner/package',
};
@ -200,9 +205,8 @@ test.serial('Prioritise CLI/API parameters over file configuration and git repo'
test.serial('Read configuration from file path in "extends"', async t => {
const release = {extends: './shareable.json'};
const shareable = {
analyzeCommits: 'analyzeCommits',
analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'},
generateNotes: 'generateNotes',
getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_param'},
branch: 'test_branch',
repositoryUrl: 'git+https://hostname.com/owner/module.git',
};
@ -222,16 +226,14 @@ test.serial('Read configuration from file path in "extends"', async t => {
t.deepEqual(t.context.plugins.args[0][1], {
analyzeCommits: './shareable.json',
generateNotes: './shareable.json',
getLastRelease: './shareable.json',
});
});
test.serial('Read configuration from module path in "extends"', async t => {
const release = {extends: 'shareable'};
const shareable = {
analyzeCommits: 'analyzeCommits',
analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'},
generateNotes: 'generateNotes',
getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_param'},
branch: 'test_branch',
repositoryUrl: 'git+https://hostname.com/owner/module.git',
};
@ -251,23 +253,22 @@ test.serial('Read configuration from module path in "extends"', async t => {
t.deepEqual(t.context.plugins.args[0][1], {
analyzeCommits: 'shareable',
generateNotes: 'shareable',
getLastRelease: 'shareable',
});
});
test.serial('Read configuration from an array of paths in "extends"', async t => {
const release = {extends: ['./shareable1.json', './shareable2.json']};
const shareable1 = {
analyzeCommits: 'analyzeCommits1',
getLastRelease: {path: 'getLastRelease1', param: 'getLastRelease_param1'},
verifyRelease: 'verifyRelease1',
analyzeCommits: {path: 'analyzeCommits1', param: 'analyzeCommits_param1'},
branch: 'test_branch',
repositoryUrl: 'git+https://hostname.com/owner/module.git',
};
const shareable2 = {
analyzeCommits: 'analyzeCommits2',
verifyRelease: 'verifyRelease2',
generateNotes: 'generateNotes2',
getLastRelease: {path: 'getLastRelease2', param: 'getLastRelease_param2'},
analyzeCommits: {path: 'analyzeCommits2', param: 'analyzeCommits_param2'},
branch: 'test_branch',
};
@ -285,11 +286,11 @@ test.serial('Read configuration from an array of paths in "extends"', async t =>
// Verify the plugins module is called with the plugin options from shareable1.json and shareable2.json
t.deepEqual(t.context.plugins.args[0][0], {...shareable1, ...shareable2});
t.deepEqual(t.context.plugins.args[0][1], {
verifyRelease1: './shareable1.json',
verifyRelease2: './shareable2.json',
generateNotes2: './shareable2.json',
analyzeCommits1: './shareable1.json',
analyzeCommits2: './shareable2.json',
generateNotes2: './shareable2.json',
getLastRelease1: './shareable1.json',
getLastRelease2: './shareable2.json',
});
});
@ -371,13 +372,13 @@ test.serial('Prioritize configuration from cli/API options over "extends"', asyn
test.serial('Allow to unset properties defined in shareable config with "null"', async t => {
const release = {
extends: './shareable.json',
getLastRelease: null,
analyzeCommits: null,
branch: 'test_branch',
repositoryUrl: 'git+https://hostname.com/owner/module.git',
};
const shareable = {
generateNotes: 'generateNotes',
getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_param'},
analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'},
};
// Create a git repository, set the current working directory at the root of the repo
@ -389,28 +390,28 @@ test.serial('Allow to unset properties defined in shareable config with "null"',
const {options} = await t.context.getConfig();
// Verify the options contains the plugin config from shareable.json
t.deepEqual(options, {...omit(shareable, 'getLastRelease'), ...omit(release, ['extends', 'getLastRelease'])});
t.deepEqual(options, {...omit(shareable, 'analyzeCommits'), ...omit(release, ['extends', 'analyzeCommits'])});
// Verify the plugins module is called with the plugin options from shareable.json
t.deepEqual(t.context.plugins.args[0][0], {
...omit(shareable, 'getLastRelease'),
...omit(release, ['extends', 'getLastRelease']),
...omit(shareable, 'analyzeCommits'),
...omit(release, ['extends', 'analyzeCommits']),
});
t.deepEqual(t.context.plugins.args[0][1], {
generateNotes: './shareable.json',
getLastRelease: './shareable.json',
analyzeCommits: './shareable.json',
});
});
test.serial('Allow to unset properties defined in shareable config with "undefined"', async t => {
const release = {
extends: './shareable.json',
getLastRelease: undefined,
analyzeCommits: undefined,
branch: 'test_branch',
repositoryUrl: 'git+https://hostname.com/owner/module.git',
};
const shareable = {
generateNotes: 'generateNotes',
getLastRelease: {path: 'getLastRelease', param: 'getLastRelease_param'},
analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'},
};
// Create a git repository, set the current working directory at the root of the repo
@ -423,15 +424,15 @@ test.serial('Allow to unset properties defined in shareable config with "undefin
const {options} = await t.context.getConfig();
// Verify the options contains the plugin config from shareable.json
t.deepEqual(options, {...omit(shareable, 'getLastRelease'), ...omit(release, ['extends', 'getLastRelease'])});
t.deepEqual(options, {...omit(shareable, 'analyzeCommits'), ...omit(release, ['extends', 'analyzeCommits'])});
// Verify the plugins module is called with the plugin options from shareable.json
t.deepEqual(t.context.plugins.args[0][0], {
...omit(shareable, 'getLastRelease'),
...omit(release, ['extends', 'getLastRelease']),
...omit(shareable, 'analyzeCommits'),
...omit(release, ['extends', 'analyzeCommits']),
});
t.deepEqual(t.context.plugins.args[0][1], {
generateNotes: './shareable.json',
getLastRelease: './shareable.json',
analyzeCommits: './shareable.json',
});
});

View File

@ -0,0 +1,70 @@
import test from 'ava';
import getAuthUrl from '../lib/get-git-auth-url';
// Save the current process.env
const envBackup = Object.assign({}, process.env);
test.beforeEach(() => {
// Restore process.env
process.env = {};
});
test.afterEach.always(() => {
// Restore process.env
process.env = envBackup;
});
test.serial('Return the same "repositoryUrl" is no "gitCredentials" is defined', t => {
t.is(getAuthUrl('git@host.com:owner/repo.git'), 'git@host.com:owner/repo.git');
});
test.serial('Return the "https" formatted URL if "gitCredentials" is defined and repositoryUrl is a "git" URL', t => {
process.env.GIT_CREDENTIALS = 'user:pass';
t.is(getAuthUrl('git@host.com:owner/repo.git'), 'https://user:pass@host.com/owner/repo.git');
});
test.serial('Return the "https" formatted URL if "gitCredentials" is defined and repositoryUrl is a "https" URL', t => {
process.env.GIT_CREDENTIALS = 'user:pass';
t.is(getAuthUrl('https://host.com/owner/repo.git'), 'https://user:pass@host.com/owner/repo.git');
});
test.serial('Return the "http" formatted URL if "gitCredentials" is defined and repositoryUrl is a "http" URL', t => {
process.env.GIT_CREDENTIALS = 'user:pass';
t.is(getAuthUrl('http://host.com/owner/repo.git'), 'http://user:pass@host.com/owner/repo.git');
});
test.serial(
'Return the "https" formatted URL if "gitCredentials" is defined and repositoryUrl is a "git+https" URL',
t => {
process.env.GIT_CREDENTIALS = 'user:pass';
t.is(getAuthUrl('git+https://host.com/owner/repo.git'), 'https://user:pass@host.com/owner/repo.git');
}
);
test.serial(
'Return the "http" formatted URL if "gitCredentials" is defined and repositoryUrl is a "git+http" URL',
t => {
process.env.GIT_CREDENTIALS = 'user:pass';
t.is(getAuthUrl('git+http://host.com/owner/repo.git'), 'http://user:pass@host.com/owner/repo.git');
}
);
test.serial('Return the "https" formatted URL if "gitCredentials" is defined with "GH_TOKEN"', t => {
process.env.GH_TOKEN = 'token';
t.is(getAuthUrl('git@host.com:owner/repo.git'), 'https://token@host.com/owner/repo.git');
});
test.serial('Return the "https" formatted URL if "gitCredentials" is defined with "GITHUB_TOKEN"', t => {
process.env.GITHUB_TOKEN = 'token';
t.is(getAuthUrl('git@host.com:owner/repo.git'), 'https://token@host.com/owner/repo.git');
});
test.serial('Return the "https" formatted URL if "gitCredentials" is defined with "GL_TOKEN"', t => {
process.env.GL_TOKEN = 'token';
t.is(getAuthUrl('git@host.com:owner/repo.git'), 'https://gitlab-ci-token:token@host.com/owner/repo.git');
});
test.serial('Return the "https" formatted URL if "gitCredentials" is defined with "GITLAB_TOKEN"', t => {
process.env.GITLAB_TOKEN = 'token';
t.is(getAuthUrl('git@host.com:owner/repo.git'), 'https://gitlab-ci-token:token@host.com/owner/repo.git');
});

View File

@ -0,0 +1,78 @@
import test from 'ava';
import {stub} from 'sinon';
import getLastRelease from '../lib/get-last-release';
import {gitRepo, gitCommits, gitTagVersion, gitCheckout} from './helpers/git-utils';
// Save the current working diretory
const cwd = process.cwd();
test.beforeEach(t => {
// Stub the logger functions
t.context.log = stub();
t.context.logger = {log: t.context.log};
});
test.afterEach.always(() => {
// Restore the current working directory
process.chdir(cwd);
});
test.serial('Get the highest valid tag', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
// Create some commits and tags
await gitCommits(['First']);
await gitTagVersion('foo');
const commits = await gitCommits(['Second']);
await gitTagVersion('v2.0.0');
await gitCommits(['Third']);
await gitTagVersion('v1.0.0');
await gitCommits(['Fourth']);
await gitTagVersion('v3.0');
const result = await getLastRelease(t.context.logger);
t.deepEqual(result, {gitHead: commits[0].hash, gitTag: 'v2.0.0', version: '2.0.0'});
t.deepEqual(t.context.log.args[0], ['Found git tag version %s', 'v2.0.0']);
});
test.serial('Get the highest tag in the history of the current branch', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
// Add commit to the master branch
await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
// Create the new branch 'other-branch' from master
await gitCheckout('other-branch');
// Add commit to the 'other-branch' branch
await gitCommits(['Second']);
// Create the tag corresponding to version 3.0.0
await gitTagVersion('v3.0.0');
// Checkout master
await gitCheckout('master', false);
// Add another commit to the master branch
const commits = await gitCommits(['Third']);
// Create the tag corresponding to version 2.0.0
await gitTagVersion('v2.0.0');
const result = await getLastRelease(t.context.logger);
t.deepEqual(result, {gitHead: commits[0].hash, gitTag: 'v2.0.0', version: '2.0.0'});
});
test.serial('Return empty object if no valid tag is found', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
// Create some commits and tags
await gitCommits(['First']);
await gitTagVersion('foo');
await gitCommits(['Second']);
await gitTagVersion('v2.0.x');
await gitCommits(['Third']);
await gitTagVersion('v3.0');
const result = await getLastRelease(t.context.logger);
t.deepEqual(result, {});
t.is(t.context.log.args[0][0], 'No git tag version found');
});

View File

@ -1,14 +1,27 @@
import test from 'ava';
import fileUrl from 'file-url';
import {gitTagHead, gitCommitTag, isCommitInHistory, unshallow, gitHead, repoUrl} from '../lib/git';
import tempy from 'tempy';
import {
gitTagHead,
isRefInHistory,
unshallow,
gitHead,
repoUrl,
tag,
push,
gitTags,
isGitRepo,
deleteTag,
} from '../lib/git';
import {
gitRepo,
gitCommits,
gitCheckout,
gitTagVersion,
gitShallowClone,
gitLog,
gitGetCommits,
gitAddConfig,
gitCommitTag,
gitRemoteTagHead,
} from './helpers/git-utils';
// Save the current working diretory
@ -27,7 +40,7 @@ test.serial('Get the last commit sha', async t => {
const result = await gitHead();
t.is(result.substring(0, 7), commits[0].hash);
t.is(result, commits[0].hash);
});
test.serial('Throw error if the last commit sha cannot be found', async t => {
@ -46,12 +59,12 @@ test.serial('Unshallow repository', async t => {
await gitShallowClone(repo);
// Verify the shallow clone contains only one commit
t.is((await gitLog()).length, 1);
t.is((await gitGetCommits()).length, 1);
await unshallow();
// Verify the shallow clone contains all the commits
t.is((await gitLog()).length, 2);
t.is((await gitGetCommits()).length, 2);
});
test.serial('Do not throw error when unshallow a complete repository', async t => {
@ -73,11 +86,11 @@ test.serial('Verify if the commit `sha` is in the direct history of the current
const otherCommits = await gitCommits(['Second']);
await gitCheckout('master', false);
t.true(await isCommitInHistory(commits[0].hash));
t.false(await isCommitInHistory(otherCommits[0].hash));
t.true(await isRefInHistory(commits[0].hash));
t.false(await isRefInHistory(otherCommits[0].hash));
});
test.serial('Get the tag associated with a commit sha or "null" if the commit does not exists', async t => {
test.serial('Get the commit sha for a given tag or falsy if the tag does not exists', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
// Add commits to the master branch
@ -85,19 +98,7 @@ test.serial('Get the tag associated with a commit sha or "null" if the commit do
// Create the tag corresponding to version 1.0.0
await gitTagVersion('v1.0.0');
t.is(await gitCommitTag(commits[0].hash), 'v1.0.0');
t.falsy(await gitCommitTag('missing_sha'));
});
test.serial('Get the commit sha for a given tag or "null" if the tag does not exists', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
// Add commits to the master branch
const commits = await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
await gitTagVersion('v1.0.0');
t.is((await gitTagHead('v1.0.0')).substring(0, 7), commits[0].hash);
t.is(await gitTagHead('v1.0.0'), commits[0].hash);
t.falsy(await gitTagHead('missing_tag'));
});
@ -117,12 +118,66 @@ test.serial('Return git remote repository url set while cloning', async t => {
// Create a clone
await gitShallowClone(repo);
t.is(await repoUrl(), fileUrl(repo));
t.is(await repoUrl(), repo);
});
test.serial('Return "undefined" if git repository url is not set', async t => {
test.serial('Return falsy if git repository url is not set', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
t.is(await repoUrl(), undefined);
t.falsy(await repoUrl());
});
test.serial('Add tag on head commit', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
const commits = await gitCommits(['Test commit']);
await tag('tag_name');
await t.is(await gitCommitTag(commits[0].hash), 'tag_name');
});
test.serial('Delete a tag', async t => {
// Create a git repository with a remote, set the current working directory at the root of the repo
const repo = await gitRepo(true);
await gitCommits(['Test commit']);
await tag('tag_name');
await push(repo, 'master');
await deleteTag(repo, 'tag_name');
t.falsy(await gitTagHead('tag_name'));
t.falsy(await gitRemoteTagHead(repo, 'tag_name'));
});
test.serial('Push tag and commit to remote repository', async t => {
// Create a git repository with a remote, set the current working directory at the root of the repo
const repo = await gitRepo(true);
const commits = await gitCommits(['Test commit']);
await tag('tag_name');
await push(repo, 'master');
t.is(await gitRemoteTagHead(repo, 'tag_name'), commits[0].hash);
});
test.serial('Return "true" if in a Git repository', async t => {
// Create a git repository with a remote, set the current working directory at the root of the repo
await gitRepo(true);
t.true(await isGitRepo());
});
test.serial('Return "false" if not in a Git repository', async t => {
const dir = tempy.directory();
process.chdir(dir);
t.false(await isGitRepo());
});
test.serial('Throws error if obtaining the tags fails', async t => {
const dir = tempy.directory();
process.chdir(dir);
await t.throws(gitTags());
});

View File

@ -2,28 +2,60 @@ import tempy from 'tempy';
import execa from 'execa';
import fileUrl from 'file-url';
import pReduce from 'p-reduce';
import gitLogParser from 'git-log-parser';
import getStream from 'get-stream';
/**
* Commit message informations.
*
* @typedef {Object} Commit
* @property {string} branch The commit branch.
* @property {string} hash The commit hash.
* @property {string} message The commit message.
* @property {String} branch The commit branch.
* @property {String} hash The commit hash.
* @property {String} message The commit message.
*/
/**
* Create a temporary git repository and change the current working directory to the repository root.
* Create a temporary git repository.
* If `withRemote` is `true`, creates a bare repository, initialize it and create a shallow clone. Change the current working directory to the clone root.
* If `withRemote` is `false`, creates a regular repository and initialize it. Change the current working directory to the repository root.
*
* @return {string} The path of the repository.
* @param {Boolean} withRemote `true` to create a shallow clone of a bare repository.
* @param {String} [branc='master'] The branch to initialize.
* @return {String} The path of the clone if `withRemote` is `true`, the path of the repository otherwise.
*/
export async function gitRepo() {
export async function gitRepo(withRemote, branch = 'master') {
const dir = tempy.directory();
process.chdir(dir);
await execa('git', ['init']);
await gitCheckout('master');
return dir;
await execa('git', ['init'].concat(withRemote ? ['--bare'] : []));
if (withRemote) {
await initBareRepo(fileUrl(dir), branch);
await gitShallowClone(fileUrl(dir));
} else {
await gitCheckout(branch);
}
return fileUrl(dir);
}
/**
* Initialize an existing bare repository:
* - Clone the repository
* - Change the current working directory to the clone root
* - Create a default branch
* - Create an initial commits
* - Push to origin
*
* @param {String} origin The URL of the bare repository.
* @param {String} [branch='master'] the branch to initialize.
*/
export async function initBareRepo(origin, branch = 'master') {
const clone = tempy.directory();
await execa('git', ['clone', '--no-hardlinks', origin, clone]);
process.chdir(clone);
await gitCheckout(branch);
await gitCommits(['Initial commit']);
await execa('git', ['push', origin, branch]);
}
/**
@ -34,35 +66,38 @@ export async function gitRepo() {
* @returns {Array<Commit>} The created commits, in reverse order (to match `git log` order).
*/
export async function gitCommits(messages) {
return (await pReduce(
await pReduce(
messages,
async (commits, msg) => {
const {stdout} = await execa('git', ['commit', '-m', msg, '--allow-empty', '--no-gpg-sign']);
const [, branch, hash, message] = /^\[(\w+)\(?.*?\)?(\w+)\] (.+)$/.exec(stdout);
commits.push({branch, hash, message});
const stdout = await execa.stdout('git', ['commit', '-m', msg, '--allow-empty', '--no-gpg-sign']);
const [, hash] = /^\[(?:\w+)\(?.*?\)?(\w+)\] .+(?:\n|$)/.exec(stdout);
commits.push(hash);
return commits;
},
[]
)).reverse();
);
return (await gitGetCommits()).slice(0, messages.length);
}
/**
* Amend a commit (rewriting the sha) on the current git repository.
* Get the list of parsed commits since a git reference.
*
* @param {string} messages commit message.
*
* @returns {Array<Commit>} the created commits.
* @param {String} [from] Git reference from which to seach commits.
* @return {Array<Object>} The list of parsed commits.
*/
export async function gitAmmendCommit(msg) {
const {stdout} = await execa('git', ['commit', '--amend', '-m', msg, '--allow-empty']);
const [, branch, hash, message] = /^\[(\w+)\(?.*?\)?(\w+)\] (.+)(.|\s)+$/.exec(stdout);
return {branch, hash, message};
export async function gitGetCommits(from) {
Object.assign(gitLogParser.fields, {hash: 'H', message: 'B', gitTags: 'd', committerDate: {key: 'ci', type: Date}});
return (await getStream.array(gitLogParser.parse({_: `${from ? from + '..' : ''}HEAD`}))).map(commit => {
commit.message = commit.message.trim();
commit.gitTags = commit.gitTags.trim();
return commit;
});
}
/**
* Checkout a branch on the current git repository.
*
* @param {string} branch Branch name.
* @param {String} branch Branch name.
* @param {boolean} create `true` to create the branche ans switch, `false` to only switch.
*/
export async function gitCheckout(branch, create = true) {
@ -70,61 +105,48 @@ export async function gitCheckout(branch, create = true) {
}
/**
* @return {string} The sha of the head commit in the current git repository.
* @return {String} The sha of the head commit in the current git repository.
*/
export async function gitHead() {
return (await execa('git', ['rev-parse', 'HEAD'])).stdout;
return execa.stdout('git', ['rev-parse', 'HEAD']);
}
/**
* Create a tag on the head commit in the current git repository.
*
* @param {string} tagName The tag name to create.
* @param {string} [sha] The commit on which to create the tag. If undefined the tag is created on the last commit.
*
* @return {string} The commit sha of the created tag.
* @param {String} tagName The tag name to create.
* @param {String} [sha] The commit on which to create the tag. If undefined the tag is created on the last commit.
*/
export async function gitTagVersion(tagName, sha) {
await execa('git', sha ? ['tag', '-f', tagName, sha] : ['tag', tagName]);
return (await execa('git', ['rev-list', '-1', '--tags', tagName])).stdout;
}
/**
* @return {Array<string>} The list of tags from the current git repository.
*/
export async function gitTags() {
return (await execa('git', ['tag'])).stdout.split('\n').filter(tag => Boolean(tag));
}
/**
* @return {Array<string>} The list of commit sha from the current git repository.
*/
export async function gitLog() {
return (await execa('git', ['log', '--format=format:%H'])).stdout.split('\n').filter(sha => Boolean(sha));
export async function gitRemoteTagVersion(origin, tagName, sha = 'HEAD') {
await execa('git', ['push', origin, `${sha}:refs/tags/${tagName}`]);
}
/**
* Create a shallow clone of a git repository and change the current working directory to the cloned repository root.
* The shallow will contain a limited number of commit and no tags.
*
* @param {string} origin The path of the repository to clone.
* @param {number} [depth=1] The number of commit to clone.
* @return {string} The path of the cloned repository.
* @param {String} origin The path of the repository to clone.
* @param {Number} [depth=1] The number of commit to clone.
* @return {String} The path of the cloned repository.
*/
export async function gitShallowClone(origin, branch = 'master', depth = 1) {
const dir = tempy.directory();
process.chdir(dir);
await execa('git', ['clone', '--no-hardlinks', '--no-tags', '-b', branch, '--depth', depth, fileUrl(origin), dir]);
await execa('git', ['clone', '--no-hardlinks', '--no-tags', '-b', branch, '--depth', depth, origin, dir]);
return dir;
}
/**
* Create a git repo with a detached head from another git repository and change the current working directory to the new repository root.
*
* @param {string} origin The path of the repository to clone.
* @param {number} head A commit sha of the origin repo that will become the detached head of the new one.
* @return {string} The path of the new repository.
* @param {String} origin The path of the repository to clone.
* @param {Number} head A commit sha of the origin repo that will become the detached head of the new one.
* @return {String} The path of the new repository.
*/
export async function gitDetachedHead(origin, head) {
const dir = tempy.directory();
@ -137,19 +159,59 @@ export async function gitDetachedHead(origin, head) {
return dir;
}
/**
* Pack heads and tags of the current git repository.
*/
export async function gitPackRefs() {
await execa('git', ['pack-refs', '--all']);
}
/**
* Add a new Git configuration.
*
* @param {string} name Config name.
* @param {string} value Config value.
* @param {String} name Config name.
* @param {String} value Config value.
*/
export async function gitAddConfig(name, value) {
await execa('git', ['config', '--add', name, value]);
}
/**
* Get the first commit sha referenced by the tag `tagName` in the local repository.
*
* @param {String} tagName Tag name for which to retrieve the commit sha.
*
* @return {String} The sha of the commit associated with `tagName` on the local repository.
*/
export async function gitTagHead(tagName) {
return execa.stdout('git', ['rev-list', '-1', tagName]);
}
/**
* Get the first commit sha referenced by the tag `tagName` in the remote repository.
*
* @param {String} origin The repository remote URL.
* @param {String} tagName The tag name to seach for.
* @return {String} The sha of the commit associated with `tagName` on the remote repository.
*/
export async function gitRemoteTagHead(origin, tagName) {
return (await execa.stdout('git', ['ls-remote', '--tags', origin, tagName]))
.split('\n')
.filter(tag => Boolean(tag))
.map(tag => tag.match(/^(\S+)/)[1])[0];
}
/**
* Get the tag associated with a commit sha.
*
* @param {String} gitHead The commit sha for which to retrieve the associated tag.
*
* @return {String} The tag associatedwith the sha in parameter or `null`.
*/
export async function gitCommitTag(gitHead) {
return execa.stdout('git', ['describe', '--tags', '--exact-match', gitHead]);
}
/**
* Push to the remote repository.
*
* @param {String} origin The remote repository URL.
* @param {String} branch The branch to push.
* @throws {Error} if the push failed.
*/
export async function push(origin, branch) {
await execa('git', ['push', '--tags', origin, `HEAD:${branch}`]);
}

75
test/helpers/gitbox.js Normal file
View File

@ -0,0 +1,75 @@
import Docker from 'dockerode';
import getStream from 'get-stream';
import pRetry from 'p-retry';
import {initBareRepo, gitShallowClone} from './git-utils';
const IMAGE = 'pvdlg/docker-gitbox';
const SERVER_PORT = 80;
const HOST_PORT = 2080;
const SERVER_HOST = 'localhost';
const GIT_USERNAME = 'integration';
const GIT_PASSWORD = 'suchsecure';
const docker = new Docker();
let container;
const gitCredential = `${GIT_USERNAME}:${GIT_PASSWORD}`;
/**
* Download the `gitbox` Docker image, create a new container and start it.
*
* @return {Promise} Promise that resolves when the container is started.
*/
async function start() {
await getStream(await docker.pull(IMAGE));
container = await docker.createContainer({
Tty: true,
Image: IMAGE,
PortBindings: {[`${SERVER_PORT}/tcp`]: [{HostPort: `${HOST_PORT}`}]},
});
await container.start();
const exec = await container.exec({
Cmd: ['ng-auth', '-u', GIT_USERNAME, '-p', GIT_PASSWORD],
AttachStdout: true,
AttachStderr: true,
});
await exec.start();
}
/**
* Stop and remote the `mockserver` Docker container.
*
* @return {Promise} Promise that resolves when the container is stopped.
*/
async function stop() {
await container.stop();
await container.remove();
}
/**
* Initialize a remote repository and creates a shallow clone.
*
* @param {String} name The remote repository name.
* @param {String} [branch='master'] The branch to initialize.
* @param {String} [description=`Repository ${name}`] The repository description.
* @return {Object} The `repositoryUrl` (URL without auth) and `authUrl` (URL with auth).
*/
async function createRepo(name, branch = 'master', description = `Repository ${name}`) {
const exec = await container.exec({
Cmd: ['repo-admin', '-n', name, '-d', description],
AttachStdout: true,
AttachStderr: true,
});
await exec.start();
const repositoryUrl = `http://${SERVER_HOST}:${HOST_PORT}/git/${name}.git`;
const authUrl = `http://${gitCredential}@${SERVER_HOST}:${HOST_PORT}/git/${name}.git`;
// Retry as the server might take a few ms to make the repo available push
await pRetry(() => initBareRepo(authUrl, branch), {retries: 3, minTimeout: 500, factor: 2});
await gitShallowClone(authUrl);
return {repositoryUrl, authUrl};
}
export default {start, stop, gitCredential, createRepo};

View File

@ -5,8 +5,16 @@ import tempy from 'tempy';
import clearModule from 'clear-module';
import SemanticReleaseError from '@semantic-release/error';
import DEFINITIONS from '../lib/plugins/definitions';
import {gitHead as getGitHead} from '../lib/git';
import {gitRepo, gitCommits, gitTagVersion} from './helpers/git-utils';
import {
gitHead as getGitHead,
gitTagHead,
gitRepo,
gitCommits,
gitTagVersion,
gitRemoteTagHead,
push,
gitShallowClone,
} from './helpers/git-utils';
// Save the current process.env
const envBackup = Object.assign({}, process.env);
@ -16,6 +24,12 @@ const cwd = process.cwd();
test.beforeEach(t => {
clearModule('../lib/hide-sensitive');
// Delete environment variables that could have been set on the machine running the tests
delete process.env.GIT_CREDENTIALS;
delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.GL_TOKEN;
delete process.env.GITLAB_TOKEN;
// Stub the logger functions
t.context.log = stub();
t.context.error = stub();
@ -36,7 +50,7 @@ test.afterEach.always(t => {
test.serial('Plugins are called with expected values', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
const repositoryUrl = await gitRepo(true);
// Add commits to the master branch
let commits = await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
@ -49,17 +63,15 @@ test.serial('Plugins are called with expected values', async t => {
const notes = 'Release notes';
const verifyConditions1 = stub().resolves();
const verifyConditions2 = stub().resolves();
const getLastRelease = stub().resolves(lastRelease);
const analyzeCommits = stub().resolves(nextRelease.type);
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves(notes);
const publish = stub().resolves();
const config = {branch: 'master', repositoryUrl: 'git@hostname.com:owner/module.git', globalOpt: 'global'};
const config = {branch: 'master', repositoryUrl, globalOpt: 'global'};
const options = {
...config,
verifyConditions: [verifyConditions1, verifyConditions2],
getLastRelease,
analyzeCommits,
verifyRelease,
generateNotes,
@ -78,16 +90,12 @@ test.serial('Plugins are called with expected values', async t => {
t.is(verifyConditions2.callCount, 1);
t.deepEqual(verifyConditions2.args[0][1], {options, logger: t.context.logger});
t.is(getLastRelease.callCount, 1);
t.deepEqual(getLastRelease.args[0][0], config);
t.deepEqual(getLastRelease.args[0][1], {options, logger: t.context.logger});
t.is(analyzeCommits.callCount, 1);
t.deepEqual(analyzeCommits.args[0][0], config);
t.deepEqual(analyzeCommits.args[0][1].options, options);
t.deepEqual(analyzeCommits.args[0][1].logger, t.context.logger);
t.deepEqual(analyzeCommits.args[0][1].lastRelease, lastRelease);
t.deepEqual(analyzeCommits.args[0][1].commits[0].hash.substring(0, 7), commits[0].hash);
t.deepEqual(analyzeCommits.args[0][1].commits[0].hash, commits[0].hash);
t.deepEqual(analyzeCommits.args[0][1].commits[0].message, commits[0].message);
t.is(verifyRelease.callCount, 1);
@ -95,7 +103,7 @@ test.serial('Plugins are called with expected values', async t => {
t.deepEqual(verifyRelease.args[0][1].options, options);
t.deepEqual(verifyRelease.args[0][1].logger, t.context.logger);
t.deepEqual(verifyRelease.args[0][1].lastRelease, lastRelease);
t.deepEqual(verifyRelease.args[0][1].commits[0].hash.substring(0, 7), commits[0].hash);
t.deepEqual(verifyRelease.args[0][1].commits[0].hash, commits[0].hash);
t.deepEqual(verifyRelease.args[0][1].commits[0].message, commits[0].message);
t.deepEqual(verifyRelease.args[0][1].nextRelease, nextRelease);
@ -104,7 +112,7 @@ test.serial('Plugins are called with expected values', async t => {
t.deepEqual(generateNotes.args[0][1].options, options);
t.deepEqual(generateNotes.args[0][1].logger, t.context.logger);
t.deepEqual(generateNotes.args[0][1].lastRelease, lastRelease);
t.deepEqual(generateNotes.args[0][1].commits[0].hash.substring(0, 7), commits[0].hash);
t.deepEqual(generateNotes.args[0][1].commits[0].hash, commits[0].hash);
t.deepEqual(generateNotes.args[0][1].commits[0].message, commits[0].message);
t.deepEqual(generateNotes.args[0][1].nextRelease, nextRelease);
@ -113,14 +121,18 @@ test.serial('Plugins are called with expected values', async t => {
t.deepEqual(publish.args[0][1].options, options);
t.deepEqual(publish.args[0][1].logger, t.context.logger);
t.deepEqual(publish.args[0][1].lastRelease, lastRelease);
t.deepEqual(publish.args[0][1].commits[0].hash.substring(0, 7), commits[0].hash);
t.deepEqual(publish.args[0][1].commits[0].hash, commits[0].hash);
t.deepEqual(publish.args[0][1].commits[0].message, commits[0].message);
t.deepEqual(publish.args[0][1].nextRelease, Object.assign({}, nextRelease, {notes}));
// Verify the tag has been created on the local and remote repo and reference the gitHead
t.is(await gitTagHead(nextRelease.gitTag), nextRelease.gitHead);
t.is(await gitRemoteTagHead(repositoryUrl, nextRelease.gitTag), nextRelease.gitHead);
});
test.serial('Use new gitHead, and recreate release notes if a publish plugin create a commit', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
const repositoryUrl = await gitRepo(true);
// Add commits to the master branch
let commits = await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
@ -128,21 +140,19 @@ test.serial('Use new gitHead, and recreate release notes if a publish plugin cre
// Add new commits to the master branch
commits = (await gitCommits(['Second'])).concat(commits);
const lastRelease = {version: '1.0.0', gitHead: commits[commits.length - 1].hash, gitTag: 'v1.0.0'};
const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'};
const notes = 'Release notes';
const generateNotes = stub().resolves(notes);
const publish1 = stub().callsFake(async () => {
await gitCommits(['Third']);
commits = (await gitCommits(['Third'])).concat(commits);
});
const publish2 = stub().resolves();
const options = {
branch: 'master',
repositoryUrl: 'git@hostname.com:owner/module.git',
repositoryUrl,
verifyConditions: stub().resolves(),
getLastRelease: stub().resolves(lastRelease),
analyzeCommits: stub().resolves(nextRelease.type),
verifyRelease: stub().resolves(),
generateNotes,
@ -153,6 +163,7 @@ test.serial('Use new gitHead, and recreate release notes if a publish plugin cre
'./lib/logger': t.context.logger,
'env-ci': () => ({isCi: true, branch: 'master', isPr: false}),
});
t.truthy(await semanticRelease(options));
t.is(generateNotes.callCount, 2);
@ -165,11 +176,15 @@ test.serial('Use new gitHead, and recreate release notes if a publish plugin cre
t.deepEqual(generateNotes.secondCall.args[1].nextRelease, Object.assign({}, nextRelease, {notes}));
t.is(publish2.callCount, 1);
t.deepEqual(publish2.args[0][1].nextRelease, Object.assign({}, nextRelease, {notes}));
// Verify the tag has been created on the local and remote repo and reference the last gitHead
t.is(await gitTagHead(nextRelease.gitTag), commits[0].hash);
t.is(await gitRemoteTagHead(repositoryUrl, nextRelease.gitTag), commits[0].hash);
});
test.serial('Log all "verifyConditions" errors', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
const repositoryUrl = await gitRepo(true);
// Add commits to the master branch
await gitCommits(['First']);
@ -178,7 +193,7 @@ test.serial('Log all "verifyConditions" errors', async t => {
const error3 = new SemanticReleaseError('error 3', 'ERR3');
const options = {
branch: 'master',
repositoryUrl: 'git@hostname.com:owner/module.git',
repositoryUrl,
verifyConditions: [stub().rejects(error1), stub().rejects(error2), stub().rejects(error3)],
};
@ -200,22 +215,20 @@ test.serial('Log all "verifyConditions" errors', async t => {
test.serial('Log all "verifyRelease" errors', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
const repositoryUrl = await gitRepo(true);
// Add commits to the master branch
let commits = await gitCommits(['First']);
await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
await gitTagVersion('v1.0.0');
// Add new commits to the master branch
commits = (await gitCommits(['Second'])).concat(commits);
await gitCommits(['Second']);
const error1 = new SemanticReleaseError('error 1', 'ERR1');
const error2 = new SemanticReleaseError('error 2', 'ERR2');
const lastRelease = {version: '1.0.0', gitHead: commits[commits.length - 1].hash, gitTag: 'v1.0.0'};
const options = {
branch: 'master',
repositoryUrl: 'git@hostname.com:owner/module.git',
repositoryUrl,
verifyConditions: stub().resolves(),
getLastRelease: stub().resolves(lastRelease),
analyzeCommits: stub().resolves('major'),
verifyRelease: [stub().rejects(error1), stub().rejects(error2)],
};
@ -233,20 +246,18 @@ test.serial('Log all "verifyRelease" errors', async t => {
test.serial('Dry-run skips publish', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
const repositoryUrl = await gitRepo(true);
// Add commits to the master branch
let commits = await gitCommits(['First']);
await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
await gitTagVersion('v1.0.0');
// Add new commits to the master branch
commits = (await gitCommits(['Second'])).concat(commits);
await gitCommits(['Second']);
const lastRelease = {version: '1.0.0', gitHead: commits[commits.length - 1].hash, gitTag: 'v1.0.0'};
const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'};
const notes = 'Release notes';
const verifyConditions = stub().resolves();
const getLastRelease = stub().resolves(lastRelease);
const analyzeCommits = stub().resolves(nextRelease.type);
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves(notes);
@ -255,9 +266,8 @@ test.serial('Dry-run skips publish', async t => {
const options = {
dryRun: true,
branch: 'master',
repositoryUrl: 'git@hostname.com:owner/module.git',
repositoryUrl,
verifyConditions,
getLastRelease,
analyzeCommits,
verifyRelease,
generateNotes,
@ -272,7 +282,6 @@ test.serial('Dry-run skips publish', async t => {
t.not(t.context.log.args[0][0], 'This run was not triggered in a known CI environment, running in dry-run mode.');
t.is(verifyConditions.callCount, 1);
t.is(getLastRelease.callCount, 1);
t.is(analyzeCommits.callCount, 1);
t.is(verifyRelease.callCount, 1);
t.is(generateNotes.callCount, 1);
@ -281,20 +290,18 @@ test.serial('Dry-run skips publish', async t => {
test.serial('Force a dry-run if not on a CI and "noCi" is not explicitly set', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
const repositoryUrl = await gitRepo(true);
// Add commits to the master branch
let commits = await gitCommits(['First']);
await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
await gitTagVersion('v1.0.0');
// Add new commits to the master branch
commits = (await gitCommits(['Second'])).concat(commits);
await gitCommits(['Second']);
const lastRelease = {version: '1.0.0', gitHead: commits[commits.length - 1].hash, gitTag: 'v1.0.0'};
const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'};
const notes = 'Release notes';
const verifyConditions = stub().resolves();
const getLastRelease = stub().resolves(lastRelease);
const analyzeCommits = stub().resolves(nextRelease.type);
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves(notes);
@ -303,9 +310,8 @@ test.serial('Force a dry-run if not on a CI and "noCi" is not explicitly set', a
const options = {
dryRun: false,
branch: 'master',
repositoryUrl: 'git@hostname.com:owner/module.git',
repositoryUrl,
verifyConditions,
getLastRelease,
analyzeCommits,
verifyRelease,
generateNotes,
@ -320,7 +326,6 @@ test.serial('Force a dry-run if not on a CI and "noCi" is not explicitly set', a
t.is(t.context.log.args[0][0], 'This run was not triggered in a known CI environment, running in dry-run mode.');
t.is(verifyConditions.callCount, 1);
t.is(getLastRelease.callCount, 1);
t.is(analyzeCommits.callCount, 1);
t.is(verifyRelease.callCount, 1);
t.is(generateNotes.callCount, 1);
@ -329,20 +334,18 @@ test.serial('Force a dry-run if not on a CI and "noCi" is not explicitly set', a
test.serial('Allow local releases with "noCi" option', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
const repositoryUrl = await gitRepo(true);
// Add commits to the master branch
let commits = await gitCommits(['First']);
await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
await gitTagVersion('v1.0.0');
// Add new commits to the master branch
commits = (await gitCommits(['Second'])).concat(commits);
await gitCommits(['Second']);
const lastRelease = {version: '1.0.0', gitHead: commits[commits.length - 1].hash, gitTag: 'v1.0.0'};
const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'};
const notes = 'Release notes';
const verifyConditions = stub().resolves();
const getLastRelease = stub().resolves(lastRelease);
const analyzeCommits = stub().resolves(nextRelease.type);
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves(notes);
@ -351,9 +354,8 @@ test.serial('Allow local releases with "noCi" option', async t => {
const options = {
noCi: true,
branch: 'master',
repositoryUrl: 'git@hostname.com:owner/module.git',
repositoryUrl,
verifyConditions,
getLastRelease,
analyzeCommits,
verifyRelease,
generateNotes,
@ -372,27 +374,25 @@ test.serial('Allow local releases with "noCi" option', async t => {
"This run was triggered by a pull request and therefore a new version won't be published."
);
t.is(verifyConditions.callCount, 1);
t.is(getLastRelease.callCount, 1);
t.is(analyzeCommits.callCount, 1);
t.is(verifyRelease.callCount, 1);
t.is(generateNotes.callCount, 1);
t.is(publish.callCount, 1);
});
test.serial('Accept "undefined" values for the "getLastRelease" and "generateNotes" plugins', async t => {
test.serial('Accept "undefined" value returned by the "generateNotes" plugins', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
const repositoryUrl = await gitRepo(true);
// Add commits to the master branch
await gitCommits(['First']);
let commits = await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
await gitTagVersion('v1.0.0');
// Add new commits to the master branch
await gitCommits(['Second']);
commits = (await gitCommits(['Second'])).concat(commits);
const lastRelease = {gitHead: undefined, gitTag: undefined, version: undefined};
const lastRelease = {version: '1.0.0', gitHead: commits[commits.length - 1].hash, gitTag: 'v1.0.0'};
const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'};
const verifyConditions = stub().resolves();
const getLastRelease = stub().resolves();
const analyzeCommits = stub().resolves(nextRelease.type);
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves();
@ -400,9 +400,8 @@ test.serial('Accept "undefined" values for the "getLastRelease" and "generateNot
const options = {
branch: 'master',
repositoryUrl: 'git@hostname.com:owner/module.git',
repositoryUrl,
verifyConditions: [verifyConditions],
getLastRelease,
analyzeCommits,
verifyRelease,
generateNotes,
@ -415,8 +414,6 @@ test.serial('Accept "undefined" values for the "getLastRelease" and "generateNot
});
t.truthy(await semanticRelease(options));
t.is(getLastRelease.callCount, 1);
t.is(analyzeCommits.callCount, 1);
t.deepEqual(analyzeCommits.args[0][1].lastRelease, lastRelease);
@ -445,26 +442,25 @@ test.serial('Returns falsy value if not running from a git repository', async t
test.serial('Returns falsy value if triggered by a PR', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
const repositoryUrl = await gitRepo(true);
const semanticRelease = proxyquire('..', {
'./lib/logger': t.context.logger,
'env-ci': () => ({isCi: true, branch: 'master', isPr: true}),
});
t.falsy(await semanticRelease({repositoryUrl: 'git@hostname.com:owner/module.git'}));
t.falsy(await semanticRelease({repositoryUrl}));
t.is(
t.context.log.args[7][0],
t.context.log.args[6][0],
"This run was triggered by a pull request and therefore a new version won't be published."
);
});
test.serial('Returns falsy value if not running from the configured branch', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
const repositoryUrl = await gitRepo(true);
const verifyConditions = stub().resolves();
const getLastRelease = stub().resolves();
const analyzeCommits = stub().resolves();
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves();
@ -472,9 +468,8 @@ test.serial('Returns falsy value if not running from the configured branch', asy
const options = {
branch: 'master',
repositoryUrl: 'git@hostname.com:owner/module.git',
repositoryUrl,
verifyConditions: [verifyConditions],
getLastRelease,
analyzeCommits,
verifyRelease,
generateNotes,
@ -495,12 +490,11 @@ test.serial('Returns falsy value if not running from the configured branch', asy
test.serial('Returns falsy value if there is no relevant changes', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
const repositoryUrl = await gitRepo(true);
// Add commits to the master branch
await gitCommits(['First']);
const verifyConditions = stub().resolves();
const getLastRelease = stub().resolves();
const analyzeCommits = stub().resolves();
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves();
@ -508,9 +502,8 @@ test.serial('Returns falsy value if there is no relevant changes', async t => {
const options = {
branch: 'master',
repositoryUrl: 'git@hostname.com:owner/module.git',
repositoryUrl,
verifyConditions: [verifyConditions],
getLastRelease,
analyzeCommits,
verifyRelease,
generateNotes,
@ -532,7 +525,7 @@ test.serial('Returns falsy value if there is no relevant changes', async t => {
test.serial('Exclude commits with [skip release] or [release skip] from analysis', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
const repositoryUrl = await gitRepo(true);
// Add commits to the master branch
const commits = await gitCommits([
'Test commit',
@ -547,17 +540,15 @@ test.serial('Exclude commits with [skip release] or [release skip] from analysis
const verifyConditions1 = stub().resolves();
const verifyConditions2 = stub().resolves();
const getLastRelease = stub().resolves({});
const analyzeCommits = stub().resolves();
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves();
const publish = stub().resolves();
const config = {branch: 'master', repositoryUrl: 'git@hostname.com:owner/module.git', globalOpt: 'global'};
const config = {branch: 'master', repositoryUrl, globalOpt: 'global'};
const options = {
...config,
verifyConditions: [verifyConditions1, verifyConditions2],
getLastRelease,
analyzeCommits,
verifyRelease,
generateNotes,
@ -571,18 +562,18 @@ test.serial('Exclude commits with [skip release] or [release skip] from analysis
await semanticRelease(options);
t.is(analyzeCommits.callCount, 1);
t.is(analyzeCommits.args[0][1].commits.length, 1);
t.deepEqual(analyzeCommits.args[0][1].commits[0].hash.substring(0, 7), commits[commits.length - 1].hash);
t.deepEqual(analyzeCommits.args[0][1].commits[0].message, commits[commits.length - 1].message);
t.is(analyzeCommits.args[0][1].commits.length, 2);
t.deepEqual(analyzeCommits.args[0][1].commits[0], commits[commits.length - 1]);
});
test.serial('Hide sensitive environment variable values from the logs', async t => {
process.env.MY_TOKEN = 'secret token';
await gitRepo();
const repositoryUrl = await gitRepo(true);
const options = {
branch: 'master',
repositoryUrl: 'git@hostname.com:owner/module.git',
repositoryUrl,
verifyConditions: async (pluginConfig, {logger}) => {
console.log(`Console: The token ${process.env.MY_TOKEN} is invalid`);
logger.log(`Log: The token ${process.env.MY_TOKEN} is invalid`);
@ -595,8 +586,9 @@ test.serial('Hide sensitive environment variable values from the logs', async t
});
await t.throws(semanticRelease(options));
t.regex(t.context.stdout.args[7][0], /Console: The token \[secure\] is invalid/);
t.regex(t.context.stdout.args[8][0], /Log: The token \[secure\] is invalid/);
t.regex(t.context.stdout.args[6][0], /Console: The token \[secure\] is invalid/);
t.regex(t.context.stdout.args[7][0], /Log: The token \[secure\] is invalid/);
t.regex(t.context.stderr.args[0][0], /Error: The token \[secure\] is invalid/);
t.regex(t.context.stderr.args[1][0], /Invalid token \[secure\]/);
});
@ -618,7 +610,7 @@ test.serial('Throw SemanticReleaseError if repositoryUrl is not set and cannot b
test.serial('Throw an Error if plugin returns an unexpected value', async t => {
// Create a git repository, set the current working directory at the root of the repo
await gitRepo();
const repositoryUrl = await gitRepo(true);
// Add commits to the master branch
await gitCommits(['First']);
// Create the tag corresponding to version 1.0.0
@ -627,13 +619,13 @@ test.serial('Throw an Error if plugin returns an unexpected value', async t => {
await gitCommits(['Second']);
const verifyConditions = stub().resolves();
const getLastRelease = stub().resolves('string');
const analyzeCommits = stub().resolves('string');
const options = {
branch: 'master',
repositoryUrl: 'git@hostname.com:owner/module.git',
repositoryUrl,
verifyConditions: [verifyConditions],
getLastRelease,
analyzeCommits,
};
const semanticRelease = proxyquire('..', {
@ -643,6 +635,41 @@ test.serial('Throw an Error if plugin returns an unexpected value', async t => {
const error = await t.throws(semanticRelease(options), Error);
// Verify error message
t.regex(error.message, new RegExp(DEFINITIONS.getLastRelease.output.message));
t.regex(error.message, new RegExp(DEFINITIONS.analyzeCommits.output.message));
t.regex(error.message, /Received: 'string'/);
});
test.serial('Get all commits including the ones not in the shallow clone', async t => {
const repositoryUrl = await gitRepo(true);
await gitTagVersion('v1.0.0');
await gitCommits(['First', 'Second', 'Third']);
await push(repositoryUrl, 'master');
await gitShallowClone(repositoryUrl);
const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'};
const notes = 'Release notes';
const verifyConditions = stub().resolves();
const analyzeCommits = stub().resolves(nextRelease.type);
const verifyRelease = stub().resolves();
const generateNotes = stub().resolves(notes);
const publish = stub().resolves();
const config = {branch: 'master', repositoryUrl, globalOpt: 'global'};
const options = {
...config,
verifyConditions,
analyzeCommits,
verifyRelease,
generateNotes,
publish,
};
const semanticRelease = proxyquire('..', {
'./lib/logger': t.context.logger,
'env-ci': () => ({isCi: true, branch: 'master', isPr: false}),
});
t.truthy(await semanticRelease(options));
t.is(analyzeCommits.args[0][1].commits.length, 3);
});

View File

@ -2,7 +2,8 @@ import test from 'ava';
import {writeJson, readJson} from 'fs-extra';
import {stub} from 'sinon';
import execa from 'execa';
import {gitRepo, gitCommits, gitHead, gitTagVersion, gitPackRefs, gitAmmendCommit} from './helpers/git-utils';
import {gitHead as getGitHead, gitTagHead, gitRepo, gitCommits, gitRemoteTagHead} from './helpers/git-utils';
import gitbox from './helpers/gitbox';
import mockServer from './helpers/mockserver';
import npmRegistry from './helpers/npm-registry';
import semanticRelease from '..';
@ -11,7 +12,7 @@ import semanticRelease from '..';
// Environment variables used with semantic-release cli (similar to what a user would setup)
const env = {
GH_TOKEN: 'github_token',
GH_TOKEN: gitbox.gitCredential,
GITHUB_URL: mockServer.url,
NPM_EMAIL: 'integration@test.com',
NPM_USERNAME: 'integration',
@ -35,6 +36,8 @@ stub(process.stdout, 'write');
stub(process.stderr, 'write');
test.before(async () => {
// Start the Git server
await gitbox.start();
// Start the local NPM registry
await npmRegistry.start();
// Start Mock Server
@ -42,17 +45,20 @@ test.before(async () => {
});
test.beforeEach(() => {
// Delete env paramaters that could have been set on the machine running the tests
// Delete environment variables that could have been set on the machine running the tests
delete process.env.NPM_TOKEN;
delete process.env.NPM_USERNAME;
delete process.env.NPM_PASSWORD;
delete process.env.NPM_EMAIL;
delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.GH_URL;
delete process.env.GITHUB_URL;
delete process.env.GH_PREFIX;
delete process.env.GITHUB_PREFIX;
delete process.env.GIT_CREDENTIALS;
delete process.env.GH_TOKEN;
delete process.env.GITHUB_TOKEN;
delete process.env.GL_TOKEN;
delete process.env.GITLAB_TOKEN;
process.env.TRAVIS = 'true';
process.env.CI = 'true';
@ -75,6 +81,8 @@ test.afterEach.always(() => {
});
test.after.always(async () => {
// Stop the Git server
await gitbox.stop();
// Stop the local NPM registry
await npmRegistry.stop();
// Stop Mock Server
@ -83,15 +91,15 @@ test.after.always(async () => {
test.serial('Release patch, minor and major versions', async t => {
const packageName = 'test-release';
const owner = 'test-owner';
const owner = 'git';
// Create a git repository, set the current working directory at the root of the repo
t.log('Create git repository and package.json');
await gitRepo();
const {repositoryUrl, authUrl} = await gitbox.createRepo(packageName);
// Create package.json in repository root
await writeJson('./package.json', {
name: packageName,
version: '0.0.0-dev',
repository: {url: `git+https://github.com/${owner}/${packageName}`},
repository: {url: repositoryUrl},
publishConfig: {registry: npmRegistry.url},
});
// Create a npm-shrinkwrap.json file
@ -118,16 +126,6 @@ test.serial('Release patch, minor and major versions', async t => {
{headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {permissions: {push: true}}, method: 'GET'}
);
let getRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs/tags/v${version}`,
{},
{body: {}, statusCode: 404, method: 'GET'}
);
let createRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs`,
{body: {ref: `refs/tags/v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {ref: `refs/tags/${version}`}}
);
let createReleaseMock = await mockServer.mock(
`/repos/${owner}/${packageName}/releases`,
{
@ -153,13 +151,14 @@ test.serial('Release patch, minor and major versions', async t => {
let [, releasedVersion, releasedGitHead] = /^version = '(.+)'\s+gitHead = '(.+)'$/.exec(
(await execa('npm', ['show', packageName, 'version', 'gitHead'], {env: testEnv})).stdout
);
let gitHead = await getGitHead();
t.is(releasedVersion, version);
t.is(releasedGitHead, await gitHead());
t.is(releasedGitHead, gitHead);
t.is(await gitTagHead(`v${version}`), gitHead);
t.is(await gitRemoteTagHead(authUrl, `v${version}`), gitHead);
t.log(`+ released ${releasedVersion} with gitHead ${releasedGitHead}`);
await mockServer.verify(verifyMock);
await mockServer.verify(getRefMock);
await mockServer.verify(createRefMock);
await mockServer.verify(createReleaseMock);
/* Patch release */
@ -169,16 +168,6 @@ test.serial('Release patch, minor and major versions', async t => {
{headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {permissions: {push: true}}, method: 'GET'}
);
getRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs/tags/v${version}`,
{},
{body: {}, statusCode: 404, method: 'GET'}
);
createRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs`,
{body: {ref: `refs/tags/v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {ref: `refs/tags/${version}`}}
);
createReleaseMock = await mockServer.mock(
`/repos/${owner}/${packageName}/releases`,
{
@ -204,13 +193,14 @@ test.serial('Release patch, minor and major versions', async t => {
[, releasedVersion, releasedGitHead] = /^version = '(.+)'\s+gitHead = '(.+)'$/.exec(
(await execa('npm', ['show', packageName, 'version', 'gitHead'], {env: testEnv})).stdout
);
gitHead = await getGitHead();
t.is(releasedVersion, version);
t.is(releasedGitHead, await gitHead());
t.is(releasedGitHead, gitHead);
t.is(await gitTagHead(`v${version}`), gitHead);
t.is(await gitRemoteTagHead(authUrl, `v${version}`), gitHead);
t.log(`+ released ${releasedVersion} with gitHead ${releasedGitHead}`);
await mockServer.verify(verifyMock);
await mockServer.verify(getRefMock);
await mockServer.verify(createRefMock);
await mockServer.verify(createReleaseMock);
/* Minor release */
@ -220,16 +210,6 @@ test.serial('Release patch, minor and major versions', async t => {
{headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {permissions: {push: true}}, method: 'GET'}
);
getRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs/tags/v${version}`,
{},
{body: {}, statusCode: 404, method: 'GET'}
);
createRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs`,
{body: {ref: `refs/tags/v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {ref: `refs/tags/${version}`}}
);
createReleaseMock = await mockServer.mock(
`/repos/${owner}/${packageName}/releases`,
{
@ -255,13 +235,14 @@ test.serial('Release patch, minor and major versions', async t => {
[, releasedVersion, releasedGitHead] = /^version = '(.+)'\s+gitHead = '(.+)'$/.exec(
(await execa('npm', ['show', packageName, 'version', 'gitHead'], {env: testEnv})).stdout
);
gitHead = await getGitHead();
t.is(releasedVersion, version);
t.is(releasedGitHead, await gitHead());
t.is(releasedGitHead, gitHead);
t.is(await gitTagHead(`v${version}`), gitHead);
t.is(await gitRemoteTagHead(authUrl, `v${version}`), gitHead);
t.log(`+ released ${releasedVersion} with gitHead ${releasedGitHead}`);
await mockServer.verify(verifyMock);
await mockServer.verify(getRefMock);
await mockServer.verify(createRefMock);
await mockServer.verify(createReleaseMock);
/* Major release */
@ -271,16 +252,6 @@ test.serial('Release patch, minor and major versions', async t => {
{headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {permissions: {push: true}}, method: 'GET'}
);
getRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs/tags/v${version}`,
{},
{body: {}, statusCode: 404, method: 'GET'}
);
createRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs`,
{body: {ref: `refs/tags/v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {ref: `refs/tags/${version}`}}
);
createReleaseMock = await mockServer.mock(
`/repos/${owner}/${packageName}/releases`,
{
@ -306,121 +277,14 @@ test.serial('Release patch, minor and major versions', async t => {
[, releasedVersion, releasedGitHead] = /^version = '(.+)'\s+gitHead = '(.+)'$/.exec(
(await execa('npm', ['show', packageName, 'version', 'gitHead'], {env: testEnv})).stdout
);
gitHead = await getGitHead();
t.is(releasedVersion, version);
t.is(releasedGitHead, await gitHead());
t.is(releasedGitHead, gitHead);
t.is(await gitTagHead(`v${version}`), gitHead);
t.is(await gitRemoteTagHead(authUrl, `v${version}`), gitHead);
t.log(`+ released ${releasedVersion} with gitHead ${releasedGitHead}`);
await mockServer.verify(verifyMock);
await mockServer.verify(getRefMock);
await mockServer.verify(createRefMock);
await mockServer.verify(createReleaseMock);
});
test.serial('Release versions from a packed git repository, using tags to determine last release gitHead', async t => {
const packageName = 'test-git-packed';
const owner = 'test-repo';
// Create a git repository, set the current working directory at the root of the repo
t.log('Create git repository');
await gitRepo();
// Create package.json in repository root
await writeJson('./package.json', {
name: packageName,
version: '0.0.0-dev',
repository: {url: `git@github.com:${owner}/${packageName}.git`},
publishConfig: {registry: npmRegistry.url},
});
/* Initial release */
let version = '1.0.0';
let verifyMock = await mockServer.mock(
`/repos/${owner}/${packageName}`,
{headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {permissions: {push: true}}, method: 'GET'}
);
let createRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs`,
{body: {ref: `refs/tags/v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {ref: `refs/tags/${version}`}}
);
let getRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs/tags/v${version}`,
{},
{body: {}, statusCode: 404, method: 'GET'}
);
let createReleaseMock = await mockServer.mock(
`/repos/${owner}/${packageName}/releases`,
{
body: {tag_name: `v${version}`, target_commitish: 'master', name: `v${version}`},
headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}],
},
{body: {html_url: `release-url/${version}`}}
);
t.log('Commit a feature');
await gitCommits(['feat: Initial commit']);
t.log('$ git pack-refs --all');
await gitPackRefs();
t.log('$ semantic-release');
let {stdout, code} = await execa(cli, [], {env});
t.regex(stdout, new RegExp(`Published GitHub release: release-url/${version}`));
t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry`));
t.is(code, 0);
// Verify package.json has been updated
t.is((await readJson('./package.json')).version, version);
// Retrieve the published package from the registry and check version
let releasedVersion = (await execa('npm', ['show', packageName, 'version'], {env: testEnv})).stdout;
t.is(releasedVersion, version);
t.log(`+ released ${releasedVersion}`);
await mockServer.verify(verifyMock);
await mockServer.verify(getRefMock);
await mockServer.verify(createRefMock);
await mockServer.verify(createReleaseMock);
// Create a tag version so the tag can be used later to determine the commit associated with the version
await gitTagVersion(`v${version}`);
t.log(`Create git tag v${version}`);
/* Patch release */
version = '1.0.1';
verifyMock = await mockServer.mock(
`/repos/${owner}/${packageName}`,
{headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {permissions: {push: true}}, method: 'GET'}
);
getRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs/tags/v${version}`,
{},
{body: {}, statusCode: 404, method: 'GET'}
);
createRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs`,
{body: {ref: `refs/tags/v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {ref: `refs/tags/${version}`}}
);
createReleaseMock = await mockServer.mock(
`/repos/${owner}/${packageName}/releases`,
{
body: {tag_name: `v${version}`, target_commitish: 'master', name: `v${version}`},
headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}],
},
{body: {html_url: `release-url/${version}`}}
);
t.log('Commit a fix');
await gitCommits(['fix: bar']);
t.log('$ semantic-release');
({stdout, code} = await execa(cli, [], {env}));
t.regex(stdout, new RegExp(`Published GitHub release: release-url/${version}`));
t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry`));
t.is(code, 0);
// Verify package.json has been updated
t.is((await readJson('./package.json')).version, version);
// Retrieve the published package from the registry and check version
releasedVersion = (await execa('npm', ['show', packageName, 'version'], {env: testEnv})).stdout;
t.is(releasedVersion, version);
t.log(`+ released ${releasedVersion}`);
await mockServer.verify(verifyMock);
await mockServer.verify(getRefMock);
await mockServer.verify(createRefMock);
await mockServer.verify(createReleaseMock);
});
@ -463,7 +327,7 @@ test.serial('Exit with 1 if a shareable config is not found', async t => {
test.serial('Exit with 1 if a shareable config reference a not found plugin', async t => {
const packageName = 'test-config-ref-not-found';
const owner = 'test-repo';
const shareable = {getLastRelease: 'non-existing-path'};
const shareable = {analyzeCommits: 'non-existing-path'};
// Create a git repository, set the current working directory at the root of the repo
t.log('Create git repository');
@ -481,157 +345,17 @@ test.serial('Exit with 1 if a shareable config reference a not found plugin', as
t.regex(stderr, /Cannot find module/);
});
test.serial('Create a tag as a recovery solution for "ENOTINHISTORY" error', async t => {
const packageName = 'test-recovery';
const owner = 'test-repo';
// Create a git repository, set the current working directory at the root of the repo
t.log('Create git repository');
await gitRepo();
// Create package.json in repository root
await writeJson('./package.json', {
name: packageName,
version: '0.0.0-dev',
repository: {url: `git+https://github.com/${owner}/${packageName}`},
publishConfig: {registry: npmRegistry.url},
});
/* Initial release */
let version = '1.0.0';
let verifyMock = await mockServer.mock(
`/repos/${owner}/${packageName}`,
{headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {permissions: {push: true}}, method: 'GET'}
);
let getRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs/tags/v${version}`,
{},
{body: {}, statusCode: 404, method: 'GET'}
);
let createRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs`,
{body: {ref: `refs/tags/v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {ref: `refs/tags/${version}`}}
);
let createReleaseMock = await mockServer.mock(
`/repos/${owner}/${packageName}/releases`,
{
body: {tag_name: `v${version}`, target_commitish: 'master', name: `v${version}`},
headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}],
},
{body: {html_url: `release-url/${version}`}}
);
t.log('Commit a feature');
await gitCommits(['feat: Initial commit']);
t.log('$ semantic-release');
let {stderr, stdout, code} = await execa(cli, [], {env});
t.regex(stdout, new RegExp(`Published GitHub release: release-url/${version}`));
t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry`));
t.is(code, 0);
// Verify package.json has been updated
t.is((await readJson('./package.json')).version, version);
// Retrieve the published package from the registry and check version and gitHead
let [, releasedVersion, releasedGitHead] = /^version = '(.+)'\s+gitHead = '(.+)'$/.exec(
(await execa('npm', ['show', packageName, 'version', 'gitHead'], {env: testEnv})).stdout
);
const head = await gitHead();
t.is(releasedGitHead, head);
t.is(releasedVersion, version);
t.log(`+ released ${releasedVersion}`);
await mockServer.verify(verifyMock);
await mockServer.verify(getRefMock);
await mockServer.verify(createRefMock);
await mockServer.verify(createReleaseMock);
// Create a tag version so the tag can be used later to determine the commit associated with the version
await gitTagVersion(`v${version}`);
t.log(`Create git tag v${version}`);
/* Rewrite sha of commit used for release */
t.log('Amend release commit');
const {hash} = await gitAmmendCommit('feat: Initial commit');
/* Patch release */
verifyMock = await mockServer.mock(
`/repos/${owner}/${packageName}`,
{headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {permissions: {push: true}}, method: 'GET'}
);
t.log('Commit a fix');
await gitCommits(['fix: bar']);
t.log('$ semantic-release');
({stderr, stdout, code} = await execa(cli, [], {env, reject: false}));
t.log('Log "ENOTINHISTORY" message');
t.is(code, 1);
t.regex(
stderr,
new RegExp(
`You can recover from this error by restoring the commit "${head}" or by creating a tag for the version "${version}" on the commit corresponding to this release`
)
);
/* Create a tag to recover and redo release */
t.log(`Create git tag v${version} to recover`);
await gitTagVersion(`v${version}`, hash);
version = '1.0.1';
verifyMock = await mockServer.mock(
`/repos/${owner}/${packageName}`,
{headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {permissions: {push: true}}, method: 'GET'}
);
getRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs/tags/v${version}`,
{},
{body: {}, statusCode: 404, method: 'GET'}
);
createRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs`,
{body: {ref: `refs/tags/v${version}`}, headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {ref: `refs/tags/${version}`}}
);
createReleaseMock = await mockServer.mock(
`/repos/${owner}/${packageName}/releases`,
{
body: {tag_name: `v${version}`, target_commitish: 'master', name: `v${version}`},
headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}],
},
{body: {html_url: `release-url/${version}`}}
);
t.log('$ semantic-release');
({stderr, stdout, code} = await execa(cli, [], {env}));
t.regex(stdout, new RegExp(`Published GitHub release: release-url/${version}`));
t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry`));
t.is(code, 0);
// Verify package.json has been updated
t.is((await readJson('./package.json')).version, version);
// Retrieve the published package from the registry and check version and gitHead
releasedVersion = (await execa('npm', ['show', packageName, 'version'], {env: testEnv})).stdout;
t.is(releasedVersion, version);
t.log(`+ released ${releasedVersion}`);
await mockServer.verify(verifyMock);
await mockServer.verify(getRefMock);
await mockServer.verify(createRefMock);
await mockServer.verify(createReleaseMock);
});
test.serial('Dry-run', async t => {
const packageName = 'test-dry-run';
const owner = 'test-repo';
const owner = 'git';
// Create a git repository, set the current working directory at the root of the repo
t.log('Create git repository and package.json');
await gitRepo();
const {repositoryUrl} = await gitbox.createRepo(packageName);
// Create package.json in repository root
await writeJson('./package.json', {
name: packageName,
version: '0.0.0-dev',
repository: {url: `git+https://github.com/${owner}/${packageName}`},
repository: {url: repositoryUrl},
publishConfig: {registry: npmRegistry.url},
});
@ -660,15 +384,15 @@ test.serial('Allow local releases with "noCi" option', async t => {
delete process.env.TRAVIS;
delete process.env.CI;
const packageName = 'test-no-ci';
const owner = 'test-repo';
const owner = 'git';
// Create a git repository, set the current working directory at the root of the repo
t.log('Create git repository and package.json');
await gitRepo();
const {repositoryUrl, authUrl} = await gitbox.createRepo(packageName);
// Create package.json in repository root
await writeJson('./package.json', {
name: packageName,
version: '0.0.0-dev',
repository: {url: `git+https://github.com/${owner}/${packageName}`},
repository: {url: repositoryUrl},
publishConfig: {registry: npmRegistry.url},
});
@ -679,19 +403,6 @@ test.serial('Allow local releases with "noCi" option', async t => {
{headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {permissions: {push: true}}, method: 'GET'}
);
const getRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs/tags/v${version}`,
{},
{body: {}, statusCode: 404, method: 'GET'}
);
const createRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs`,
{
body: {ref: `refs/tags/v${version}`},
headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}],
},
{body: {ref: `refs/tags/${version}`}}
);
const createReleaseMock = await mockServer.mock(
`/repos/${owner}/${packageName}/releases`,
{
@ -705,7 +416,6 @@ test.serial('Allow local releases with "noCi" option', async t => {
await gitCommits(['feat: Initial commit']);
t.log('$ semantic-release --no-ci');
const {stdout, code} = await execa(cli, ['--no-ci'], {env});
console.log(stdout);
t.regex(stdout, new RegExp(`Published GitHub release: release-url/${version}`));
t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry`));
t.is(code, 0);
@ -717,27 +427,28 @@ test.serial('Allow local releases with "noCi" option', async t => {
const [, releasedVersion, releasedGitHead] = /^version = '(.+)'\s+gitHead = '(.+)'$/.exec(
(await execa('npm', ['show', packageName, 'version', 'gitHead'], {env: testEnv})).stdout
);
const gitHead = await getGitHead();
t.is(releasedVersion, version);
t.is(releasedGitHead, await gitHead());
t.is(releasedGitHead, gitHead);
t.is(await gitTagHead(`v${version}`), gitHead);
t.is(await gitRemoteTagHead(authUrl, `v${version}`), gitHead);
t.log(`+ released ${releasedVersion} with gitHead ${releasedGitHead}`);
await mockServer.verify(verifyMock);
await mockServer.verify(getRefMock);
await mockServer.verify(createRefMock);
await mockServer.verify(createReleaseMock);
});
test.serial('Pass options via CLI arguments', async t => {
const packageName = 'test-cli';
const owner = 'test-repo';
// Create a git repository, set the current working directory at the root of the repo
t.log('Create git repository and package.json');
await gitRepo();
const {repositoryUrl, authUrl} = await gitbox.createRepo(packageName);
// Create package.json in repository root
await writeJson('./package.json', {
name: packageName,
version: '0.0.0-dev',
repository: {url: `git+https://github.com/${owner}/${packageName}`},
repository: {url: repositoryUrl},
publishConfig: {registry: npmRegistry.url},
});
@ -761,22 +472,25 @@ test.serial('Pass options via CLI arguments', async t => {
const [, releasedVersion, releasedGitHead] = /^version = '(.+)'\s+gitHead = '(.+)'$/.exec(
(await execa('npm', ['show', packageName, 'version', 'gitHead'], {env: testEnv})).stdout
);
const gitHead = await getGitHead();
t.is(releasedVersion, version);
t.is(releasedGitHead, await gitHead());
t.is(releasedGitHead, gitHead);
t.is(await gitTagHead(`v${version}`), gitHead);
t.is(await gitRemoteTagHead(authUrl, `v${version}`), gitHead);
t.log(`+ released ${releasedVersion} with gitHead ${releasedGitHead}`);
});
test.serial('Run via JS API', async t => {
const packageName = 'test-js-api';
const owner = 'test-repo';
const owner = 'git';
// Create a git repository, set the current working directory at the root of the repo
t.log('Create git repository and package.json');
await gitRepo();
const {repositoryUrl, authUrl} = await gitbox.createRepo(packageName);
// Create package.json in repository root
await writeJson('./package.json', {
name: packageName,
version: '0.0.0-dev',
repository: {url: `git+https://github.com/${owner}/${packageName}`},
repository: {url: repositoryUrl},
publishConfig: {registry: npmRegistry.url},
});
@ -787,19 +501,6 @@ test.serial('Run via JS API', async t => {
{headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}]},
{body: {permissions: {push: true}}, method: 'GET'}
);
const getRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs/tags/v${version}`,
{},
{body: {}, statusCode: 404, method: 'GET'}
);
const createRefMock = await mockServer.mock(
`/repos/${owner}/${packageName}/git/refs`,
{
body: {ref: `refs/tags/v${version}`},
headers: [{name: 'Authorization', values: [`token ${env.GH_TOKEN}`]}],
},
{body: {ref: `refs/tags/${version}`}}
);
const createReleaseMock = await mockServer.mock(
`/repos/${owner}/${packageName}/releases`,
{
@ -823,27 +524,27 @@ test.serial('Run via JS API', async t => {
const [, releasedVersion, releasedGitHead] = /^version = '(.+)'\s+gitHead = '(.+)'$/.exec(
(await execa('npm', ['show', packageName, 'version', 'gitHead'], {env: testEnv})).stdout
);
const gitHead = await getGitHead();
t.is(releasedVersion, version);
t.is(releasedGitHead, await gitHead());
t.is(releasedGitHead, gitHead);
t.is(await gitTagHead(`v${version}`), gitHead);
t.is(await gitRemoteTagHead(authUrl, `v${version}`), gitHead);
t.log(`+ released ${releasedVersion} with gitHead ${releasedGitHead}`);
await mockServer.verify(verifyMock);
await mockServer.verify(getRefMock);
await mockServer.verify(createRefMock);
await mockServer.verify(createReleaseMock);
});
test.serial('Log unexpected errors from plugins and exit with 1', async t => {
const packageName = 'test-unexpected-error';
const owner = 'test-repo';
// Create a git repository, set the current working directory at the root of the repo
t.log('Create git repository and package.json');
await gitRepo();
const {repositoryUrl} = await gitbox.createRepo(packageName);
// Create package.json in repository root
await writeJson('./package.json', {
name: packageName,
version: '0.0.0-dev',
repository: {url: `git+https://github.com/${owner}/${packageName}`},
repository: {url: repositoryUrl},
release: {verifyConditions: pluginError},
});
@ -863,15 +564,14 @@ test.serial('Log unexpected errors from plugins and exit with 1', async t => {
test.serial('Log errors inheriting SemanticReleaseError and exit with 1', async t => {
const packageName = 'test-inherited-error';
const owner = 'test-repo';
// Create a git repository, set the current working directory at the root of the repo
t.log('Create git repository and package.json');
await gitRepo();
const {repositoryUrl} = await gitbox.createRepo(packageName);
// Create package.json in repository root
await writeJson('./package.json', {
name: packageName,
version: '0.0.0-dev',
repository: {url: `git+https://github.com/${owner}/${packageName}`},
repository: {url: repositoryUrl},
release: {verifyConditions: pluginInheritedError},
});
@ -885,6 +585,28 @@ test.serial('Log errors inheriting SemanticReleaseError and exit with 1', async
t.is(code, 1);
});
test.serial('Exit with 1 if missing permission to push to the remote repository', async t => {
const packageName = 'unauthorized';
// Create a git repository, set the current working directory at the root of the repo
t.log('Create git repository');
const {repositoryUrl} = await gitbox.createRepo(packageName);
await writeJson('./package.json', {
name: packageName,
version: '0.0.0-dev',
repository: {url: repositoryUrl},
});
/* Initial release */
t.log('Commit a feature');
await gitCommits(['feat: Initial commit']);
t.log('$ semantic-release');
const {stdout, code} = await execa(cli, [], {env: {...env, ...{GH_TOKEN: 'user:wrong_pass'}}, reject: false});
// Verify the type and message are logged
t.regex(stdout, /EGITNOPERMISSION/);
t.is(code, 1);
});
test.serial('CLI returns error code and prints help if called with a command', async t => {
t.log('$ semantic-release pre');
const {stdout, code} = await execa(cli, ['pre'], {env, reject: false});

View File

@ -12,17 +12,6 @@ test('The "verifyConditions" plugin, if defined, must be a single or an array of
t.true(definitions.verifyConditions.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
});
test('The "getLastRelease" plugin is mandatory, and must be a single plugin definition', t => {
t.false(definitions.getLastRelease.config.validator({}));
t.false(definitions.getLastRelease.config.validator({path: null}));
t.false(definitions.getLastRelease.config.validator([]));
t.false(definitions.getLastRelease.config.validator());
t.true(definitions.getLastRelease.config.validator({path: 'plugin-path.js'}));
t.true(definitions.getLastRelease.config.validator('plugin-path.js'));
t.true(definitions.getLastRelease.config.validator(() => {}));
});
test('The "analyzeCommits" plugin is mandatory, and must be a single plugin definition', t => {
t.false(definitions.analyzeCommits.config.validator({}));
t.false(definitions.analyzeCommits.config.validator({path: null}));
@ -67,19 +56,6 @@ test('The "publish" plugin is mandatory, and must be a single or an array of plu
t.true(definitions.publish.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
});
test('The "getLastRelease" plugin output if defined, must be an object with a valid semver version in the "version" property and the corresponding git reference in "gitHead" property', t => {
t.false(definitions.getLastRelease.output.validator('string'));
t.false(definitions.getLastRelease.output.validator(1));
t.false(definitions.getLastRelease.output.validator({version: 'v1.0.0'}));
t.false(definitions.getLastRelease.output.validator({version: 'invalid'}));
t.true(definitions.getLastRelease.output.validator());
t.true(definitions.getLastRelease.output.validator({}));
t.true(definitions.getLastRelease.output.validator({version: 'v1.0.0', gitHead: '123'}));
t.true(definitions.getLastRelease.output.validator({version: '1.0.0', gitHead: '123'}));
t.true(definitions.getLastRelease.output.validator({version: null}));
});
test('The "analyzeCommits" plugin output must be either undefined or a valid semver release type', t => {
t.false(definitions.analyzeCommits.output.validator('invalid'));
t.false(definitions.analyzeCommits.output.validator(1));

View File

@ -24,7 +24,6 @@ test('Export default plugins', t => {
// Verify the module returns a function for each plugin
t.is(typeof plugins.verifyConditions, 'function');
t.is(typeof plugins.getLastRelease, 'function');
t.is(typeof plugins.analyzeCommits, 'function');
t.is(typeof plugins.verifyRelease, 'function');
t.is(typeof plugins.generateNotes, 'function');
@ -35,7 +34,7 @@ test('Export plugins based on config', t => {
const plugins = getPlugins(
{
verifyConditions: ['./test/fixtures/plugin-noop', {path: './test/fixtures/plugin-noop'}],
getLastRelease: './test/fixtures/plugin-noop',
generateNotes: './test/fixtures/plugin-noop',
analyzeCommits: {path: './test/fixtures/plugin-noop'},
verifyRelease: () => {},
},
@ -45,7 +44,6 @@ test('Export plugins based on config', t => {
// Verify the module returns a function for each plugin
t.is(typeof plugins.verifyConditions, 'function');
t.is(typeof plugins.getLastRelease, 'function');
t.is(typeof plugins.analyzeCommits, 'function');
t.is(typeof plugins.verifyRelease, 'function');
t.is(typeof plugins.generateNotes, 'function');
@ -64,7 +62,7 @@ test.serial('Export plugins loaded from the dependency of a shareable config mod
const plugins = getPlugins(
{
verifyConditions: ['custom-plugin', {path: 'custom-plugin'}],
getLastRelease: 'custom-plugin',
generateNotes: 'custom-plugin',
analyzeCommits: {path: 'custom-plugin'},
verifyRelease: () => {},
},
@ -74,7 +72,6 @@ test.serial('Export plugins loaded from the dependency of a shareable config mod
// Verify the module returns a function for each plugin
t.is(typeof plugins.verifyConditions, 'function');
t.is(typeof plugins.getLastRelease, 'function');
t.is(typeof plugins.analyzeCommits, 'function');
t.is(typeof plugins.verifyRelease, 'function');
t.is(typeof plugins.generateNotes, 'function');
@ -90,7 +87,7 @@ test.serial('Export plugins loaded from the dependency of a shareable config fil
const plugins = getPlugins(
{
verifyConditions: ['./plugin/plugin-noop', {path: './plugin/plugin-noop'}],
getLastRelease: './plugin/plugin-noop',
generateNotes: './plugin/plugin-noop',
analyzeCommits: {path: './plugin/plugin-noop'},
verifyRelease: () => {},
},
@ -100,7 +97,6 @@ test.serial('Export plugins loaded from the dependency of a shareable config fil
// Verify the module returns a function for each plugin
t.is(typeof plugins.verifyConditions, 'function');
t.is(typeof plugins.getLastRelease, 'function');
t.is(typeof plugins.analyzeCommits, 'function');
t.is(typeof plugins.verifyRelease, 'function');
t.is(typeof plugins.generateNotes, 'function');
@ -108,10 +104,10 @@ test.serial('Export plugins loaded from the dependency of a shareable config fil
});
test('Use default when only options are passed for a single plugin', t => {
const plugins = getPlugins({getLastRelease: {}, analyzeCommits: {}}, {}, t.context.logger);
const plugins = getPlugins({generateNotes: {}, analyzeCommits: {}}, {}, t.context.logger);
// Verify the module returns a function for each plugin
t.is(typeof plugins.getLastRelease, 'function');
t.is(typeof plugins.generateNotes, 'function');
t.is(typeof plugins.analyzeCommits, 'function');
});
@ -120,13 +116,13 @@ test('Merge global options with plugin options', async t => {
{
globalOpt: 'global',
otherOpt: 'globally-defined',
getLastRelease: {path: './test/fixtures/plugin-result-config', localOpt: 'local', otherOpt: 'locally-defined'},
verifyRelease: {path: './test/fixtures/plugin-result-config', localOpt: 'local', otherOpt: 'locally-defined'},
},
{},
t.context.logger
);
const result = await plugins.getLastRelease();
const result = await plugins.verifyRelease();
t.deepEqual(result.pluginConfig, {localOpt: 'local', globalOpt: 'global', otherOpt: 'locally-defined'});
});