277 lines
8.1 KiB
Plaintext
277 lines
8.1 KiB
Plaintext
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import got from 'got';
|
|
import { globby } from 'globby';
|
|
import { FormData, fileFromSync } from 'node-fetch';
|
|
import allSettled from 'promise.allsettled';
|
|
import _ from 'lodash';
|
|
import Release from '../GitRelease.js';
|
|
import { format, e } 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 { certificateAuthorityFile } = this.options;
|
|
this.certificateAuthorityOption = certificateAuthorityFile
|
|
? { https: { certificateAuthority: fs.readFileSync(certificateAuthorityFile) } }
|
|
: {};
|
|
}
|
|
|
|
get client() {
|
|
if (this._client) return this._client;
|
|
const { tokenHeader } = this.options;
|
|
const { baseUrl } = this.getContext();
|
|
this._client = got.extend({
|
|
prefixUrl: baseUrl,
|
|
method: 'POST',
|
|
headers: {
|
|
'user-agent': 'webpro/release-it',
|
|
[tokenHeader]: this.token
|
|
},
|
|
...this.certificateAuthorityOption
|
|
});
|
|
return this._client;
|
|
}
|
|
|
|
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 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 response = await this.client[method](endpoint, options);
|
|
const body = typeof response.body === 'string' ? JSON.parse(response.body) : response.body || {};
|
|
this.debug(body);
|
|
return body;
|
|
}
|
|
|
|
async createRelease() {
|
|
const { releaseName } = this.options;
|
|
const { tagName } = this.config.getContext();
|
|
const { id, releaseNotes, repo, origin } = this.getContext();
|
|
const { isDryRun } = this.config;
|
|
const name = format(releaseName, this.config.getContext());
|
|
const description = releaseNotes || '-';
|
|
const releaseUrl = `${origin}/${repo.repository}/-/releases`;
|
|
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,
|
|
tag_name: tagName,
|
|
description
|
|
}
|
|
};
|
|
|
|
if (this.assets.length) {
|
|
options.json.assets = {
|
|
links: this.assets
|
|
};
|
|
}
|
|
|
|
if (releaseMilestones.length) {
|
|
options.json.milestones = releaseMilestones;
|
|
}
|
|
|
|
try {
|
|
await this.request(endpoint, options);
|
|
this.log.verbose('gitlab releases#createRelease: done');
|
|
this.setContext({ isReleased: true, releaseUrl });
|
|
return true;
|
|
} catch (err) {
|
|
this.debug(err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async uploadAsset(filePath) {
|
|
const name = path.basename(filePath);
|
|
const { id, origin, repo } = this.getContext();
|
|
const endpoint = `projects/${id}/uploads`;
|
|
|
|
const body = new FormData();
|
|
body.set('file', fileFromSync(filePath));
|
|
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: `${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 globby(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;
|