UNPKG

release-it

Version:

Generic CLI tool to automate versioning and package publishing-related tasks.

456 lines (362 loc) 15.6 kB
import fs from 'node:fs'; import test, { before, after, afterEach, beforeEach, describe } from 'node:test'; import assert from 'node:assert/strict'; import { Agent } from 'undici'; import Git from '../lib/plugin/git/Git.js'; import GitLab from '../lib/plugin/gitlab/GitLab.js'; import { GitlabTestServer } from './util/https-server/server.js'; import { factory, runTasks } from './util/index.js'; import { interceptUser, interceptCollaborator, interceptPublish, interceptAsset, interceptAssetGeneric, interceptMilestones, interceptMembers } from './stub/gitlab.js'; import { mockFetch } from './util/mock.js'; describe('GitLab', () => { const tokenHeader = 'Private-Token'; const tokenRef = 'GITLAB_TOKEN'; const certificateAuthorityFileRef = 'CI_SERVER_TLS_CA_FILE'; const [mocker, api, example, local] = mockFetch([ 'https://gitlab.com/api/v4', 'https://gitlab.example.org/api/v4', 'https://localhost:3000/api/v4' ]); before(() => { mocker.mockGlobal(); }); let originalEnv; beforeEach(() => { originalEnv = process.env; process.env = { ...originalEnv }; process.env[tokenRef] = '123'; }); afterEach(() => { if (originalEnv !== undefined) process.env = originalEnv; mocker.clearAll(); }); after(() => { mocker.unmockGlobal(); }); test('should validate token', async () => { const tokenRef = 'MY_GITLAB_TOKEN'; const pushRepo = 'https://gitlab.com/user/repo'; const options = { gitlab: { release: true, tokenRef, tokenHeader, pushRepo } }; const gitlab = await factory(GitLab, { options }); delete process.env[tokenRef]; await assert.rejects(gitlab.init(), /Environment variable "MY_GITLAB_TOKEN" is required for GitLab releases/); process.env[tokenRef] = '123'; interceptUser(api, { headers: { 'private-token': '123' } }); interceptCollaborator(api, { headers: { 'private-token': '123' } }); await assert.doesNotReject(gitlab.init()); }); test('should support CI Job token header', async () => { const tokenRef = 'CI_JOB_TOKEN'; const tokenHeader = 'Job-Token'; process.env[tokenRef] = 'j0b-t0k3n'; const pushRepo = 'https://gitlab.com/user/repo'; const options = { git: { pushRepo }, gitlab: { release: true, tokenRef, tokenHeader } }; const gitlab = await factory(GitLab, { options }); interceptPublish(api, { headers: { 'job-token': '1' } }); await assert.doesNotReject(gitlab.init()); delete process.env[tokenRef]; }); test('should upload assets and release', async t => { const pushRepo = 'https://gitlab.com/user/repo'; const options = { git: { pushRepo }, gitlab: { tokenRef, release: true, releaseName: 'Release ${version}', releaseNotes: 'echo Custom notes', assets: 'test/resources/file-v${version}.txt', milestones: ['${version}', '${latestVersion} UAT'] } }; const gitlab = await factory(GitLab, { options }); t.mock.method(gitlab, 'getLatestVersion', () => Promise.resolve('2.0.0')); const git = await factory(Git); const ref = (await git.getBranchName()) ?? 'HEAD'; interceptUser(api); interceptCollaborator(api); interceptMilestones(api, { query: { title: '2.0.1' }, milestones: [{ id: 17, iid: 3, title: '2.0.1' }] }); interceptMilestones(api, { query: { title: '2.0.0 UAT' }, milestones: [{ id: 42, iid: 4, title: '2.0.0 UAT' }] }); interceptAsset(api); interceptPublish(api, { body: { name: 'Release 2.0.1', ref, tag_name: '2.0.1', tag_message: 'Release 2.0.1', description: 'Custom notes', assets: { links: [ { name: 'file-v2.0.1.txt', url: `${pushRepo}/uploads/7e8bec1fe27cc46a4bc6a91b9e82a07c/file-v2.0.1.txt` } ] }, milestones: ['2.0.1', '2.0.0 UAT'] } }); await runTasks(gitlab); assert.equal(gitlab.assets[0].url, `${pushRepo}/uploads/7e8bec1fe27cc46a4bc6a91b9e82a07c/file-v2.0.1.txt`); const { isReleased, releaseUrl } = gitlab.getContext(); assert(isReleased); assert.equal(releaseUrl, `${pushRepo}/-/releases/2.0.1`); }); test('should upload assets with ID-based URLs', async t => { const host = 'https://gitlab.com'; const pushRepo = `${host}/user/repo`; const options = { git: { pushRepo }, gitlab: { tokenRef, release: true, assets: 'test/resources/file-v${version}.txt', useIdsForUrls: true } }; const gitlab = await factory(GitLab, { options }); t.mock.method(gitlab, 'getLatestVersion', () => Promise.resolve('2.0.0')); interceptUser(api); interceptCollaborator(api); interceptAsset(api); interceptPublish(api); await runTasks(gitlab); assert.equal( gitlab.assets[0].url, `${host}/-/project/1234/uploads/7e8bec1fe27cc46a4bc6a91b9e82a07c/file-v2.0.1.txt` ); }); test('should upload assets to generic repo', async t => { const host = 'https://gitlab.com'; const pushRepo = `${host}/user/repo`; const options = { git: { pushRepo }, gitlab: { tokenRef, release: true, assets: 'test/resources/file-v${version}.txt', useGenericPackageRepositoryForAssets: true, genericPackageRepositoryName: 'release-it' } }; const gitlab = await factory(GitLab, { options }); t.mock.method(gitlab, 'getLatestVersion', () => Promise.resolve('2.0.0')); interceptUser(api); interceptCollaborator(api); interceptAssetGeneric(api); interceptPublish(api); await runTasks(gitlab); assert.equal( gitlab.assets[0].url, `${host}/api/v4/projects/user%2Frepo/packages/generic/release-it/2.0.1/file-v2.0.1.txt` ); }); test('should throw when release milestone is missing', async t => { const pushRepo = 'https://gitlab.com/user/repo'; const options = { git: { pushRepo }, gitlab: { tokenRef, release: true, milestones: ['${version}', '${latestVersion} UAT'] } }; const gitlab = await factory(GitLab, { options }); t.mock.method(gitlab, 'getLatestVersion', () => Promise.resolve('2.0.0')); interceptUser(api); interceptCollaborator(api); interceptMilestones(api, { query: { title: '2.0.1' }, milestones: [{ id: 17, iid: 3, title: '2.0.1' }] }); interceptMilestones(api, { query: { title: '2.0.0 UAT' }, milestones: [] }); await assert.rejects( runTasks(gitlab), /Missing one or more milestones in GitLab. Creating a GitLab release will fail./ ); }); test('should release to self-managed host', async t => { const host = 'https://gitlab.example.org'; const options = { git: { pushRepo: `${host}/user/repo` }, gitlab: { releaseName: 'Release ${version}', releaseNotes: 'echo readme', tokenRef } }; const gitlab = await factory(GitLab, { options }); t.mock.method(gitlab, 'getLatestVersion', () => Promise.resolve('1.0.0')); interceptUser(example); interceptCollaborator(example); interceptPublish(example); await runTasks(gitlab); const { origin, baseUrl } = gitlab.getContext(); assert.equal(origin, host); assert.equal(baseUrl, `${host}/api/v4`); }); test('should release to sub-grouped repo', async () => { const options = { gitlab: { tokenRef }, git: { pushRepo: 'git@gitlab.com:group/sub-group/repo.git' } }; const gitlab = await factory(GitLab, { options }); interceptUser(api, { owner: 'sub-group' }); interceptCollaborator(api, { owner: 'sub-group', group: 'group' }); interceptPublish(api, { owner: 'group', project: 'sub-group%2Frepo' }); await runTasks(gitlab); const { isReleased, releaseUrl } = gitlab.getContext(); assert(isReleased); assert.match(releaseUrl, /https:\/\/gitlab.com\/group\/sub-group(\/|%2F)repo\/-\/releases\//); }); test('should throw for unauthenticated user', async () => { const host = 'https://gitlab.com'; const pushRepo = `${host}/user/repo`; const options = { gitlab: { tokenRef, pushRepo, host } }; const gitlab = await factory(GitLab, { options }); api.get('/user', { status: 401 }); await assert.rejects( runTasks(gitlab), /Could not authenticate with GitLab using environment variable "GITLAB_TOKEN"/ ); }); test('should throw for non-collaborator', async () => { const host = 'https://gitlab.com'; const pushRepo = `${host}/john/repo`; const options = { gitlab: { tokenRef, pushRepo, host } }; const gitlab = await factory(GitLab, { options }); interceptMembers(api, { owner: 'emma' }); interceptUser(api, { owner: 'john' }); await assert.rejects(runTasks(gitlab), /User john is not a collaborator for john\/repo/); }); test('should throw for insufficient access level', async () => { const host = 'https://gitlab.com'; const pushRepo = `${host}/john/repo`; const options = { gitlab: { tokenRef, pushRepo, host } }; const gitlab = await factory(GitLab, { options }); interceptMembers(api, { owner: 'john', access_level: 10 }); interceptUser(api, { owner: 'john' }); await assert.rejects(runTasks(gitlab), /User john is not a collaborator for john\/repo/); }); test('should not make requests in dry run', async t => { const [host, owner, repo] = ['https://gitlab.example.org', 'user', 'repo']; const pushRepo = `${host}/${owner}/${repo}`; const options = { 'dry-run': true, git: { pushRepo }, gitlab: { releaseName: 'R', tokenRef } }; const gitlab = await factory(GitLab, { options }); t.mock.method(gitlab, 'getLatestVersion', () => Promise.resolve('1.0.0')); await runTasks(gitlab); const { isReleased, releaseUrl } = gitlab.getContext(); assert.equal(gitlab.log.exec.mock.calls[2].arguments[0], 'gitlab releases#uploadAssets'); assert.equal(gitlab.log.exec.mock.calls[3].arguments[0], 'gitlab releases#createRelease "R" (1.0.1)'); assert(isReleased); assert.equal(releaseUrl, `${pushRepo}/-/releases/1.0.1`); }); test('should skip checks', async () => { const options = { gitlab: { tokenRef, skipChecks: true, release: true, milestones: ['v1.0.0'] } }; const gitlab = await factory(GitLab, { options }); await assert.doesNotReject(gitlab.init()); await assert.doesNotReject(gitlab.beforeRelease()); assert.equal( gitlab.log.exec.mock.calls .flatMap(call => call.arguments) .filter(entry => /checkReleaseMilestones/.test(entry[0])).length, 0 ); }); test('should not create fetch agent', async () => { const options = { gitlab: {} }; const gitlab = await factory(GitLab, { options }); assert.deepEqual(gitlab.certificateAuthorityOption, {}); }); test('should create fetch agent if secure == false', async () => { const options = { gitlab: { secure: false } }; const gitlab = await factory(GitLab, { options }); const { dispatcher } = gitlab.certificateAuthorityOption; assert(dispatcher instanceof Agent, "Fetch dispatcher should be an instance of undici's Agent class"); const kOptions = Object.getOwnPropertySymbols(dispatcher).find(symbol => symbol.description === 'options'); assert.deepEqual(dispatcher[kOptions].connect, { rejectUnauthorized: false, ca: undefined }); }); test('should create fetch agent if certificateAuthorityFile', async t => { const readFileSync = t.mock.method(fs, 'readFileSync', () => 'test certificate'); const options = { gitlab: { certificateAuthorityFile: 'cert.crt' } }; const gitlab = await factory(GitLab, { options }); const { dispatcher } = gitlab.certificateAuthorityOption; assert(dispatcher instanceof Agent, "Fetch dispatcher should be an instance of undici's Agent class"); const kOptions = Object.getOwnPropertySymbols(dispatcher).find(symbol => symbol.description === 'options'); assert.deepEqual(dispatcher[kOptions].connect, { rejectUnauthorized: undefined, ca: 'test certificate' }); readFileSync.mock.restore(); }); test('should create fetch agent if CI_SERVER_TLS_CA_FILE env is set', async t => { const readFileSync = t.mock.method(fs, 'readFileSync', () => 'test certificate'); process.env[certificateAuthorityFileRef] = 'ca.crt'; const options = { gitlab: {} }; const gitlab = await factory(GitLab, { options }); const { dispatcher } = gitlab.certificateAuthorityOption; assert(dispatcher instanceof Agent, "Fetch dispatcher should be an instance of undici's Agent class"); const kOptions = Object.getOwnPropertySymbols(dispatcher).find(symbol => symbol.description === 'options'); assert.deepEqual(dispatcher[kOptions].connect, { rejectUnauthorized: undefined, ca: 'test certificate' }); readFileSync.mock.restore(); }); test('should create fetch agent if certificateAuthorityFileRef env is set', async t => { const readFileSync = t.mock.method(fs, 'readFileSync', () => 'test certificate'); process.env['GITLAB_CA_FILE'] = 'custom-ca.crt'; const options = { gitlab: { certificateAuthorityFileRef: 'GITLAB_CA_FILE' } }; const gitlab = await factory(GitLab, { options }); const { dispatcher } = gitlab.certificateAuthorityOption; assert(dispatcher instanceof Agent, "Fetch dispatcher should be an instance of undici's Agent class"); const kOptions = Object.getOwnPropertySymbols(dispatcher).find(symbol => symbol.description === 'options'); assert.deepEqual(dispatcher[kOptions].connect, { rejectUnauthorized: undefined, ca: 'test certificate' }); readFileSync.mock.restore(); }); test('should throw for insecure connections to self-hosted instances', async t => { const host = 'https://localhost:3000'; const options = { git: { pushRepo: `${host}/user/repo` }, gitlab: { host, tokenRef, origin: host } }; const gitlab = await factory(GitLab, { options }); const server = new GitlabTestServer(); t.after(async () => { await server.stop(); }); await server.run(); await assert.rejects(gitlab.init(), /Could not authenticate with GitLab using environment variable "GITLAB_TOKEN"/); }); test('should succesfully connect to self-hosted instance if insecure connection allowed', async t => { const host = 'https://localhost:3000'; const options = { git: { pushRepo: `${host}/user/repo` }, gitlab: { host, tokenRef, origin: host, secure: false } }; const gitlab = await factory(GitLab, { options }); const server = new GitlabTestServer(); t.after(async () => { await server.stop(); }); await server.run(); interceptUser(local); interceptCollaborator(local); await assert.doesNotReject(gitlab.init()); }); test('should succesfully connect to self-hosted instance with valid CA file', async t => { const host = 'https://localhost:3000'; const options = { git: { pushRepo: `${host}/user/repo` }, gitlab: { host, tokenRef, origin: host, certificateAuthorityFile: 'test/util/https-server/client/my-private-root-ca.cert.pem' } }; const gitlab = await factory(GitLab, { options }); const server = new GitlabTestServer(); t.after(async () => { await server.stop(); }); await server.run(); interceptUser(local); interceptCollaborator(local); await assert.doesNotReject(gitlab.init()); }); });