/** * @license * Copyright 2019 Balena Ltd. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import * as Bluebird from 'bluebird'; import * as _ from 'lodash'; import * as semver from 'semver'; import { finalReleaseAssets, version } from './build-bin'; const { GITHUB_TOKEN } = process.env; /** * Create or update a release in GitHub's releases page, uploading the * installer files (standalone zip + native oclif installers). */ export async function createGitHubRelease() { console.log(`Publishing release ${version} to GitHub`); const publishRelease = await import('publish-release'); const ghRelease = await Bluebird.fromCallback( publishRelease.bind(null, { token: GITHUB_TOKEN || '', owner: 'balena-io', repo: 'balena-cli', tag: version, name: `balena-CLI ${version}`, reuseRelease: true, assets: finalReleaseAssets[process.platform], }), ); console.log(`Release ${version} successful: ${ghRelease.html_url}`); } /** * Top-level function to create a CLI release in GitHub's releases page: * call zipStandaloneInstaller(), rename the files as we'd like them to * display on the releases page, and call createGitHubRelease() to upload * the files. */ export async function release() { try { await createGitHubRelease(); } catch (err) { throw new Error(`Error creating GitHub release:\n${err}`); } } /** Return a cached Octokit instance, creating a new one as needed. */ const getOctokit = _.once(function () { const Octokit = ( require('@octokit/rest') as typeof import('@octokit/rest') ).Octokit.plugin( ( require('@octokit/plugin-throttling') as typeof import('@octokit/plugin-throttling') ).throttling, ); return new Octokit({ auth: GITHUB_TOKEN, throttle: { onRateLimit: (retryAfter: number, options: any) => { console.warn( `Request quota exhausted for request ${options.method} ${options.url}`, ); // retries 3 times if (options.request.retryCount < 3) { console.log(`Retrying after ${retryAfter} seconds!`); return true; } }, onAbuseLimit: (_retryAfter: number, options: any) => { // does not retry, only logs a warning console.warn( `Abuse detected for request ${options.method} ${options.url}`, ); }, }, }); }); /** * Extract pagination information (current page, total pages, ordinal number) * from the 'link' response header (example below), using the parse-link-header * npm package: * "link": "; rel=\"next\", * ; rel=\"last\"" * * @param response Octokit response object (including response.headers.link) * @param perPageDefault Default per_page pagination value if missing in URL * @return Object where 'page' is the current page number (1-based), * 'pages' is the total number of pages, and 'ordinal' is the ordinal number * (3rd, 4th, 5th...) of the first item in the current page. */ function getPageNumbers( response: any, perPageDefault: number, ): { page: number; pages: number; ordinal: number } { const res = { page: 1, pages: 1, ordinal: 1 }; if (!response.headers.link) { return res; } const parse = require('parse-link-header') as typeof import('parse-link-header'); const parsed = parse(response.headers.link); if (parsed == null) { throw new Error(`Failed to parse link header: '${response.headers.link}'`); } let perPage = perPageDefault; if (parsed.next) { if (parsed.next.per_page) { perPage = parseInt(parsed.next.per_page, 10); } res.page = parseInt(parsed.next.page, 10) - 1; res.pages = parseInt(parsed.last.page, 10); } else { if (parsed.prev.per_page) { perPage = parseInt(parsed.prev.per_page, 10); } res.page = res.pages = parseInt(parsed.prev.page, 10) + 1; } res.ordinal = (res.page - 1) * perPage + 1; return res; } /** * Iterate over every GitHub release in the given owner/repo, check whether * its tag_name matches against the affectedVersions semver spec, and if so * replace its release description (body) with the given newDescription value. * @param owner GitHub repo owner, e.g. 'balena-io' or 'pdcastro' * @param repo GitHub repo, e.g. 'balena-cli' * @param affectedVersions Semver spec, e.g. '2.6.1 - 7.10.9 || 8.0.0' * @param newDescription New release description (body) * @param editID Short string present in newDescription, e.g. '[AA101]', that * can be searched to determine whether that release has already been updated. */ async function updateGitHubReleaseDescriptions( owner: string, repo: string, affectedVersions: string, newDescription: string, editID: string, ) { const perPage = 30; const octokit = getOctokit(); const options = await octokit.repos.listReleases.endpoint.merge({ owner, repo, per_page: perPage, }); let errCount = 0; type Release = import('@octokit/rest').RestEndpointMethodTypes['repos']['listReleases']['response']['data'][0]; for await (const response of octokit.paginate.iterator(options)) { const { page: thisPage, pages: totalPages, ordinal, } = getPageNumbers(response, perPage); let i = 0; for (const cliRelease of response.data) { const prefix = `[#${ordinal + i++} pg ${thisPage}/${totalPages}]`; if (!cliRelease.id) { console.error( `${prefix} Error: missing release ID (errCount=${++errCount})`, ); continue; } const skipMsg = `${prefix} skipping release "${cliRelease.tag_name}" (${cliRelease.id})`; if (cliRelease.draft === true) { console.info(`${skipMsg}: draft release`); continue; } else if (cliRelease.body && cliRelease.body.includes(editID)) { console.info(`${skipMsg}: already updated`); continue; } else if (!semver.satisfies(cliRelease.tag_name, affectedVersions)) { console.info(`${skipMsg}: outside version range`); continue; } else { const updatedRelease = { owner, repo, release_id: cliRelease.id, body: newDescription, }; let oldBodyPreview = cliRelease.body; if (oldBodyPreview) { oldBodyPreview = oldBodyPreview.replace(/\s+/g, ' ').trim(); if (oldBodyPreview.length > 12) { oldBodyPreview = oldBodyPreview.substring(0, 9) + '...'; } } console.info( `${prefix} updating release "${cliRelease.tag_name}" (${cliRelease.id}) old body="${oldBodyPreview}"`, ); try { await octokit.repos.updateRelease(updatedRelease); } catch (err) { console.error( `${skipMsg}: Error: ${err.message} (count=${++errCount})`, ); continue; } } } } } /** * Add a warning description to CLI releases affected by a mixpanel tracking * security issue (#1359). This function can be executed "manually" with the * following command line: * * npx ts-node --type-check -P automation/tsconfig.json automation/run.ts fix1359 */ export async function updateDescriptionOfReleasesAffectedByIssue1359() { // Run only on Linux/Node10, instead of all platform/Node combinations. // (It could have been any other platform, as long as it only runs once.) if (process.platform !== 'linux' || semver.major(process.version) !== 10) { return; } const owner = 'balena-io'; const repo = 'balena-cli'; const affectedVersions = '2.6.1 - 7.10.9 || 8.0.0 - 8.1.0 || 9.0.0 - 9.15.6 || 10.0.0 - 10.17.5 || 11.0.0 - 11.7.2'; const editID = '[AA100]'; let newDescription = ` Please note: the "login" command in this release is affected by a security issue fixed in versions [7.10.10](https://github.com/balena-io/balena-cli/releases/tag/v7.10.10), [8.1.1](https://github.com/balena-io/balena-cli/releases/tag/v8.1.1), [9.15.7](https://github.com/balena-io/balena-cli/releases/tag/v9.15.7), [10.17.6](https://github.com/balena-io/balena-cli/releases/tag/v10.17.6), [11.7.3](https://github.com/balena-io/balena-cli/releases/tag/v11.7.3) and later. If you need to use this version, avoid passing your password, keys or tokens as command-line arguments. ${editID}`; // remove line breaks and collapse white space newDescription = newDescription.replace(/\s+/g, ' ').trim(); await updateGitHubReleaseDescriptions( owner, repo, affectedVersions, newDescription, editID, ); }