| // Copyright 2022 The ChromiumOS Authors | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import * as fs from 'fs'; | |
| import * as path from 'path'; | |
| import * as semver from 'semver'; | |
| import * as commonUtil from '../src/common/common_util'; | |
| const USAGE = ` | |
| Usage: | |
| release.sh [command] [options] | |
| Commands: | |
| publish | |
| Builds and releases the extension. | |
| update | |
| Updates the version and commits the change for review. | |
| help | |
| Prints this message. | |
| Options: | |
| --pre-release | |
| Run the command for pre-release. | |
| --extra-message | |
| Additional text to be added to the version update commit message. | |
| This option is meaningful only for the update command. | |
| --remote-branch | |
| Specify the remote release branch such as refs/ide/0.4.0 . | |
| This option is meaningful only for the publish command. | |
| `; | |
| async function execute( | |
| name: string, | |
| args: string[], | |
| opts?: { | |
| logStdout?: boolean; | |
| cwd?: string; | |
| } | |
| ): Promise<string> { | |
| const res = await commonUtil.exec(name, args, { | |
| logger: new (class { | |
| append(s: string): void { | |
| process.stderr.write(s); | |
| } | |
| })(), | |
| logStdout: opts?.logStdout, | |
| cwd: opts?.cwd, | |
| }); | |
| if (res instanceof Error) { | |
| throw res; | |
| } | |
| return res.stdout; | |
| } | |
| async function currentVersion(): Promise<semver.SemVer> { | |
| const version = JSON.parse(fs.readFileSync('./package.json', 'utf8')).version; | |
| return new semver.SemVer(version); | |
| } | |
| /** | |
| * Verify that HEAD change is merged and it updated the version in package.json | |
| */ | |
| async function assertHeadUpdatesVersion(remoteBranch?: string) { | |
| let mergedRevision: string; | |
| if (remoteBranch) { | |
| await execute('git', [ | |
| 'fetch', | |
| 'https://chromium.googlesource.com/chromiumos/chromite', | |
| remoteBranch, | |
| ]); | |
| mergedRevision = 'FETCH_HEAD'; | |
| } else { | |
| mergedRevision = 'cros/main'; | |
| } | |
| // IDE_CROS_MAIN_FOR_TESTING substitutes the branch for manual testing. | |
| const revision = process.env.IDE_CROS_MAIN_FOR_TESTING || mergedRevision; | |
| try { | |
| // Assert HEAD is already merged, i.e. an ancestor a remote branch. | |
| await execute('git', ['merge-base', '--is-ancestor', 'HEAD', revision]); | |
| } catch (_e) { | |
| throw new Error(`HEAD should be an ancestor of ${revision}`); | |
| } | |
| // HEAD commit should update version in package.json . | |
| const diff = await execute('git', [ | |
| 'diff', | |
| '-p', | |
| 'HEAD~', | |
| '--', | |
| '**package.json', | |
| ]); | |
| if (!/^\+\s*"version"\s*:/m.test(diff)) { | |
| throw new Error('HEAD commit should update version in package.json'); | |
| } | |
| } | |
| /* | |
| * Asserts the working directory is clean. | |
| */ | |
| async function assertCleanGitStatus() { | |
| if (await execute('git', ['status', '--short'])) { | |
| throw new Error('dirty git status; run the command in clean environment'); | |
| } | |
| } | |
| /** | |
| * We use even minor version for release and odd minor version for pre-release following | |
| * https://code.visualstudio.com/api/working-with-extensions/publishing-extension#prerelease-extensions | |
| */ | |
| function expectedMinorVersionParity(preRelease: boolean) { | |
| return preRelease ? 1 : 0; | |
| } | |
| /** | |
| * Returns whether the version matches the expectation for the release type. | |
| */ | |
| function hasCorrectMinorVersion(version: semver.SemVer, preRelease: boolean) { | |
| const minorVersionParity = version.minor % 2; | |
| return minorVersionParity === expectedMinorVersionParity(preRelease); | |
| } | |
| type UpdateKind = 'minor' | 'patch'; | |
| function nextUpdateKind( | |
| current: semver.SemVer, | |
| preRelease: boolean | |
| ): UpdateKind { | |
| if (hasCorrectMinorVersion(current, preRelease)) { | |
| return 'patch'; | |
| } else { | |
| return 'minor'; | |
| } | |
| } | |
| async function bumpVersion(preRelease: boolean): Promise<semver.SemVer> { | |
| return new semver.SemVer( | |
| await execute('npm', [ | |
| 'version', | |
| nextUpdateKind(await currentVersion(), preRelease), | |
| ]) | |
| ); | |
| } | |
| const MONTH_NAMES = [ | |
| 'January', | |
| 'February', | |
| 'March', | |
| 'April', | |
| 'May', | |
| 'June', | |
| 'July', | |
| 'August', | |
| 'September', | |
| 'October', | |
| 'November', | |
| 'December', | |
| ]; | |
| async function updateChangelogForRelease(version: semver.SemVer) { | |
| const changeLogFile = './CHANGELOG.md'; | |
| const changeLog = (await fs.promises.readFile(changeLogFile, 'utf8')).split( | |
| '\n' | |
| ); | |
| const header = changeLog.slice(0, 2); | |
| const body = changeLog.slice(2); | |
| const now = new Date(); | |
| const month = MONTH_NAMES[now.getMonth()]; | |
| const releaseDate = `${month} ${now.getFullYear()}`; | |
| const insertion = `## ${version} (${releaseDate}) | |
| - FIXME: fill in the update | |
| `; | |
| console.log( | |
| `*** Please manually update ${changeLogFile} before submitting the change. ***` | |
| ); | |
| const updatedText = [...header, insertion, ...body].join('\n'); | |
| await fs.promises.writeFile(changeLogFile, updatedText, 'utf8'); | |
| } | |
| async function updateVersionAndCommit( | |
| preRelease: boolean, | |
| extraMessage: string | |
| ) { | |
| await assertCleanGitStatus(); | |
| const version = await bumpVersion(preRelease); | |
| if (!preRelease) { | |
| await updateChangelogForRelease(version); | |
| } | |
| // Prepend and append by a new line if given non-empty extra messages to | |
| // format it as a stand-alone paragraph. | |
| const additionalLines = | |
| (extraMessage.length > 0 ? '\n' : '') + | |
| extraMessage + | |
| (extraMessage.length > 0 ? '\n' : ''); | |
| const release = preRelease ? 'pre-release' : 'release'; | |
| const test = preRelease ? 'None' : 'Bugfest'; | |
| await execute('git', [ | |
| 'commit', | |
| '-a', | |
| '-m', | |
| `ide: Bump the version to ${version} for ${release} | |
| Commit generated by cros-ide/release.sh . | |
| ${additionalLines} | |
| BUG=b:246668828 | |
| TEST=${test} | |
| `, | |
| ]); | |
| } | |
| async function build(tempDir: string, preRelease: boolean): Promise<string> { | |
| const args = ['vsce', 'package', '-o', `${tempDir}/`]; | |
| if (preRelease) { | |
| args.push('--pre-release'); | |
| } | |
| await execute('npx', args); | |
| const localName: string = (await fs.promises.readdir(tempDir))[0]; | |
| return path.join(tempDir, localName); | |
| } | |
| async function buildAndUpload(preRelease: boolean, remoteBranch?: string) { | |
| if (!process.env.OVSX_PAT || !process.env.VSCE_PAT) { | |
| throw new Error('Set OVSX_PAT and VSCE_PAT: read go/chromiumide-release'); | |
| } | |
| await assertCleanGitStatus(); | |
| await assertHeadUpdatesVersion(remoteBranch); | |
| const version = await currentVersion(); | |
| if (!hasCorrectMinorVersion(version, preRelease)) { | |
| const expectation = | |
| expectedMinorVersionParity(preRelease) === 0 ? 'even' : 'odd'; | |
| throw new Error( | |
| `Bad version ${version}: minor version must be ${expectation}` | |
| ); | |
| } | |
| await commonUtil.withTempDir(async td => { | |
| const vsixFile = await build(td, preRelease); | |
| const fileName = path.basename(vsixFile); | |
| const ovsxArgs = ['ovsx', 'publish', vsixFile]; | |
| const vsceArgs = ['vsce', 'publish', '-i', vsixFile]; | |
| if (preRelease) { | |
| ovsxArgs.push('--pre-release'); | |
| vsceArgs.push('--pre-release'); | |
| } | |
| console.log(`Publishing ${fileName} to OpenVSX`); | |
| try { | |
| await execute('npx', ovsxArgs); | |
| } catch (e) { | |
| console.error(e); | |
| } | |
| console.log(`Publishing ${fileName} to MS Marketplace`); | |
| try { | |
| await execute('npx', vsceArgs); | |
| } catch (e) { | |
| console.error(e); | |
| } | |
| }); | |
| } | |
| type Command = 'publish' | 'update' | 'help'; | |
| const ALL_COMMANDS: Command[] = ['publish', 'update', 'help']; | |
| type Config = { | |
| command: Command; | |
| preRelease: boolean; | |
| // Name of the remote branch for patch release. e.g. refs/ide/0.4.0 | |
| remoteBranch?: string; | |
| // Additional message to be added to version update commit message. | |
| extraMessage?: string; | |
| }; | |
| /** | |
| * Parse args. | |
| * | |
| * @throws Error on invalid input | |
| */ | |
| export function parseArgs(args: string[]): Config { | |
| // Skip ts-node release.ts | |
| args = args.slice(2); | |
| const command = args.shift() as Command; | |
| if (!ALL_COMMANDS.includes(command)) { | |
| throw new Error(`Unknown command ${command}; see help`); | |
| } | |
| while (args.length > 0 && !args[0].startsWith('--')) { | |
| args.shift(); | |
| } | |
| let preRelease = false; | |
| let remoteBranch = undefined; | |
| let extraMessage = undefined; | |
| while (args.length > 0) { | |
| const flag = args.shift(); | |
| switch (flag) { | |
| case '--pre-release': | |
| preRelease = true; | |
| break; | |
| case '--remote-branch': | |
| remoteBranch = args.shift(); | |
| break; | |
| case '--extra-message': | |
| extraMessage = args.shift(); | |
| break; | |
| default: | |
| throw new Error(`Unknown flag ${flag}; see help`); | |
| } | |
| } | |
| return { | |
| command, | |
| preRelease, | |
| remoteBranch, | |
| extraMessage, | |
| }; | |
| } | |
| // TODO(oka): Refactor the module and add tests. | |
| async function main() { | |
| const config = parseArgs(process.argv); | |
| switch (config.command) { | |
| case 'help': | |
| console.log(USAGE); | |
| return; | |
| case 'publish': | |
| await buildAndUpload(config.preRelease, config.remoteBranch); | |
| return; | |
| case 'update': | |
| await updateVersionAndCommit( | |
| config.preRelease, | |
| config.extraMessage ?? '' | |
| ); | |
| return; | |
| } | |
| } | |
| if (require.main === module) { | |
| main().catch(e => { | |
| console.error(e); | |
| // eslint-disable-next-line no-process-exit | |
| process.exit(1); | |
| }); | |
| } |