diff --git a/.commitlintrc.json b/.commitlintrc.json index 85b07ea..270351a 100644 --- a/.commitlintrc.json +++ b/.commitlintrc.json @@ -5,6 +5,7 @@ 2, "always", [ + "release", "build", "ci", "chore", diff --git a/package.json b/package.json index 6a44d6a..5a8d9a6 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "eslint-plugin-standard": "^4.0.1", "file-loader": "^6.0.0", "husky": "^4.2.5", + "inquirer": "^7.1.0", "node-sass": "^4.13.1", "npm-run-all": "^4.1.5", "react": "^16.13.1", diff --git a/scripts/publish-release.js b/scripts/publish-release.js index 387df68..a0d0457 100644 --- a/scripts/publish-release.js +++ b/scripts/publish-release.js @@ -6,14 +6,17 @@ require('colors').setTheme({ const fs = require("fs"); const path = require("path"); +const https = require('https'); const { Octokit } = require("@octokit/rest"); const execa = require('execa'); +const inquirer = require('inquirer'); const git = require('simple-git/promise')(); const package = require('../package.json'); require('dotenv').config(); -const commitMessage = `build: ${package.version}`; +const isDryRun = process.argv.indexOf('--dry-run') > 1; +const commitMessage = `release: ${package.version} :tada:`; const tagName = `v${package.version}`; const files = [ path.join(__dirname, '..', 'archives', `bbb-v${package.version}.tar.gz`), @@ -22,8 +25,14 @@ const files = [ path.join(__dirname, '..', 'archives', `bbb-v${package.version}.tar.gz.sig`), ]; +function pull() { + return git.pull('origin', 'master'); +} + async function notAlreadyTagged() { - return (await git.tags()).all.indexOf(tagName) < 0; + if ((await git.tags()).all.includes(tagName)) { + throw 'version already tagged'; + } } async function lastCommitNotBuild() { @@ -34,21 +43,118 @@ async function isMasterBranch() { return (await git.branch()) === 'master'; } +async function generateChangelog() { + const latestTag = (await git.tags()).latest; + const title = `v${package.version}` === latestTag ? '[Unreleased]' : `${package.version} (${new Date().toISOString().split('T')[0]})`; + + const logs = await git.log({ + from: latestTag, + to: 'HEAD' + }); + + const sections = [{ + type: 'feat', + label: 'Added', + }, { + type: 'fix', + label: 'Fixed', + }]; + + const entries = {}; + + logs.all.forEach(log => { + let [, type, scope, description] = log.message.match(/^([a-z]+)(?:\((\w+)\))?: (.+)/); + let entry = { type, scope, description, issues: [] }; + + for (let match of log.body.match(/(?:fix|fixes|closes?|refs?) #(\d+)/g)) { + const [, number] = match.match(/(\d+)$/); + + entry.issues.push(number); + } + + if (!entries[type]) { + entries[type] = [] + } + + entries[type].push(entry); + }); + + let changeLog = `## ${title}\n`; + + function stringifyEntry(entry) { + let issues = entry.issues.map(issue => { + return `[#${issue}](https://github.com/sualko/cloud_bbb/issues/${issue})`; + }).join(''); + return `- ${issues} ${entry.description}\n`; + } + + sections.forEach(section => { + if (!entries[section.type]) { + return; + } + + changeLog += `### ${section.label}\n`; + + entries[section.type].forEach(entry => { + changeLog += stringifyEntry(entry); + }); + + delete entries[section.type]; + + changeLog += `\n` + }); + + const miscKeys = Object.keys(entries); + + if (miscKeys && miscKeys.length > 0) { + changeLog += `### Misc\n`; + + miscKeys.forEach(type => { + entries[type].forEach(entry => { + changeLog += stringifyEntry(entry); + }); + }) + } + + return changeLog; +} + function hasChangeLogEntry() { return new Promise(resolve => { fs.readFile(path.join(__dirname, '..', 'CHANGELOG.md'), function (err, data) { if (err) throw err; - resolve(data.includes(`[${package.version}]`)); + if (!data.includes(`## ${package.version}`)) { + throw `Found no change log entry for ${package.version}`; + } + + resolve(); }); }); } +async function commitChangeLog() { + let status = await git.status(); + + if (status.staged.length > 0) { + throw 'Repo not clean. Found staged files.'; + } + + if (!isDryRun) { + await git.add('CHANGELOG.md'); + await git.commit('docs: update change log'); + } +} + async function hasArchiveAndSignatures() { return files.map(file => fs.existsSync(file)).indexOf(false) < 0; } async function stageAllFiles() { + if (isDryRun) { + return; + } + let gitProcess = execa('git', ['add', '-u']); gitProcess.stdout.pipe(process.stdout); @@ -65,29 +171,54 @@ function showStagedDiff() { } async function keypress() { - process.stdin.setRawMode(true); - - return new Promise(resolve => process.stdin.once('data', () => { - process.stdin.setRawMode(false) - resolve() - })); + return inquirer.prompt([{ + type: 'input', + name: 'keypress', + message: 'Press any key to continue... (where is the any key?)', + }]); } function commit() { + if (isDryRun) { + return; + } + return git.commit(commitMessage, ['-S', '-n']); } +async function wantToContinue(message) { + let answers = await inquirer.prompt([{ + type: 'confirm', + name: 'continue', + message, + default: false, + }]); + + if (!answers.continue) { + process.exit(10); + } +} + function push() { + if (isDryRun) { + return; + } + return git.push('origin', 'master'); } -async function createGithubRelease() { +async function createGithubRelease(changeLog) { + if (!process.env.GITHUB_TOKEN) { + throw 'Github token missing' + } + const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN, userAgent: 'custom releaser for sualko/cloud_bbb', }); - let matches = (await git.remote(['get-url', 'origin'])).match(/^git@github\.com:(.+)\/(.+)\.git$/); + let origin = (await git.remote(['get-url', 'origin'])).trim(); + let matches = origin.match(/^git@github\.com:(.+)\/(.+)\.git$/); if (!matches) { throw 'Origin is not configured or no ssh url'; @@ -95,33 +226,129 @@ async function createGithubRelease() { const owner = matches[1]; const repo = matches[2]; - - let releaseResponse = await octokit.repos.createRelease({ + const releaseOptions = { owner, repo, tag_name: tagName, name: tagName, - body: '', //@TODO + body: changeLog, draft: true, prerelease: !/^\d+\.\d+\.\d+$/.test(package.version), - }); + }; - console.log('Draft created, see ' + releaseResponse.data.html_url); + if (isDryRun) { + console.log('github release options', releaseOptions); + return []; + } + + let releaseResponse = await octokit.repos.createRelease(releaseOptions); + + console.log(`Draft created, see ${releaseResponse.data.html_url}`.verbose); + + function getMimeType(filename) { + if (filename.endsWith('.asc') || filename.endsWith('sig')) { + return 'application/pgp-signature'; + } + + if (filename.endsWith('.tar.gz')) { + return 'application/gzip'; + } + + if (filename.endsWith('.ncsig')) { + return 'text/plain'; + } + + return 'application/octet-stream'; + } + + let assetUrls = []; files.forEach(async file => { - let assetResponse = await octokit.repos.uploadReleaseAsset({ + const filename = path.basename(file); + const uploadOptions = { owner, repo, release_id: releaseResponse.data.id, data: fs.createReadStream(file), - name: path.basename(file), - }); + headers: { + 'content-type': getMimeType(filename), + 'content-length': fs.statSync(file)[size], + }, + name: filename, + }; - console.log('Asset uploaded: ' + assetResponse.data.name); - }) + let assetResponse = await octokit.repos.uploadReleaseAsset(uploadOptions); + + console.log(`Asset uploaded: ${assetResponse.data.name}`.verbose); + + assetUrls.push(assetResponse.data.browser_download_url); + }); + + return assetUrls; } -(async () => { +async function uploadToNextcloudStore(archiveUrl) { + if(!process.env.NEXTCLOUD_TOKEN) { + throw 'Nextcloud token missing'; + } + + const hostname = 'apps.nextcloud.com'; + const apiEndpoint = '/api/v1/apps/releases'; + const signatureFile = files.find(file => file.endsWith('.ncsig')); + const data = JSON.stringify({ + download: archiveUrl, + signature: fs.readFileSync(signatureFile, 'utf-8'), + nightly: false, + }); + const options = { + hostname, + port: 443, + path: apiEndpoint, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': data.length, + 'Authorization': `Token ${process.env.NEXTCLOUD_TOKEN}`, + } + }; + + if (isDryRun) { + console.log('nextcloud app store request', options, data); + return; + } + + return new Promise((resolve, reject) => { + const req = https.request(options, res => { + if (res.statusCode === 200) { + console.log('App release was updated successfully'.verbose); + resolve(); + } else if (res.statusCode === 201) { + console.log('App release was created successfully'.verbose); + resolve(); + } else if (res.statusCode === 400) { + reject('App release was not accepted'); + } else { + reject('App release rejected with status ' + res.statusCode); + } + + res.on('data', d => { + process.stdout.write(d) + }) + }) + + req.on('error', error => { + reject(error); + }); + + req.write(data); + req.end(); + }); +} + +async function run() { + await pull(); + console.log(`✔ pulled latest changes`.green); + await notAlreadyTagged(); console.log(`✔ not already tagged`.green); @@ -131,9 +358,19 @@ async function createGithubRelease() { await isMasterBranch(); console.log(`✔ this is the master branch`.green); + const changeLog = await generateChangelog(); + console.log(changeLog.verbose); + console.log(`✔ change log generated`.green); + + console.log('Press any key to continue...'); + await keypress(); + await hasChangeLogEntry(); console.log(`✔ there is a change log entry for this version`.green); + await commitChangeLog(); + console.log(`✔ change log commited`.green); + await hasArchiveAndSignatures(); console.log(`✔ found archive and signatures`.green); @@ -142,21 +379,29 @@ async function createGithubRelease() { await showStagedDiff(); - console.log('Press any key to continue...'); - await keypress(); + await wantToContinue('Should I commit those changes?'); await commit(); console.log(`✔ All files commited`.green); - console.log('Press any key to continue...'); - await keypress(); + await wantToContinue('Should I push all pending commits?'); await push(); console.log(`✔ All commits pushed`.green); - await createGithubRelease(); - console.log(`✔ released on github`.green); -})(); + await wantToContinue('Should I continue to create a Github release?'); -// create changelog -// upload nextcloud app store \ No newline at end of file + const assetUrls = await createGithubRelease(changeLog); + console.log(`✔ released on github`.green); + + const archiveAssetUrl = assetUrls.find(url => url.endsWith('.tar.gz')); + + await wantToContinue('Should I continue to upload the release to the app store?'); + + await uploadToNextcloudStore(archiveAssetUrl); + console.log(`✔ released in Nextcloud app store`.green); +}; + +run().catch(err => { + console.log(`✘ ${err.toString()}`.error); +}); diff --git a/yarn.lock b/yarn.lock index 446e14f..7b5c43a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4452,7 +4452,7 @@ ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== -inquirer@^7.0.0: +inquirer@^7.0.0, inquirer@^7.1.0: version "7.1.0" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.1.0.tgz#1298a01859883e17c7264b82870ae1034f92dd29" integrity sha512-5fJMWEmikSYu0nv/flMc475MhGbB7TSPd/2IpFV4I4rMklboCH2rQjYY5kKiYGHqUF9gvaambupcJFFG9dvReg==