release-it
Version:
Generic CLI tool to automate versioning and package publishing-related tasks.
339 lines (294 loc) • 10.8 kB
JavaScript
import path from 'node:path';
import fs from 'node:fs'; // import fs here so it can be stubbed in tests
import { readFile } from 'node:fs/promises';
import { glob } from 'tinyglobby';
import { Agent } from 'undici';
import Release from '../GitRelease.js';
import { format, e, castArray } from '../../util.js';
import prompts from './prompts.js';
const docs = 'https://git.io/release-it-gitlab';
const noop = Promise.resolve();
class GitLab extends Release {
constructor(...args) {
super(...args);
this.registerPrompts(prompts);
this.assets = [];
const { secure } = this.options;
const certificateAuthorityFileRef = this.options.certificateAuthorityFileRef || 'CI_SERVER_TLS_CA_FILE';
const certificateAuthorityFile =
this.options.certificateAuthorityFile || process.env[certificateAuthorityFileRef] || null;
this.certificateAuthorityOption = {};
const needsCustomAgent = Boolean(secure === false || certificateAuthorityFile);
if (needsCustomAgent) {
this.certificateAuthorityOption.dispatcher = new Agent({
connect: {
rejectUnauthorized: secure,
ca: certificateAuthorityFile ? fs.readFileSync(certificateAuthorityFile) : undefined
}
});
}
}
async init() {
await super.init();
const { skipChecks, tokenRef, tokenHeader } = this.options;
const { repo } = this.getContext();
const hasJobToken = (tokenHeader || '').toLowerCase() === 'job-token';
const origin = this.options.origin || `https://${repo.host}`;
this.setContext({
id: encodeURIComponent(repo.repository),
origin,
baseUrl: `${origin}/api/v4`
});
if (skipChecks) return;
if (!this.token) {
throw e(`Environment variable "${tokenRef}" is required for GitLab releases.`, docs);
}
if (!hasJobToken) {
if (!(await this.isAuthenticated())) {
throw e(`Could not authenticate with GitLab using environment variable "${tokenRef}".`, docs);
}
if (!(await this.isCollaborator())) {
const { user, repo } = this.getContext();
throw e(`User ${user.username} is not a collaborator for ${repo.repository}.`, docs);
}
}
}
async isAuthenticated() {
if (this.config.isDryRun) return true;
const endpoint = `user`;
try {
const { id, username } = await this.request(endpoint, { method: 'GET' });
this.setContext({ user: { id, username } });
return true;
} catch (err) {
this.debug(err);
return false;
}
}
async isCollaborator() {
if (this.config.isDryRun) return true;
const { id, user } = this.getContext();
const endpoint = `projects/${id}/members/all/${user.id}`;
try {
const { access_level } = await this.request(endpoint, { method: 'GET' });
return access_level && access_level >= 30;
} catch (err) {
this.debug(err);
return false;
}
}
async beforeRelease() {
await super.beforeRelease();
await this.checkReleaseMilestones();
}
async checkReleaseMilestones() {
if (this.options.skipChecks) return;
const releaseMilestones = this.getReleaseMilestones();
if (releaseMilestones.length < 1) {
return;
}
this.log.exec(`gitlab releases#checkReleaseMilestones`);
const { id } = this.getContext();
const endpoint = `projects/${id}/milestones`;
const requests = releaseMilestones.map(milestone => {
const options = {
method: 'GET',
searchParams: {
title: milestone,
include_parent_milestones: true
}
};
return this.request(endpoint, options).then(response => {
if (!Array.isArray(response)) {
const { baseUrl } = this.getContext();
throw new Error(
`Unexpected response from ${baseUrl}/${endpoint}. Expected an array but got: ${JSON.stringify(response)}`
);
}
if (response.length === 0) {
const error = new Error(`Milestone '${milestone}' does not exist.`);
this.log.warn(error.message);
throw error;
}
this.log.verbose(`gitlab releases#checkReleaseMilestones: milestone '${milestone}' exists`);
});
});
try {
await Promise.allSettled(requests).then(results => {
for (const result of results) {
if (result.status === 'rejected') {
throw e('Missing one or more milestones in GitLab. Creating a GitLab release will fail.', docs);
}
}
});
} catch (err) {
this.debug(err);
throw err;
}
this.log.verbose('gitlab releases#checkReleaseMilestones: done');
}
getReleaseMilestones() {
const { milestones } = this.options;
return (milestones || []).map(milestone => format(milestone, this.config.getContext()));
}
async release() {
const glRelease = () => this.createRelease();
const glUploadAssets = () => this.uploadAssets();
if (this.config.isCI) {
await this.step({ enabled: this.options.assets, task: glUploadAssets, label: 'GitLab upload assets' });
return await this.step({ task: glRelease, label: 'GitLab release' });
} else {
const release = () => glUploadAssets().then(() => glRelease());
return await this.step({ task: release, label: 'GitLab release', prompt: 'release' });
}
}
async request(endpoint, options) {
const { baseUrl } = this.getContext();
this.debug(Object.assign({ url: `${baseUrl}/${endpoint}` }, options));
const method = (options.method || 'POST').toLowerCase();
const { tokenHeader } = this.options;
const url = `${baseUrl}/${endpoint}${options.searchParams ? `?${new URLSearchParams(options.searchParams)}` : ''}`;
const headers = {
'user-agent': 'webpro/release-it',
[tokenHeader]: this.token
};
// When using fetch() with FormData bodies, we should not set the Content-Type header.
// See: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects#sending_files_using_a_formdata_object
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = typeof options.json !== 'undefined' ? 'application/json' : 'text/plain';
}
const requestOptions = {
method,
headers,
...this.certificateAuthorityOption
};
try {
const response = await fetch(
url,
options.json || options.body
? {
...requestOptions,
body: options.json ? JSON.stringify(options.json) : options.body
}
: requestOptions
);
if (!response.ok) {
const body = await response.json();
throw new Error(body.error);
}
const body = await response.json();
this.debug(body);
return body;
} catch (err) {
this.debug(err);
throw err;
}
}
async createRelease() {
const { releaseName } = this.options;
const { tagName, branchName, git: { tagAnnotation } = {} } = this.config.getContext();
const { id, releaseNotes, repo, origin } = this.getContext();
const { isDryRun } = this.config;
const name = format(releaseName, this.config.getContext());
const tagMessage = format(tagAnnotation, this.config.getContext());
const description = releaseNotes || '-';
const releaseUrl = `${origin}/${repo.repository}/-/releases/${tagName}`;
const releaseMilestones = this.getReleaseMilestones();
this.log.exec(`gitlab releases#createRelease "${name}" (${tagName})`, { isDryRun });
if (isDryRun) {
this.setContext({ isReleased: true, releaseUrl });
return true;
}
const endpoint = `projects/${id}/releases`;
const options = {
json: {
name,
ref: branchName,
tag_name: tagName,
tag_message: tagMessage,
description
}
};
if (this.assets.length) {
options.json.assets = {
links: this.assets
};
}
if (releaseMilestones.length) {
options.json.milestones = releaseMilestones;
}
try {
const body = await this.request(endpoint, options);
const releaseUrlSelf = body._links?.self ?? releaseUrl;
this.log.verbose('gitlab releases#createRelease: done');
this.setContext({ isReleased: true, releaseUrl: releaseUrlSelf });
this.config.setContext({ isReleased: true, releaseUrl: releaseUrlSelf });
return true;
} catch (err) {
this.debug(err);
throw err;
}
}
async uploadAsset(filePath) {
const name = path.basename(filePath);
const { useIdsForUrls, useGenericPackageRepositoryForAssets, genericPackageRepositoryName } = this.options;
const { id, origin, repo, version, baseUrl } = this.getContext();
const endpoint = useGenericPackageRepositoryForAssets
? `projects/${id}/packages/generic/${genericPackageRepositoryName}/${version}/${name}`
: `projects/${id}/uploads`;
if (useGenericPackageRepositoryForAssets) {
const options = {
method: 'PUT',
body: await readFile(filePath)
};
try {
const body = await this.request(endpoint, options);
if (!(body.message && body.message == '201 Created')) {
throw new Error(`GitLab asset upload failed`);
}
this.log.verbose(`gitlab releases#uploadAsset: done (${endpoint})`);
this.assets.push({
name,
url: `${baseUrl}/${endpoint}`
});
} catch (err) {
this.debug(err);
throw err;
}
} else {
const body = new FormData();
const rawData = await readFile(filePath);
body.set('file', new Blob([rawData]), name);
const options = { body };
try {
const body = await this.request(endpoint, options);
this.log.verbose(`gitlab releases#uploadAsset: done (${body.url})`);
this.assets.push({
name,
url: useIdsForUrls ? `${origin}${body.full_path}` : `${origin}/${repo.repository}${body.url}`
});
} catch (err) {
this.debug(err);
throw err;
}
}
}
uploadAssets() {
const { assets } = this.options;
const { isDryRun } = this.config;
const context = this.config.getContext();
const patterns = castArray(assets).map(pattern => format(pattern, context));
this.log.exec('gitlab releases#uploadAssets', patterns, { isDryRun });
if (!assets) {
return noop;
}
return glob(patterns).then(files => {
if (!files.length) {
this.log.warn(`gitlab releases#uploadAssets: could not find "${assets}" relative to ${process.cwd()}`);
}
if (isDryRun) return Promise.resolve();
return Promise.all(files.map(filePath => this.uploadAsset(filePath)));
});
}
}
export default GitLab;