diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6add878688..c09777a380 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: # See https://github.com/facebook/jest/issues/11956 run: yarn test --coverage --logHeapUsage -w=4 --workerIdleMemoryLimit=1.5G + - name: Run deployment script tests + run: yarn test-deploy-prod + - name: Upload coverage to Codecov if: matrix.os == 'ubuntu-latest' uses: codecov/codecov-action@v5 diff --git a/bin/deploy-prod.mts b/bin/deploy-prod.mts new file mode 100755 index 0000000000..690dd3a24b --- /dev/null +++ b/bin/deploy-prod.mts @@ -0,0 +1,1638 @@ +#!/usr/bin/env node +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This script automates steps 1-4 of the production deployment workflow in +// docs-developer/deploying.md. Keep its behavior and prompts aligned with that +// document whenever the deployment process changes. + +import { spawnSync } from 'child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { basename, join } from 'path'; +import { createInterface } from 'readline/promises'; +import { fileURLToPath } from 'url'; + +type RunOptions = { + capture?: boolean; + allowFailure?: boolean; +}; + +type DeploymentOptions = { + dryRun: boolean; +}; + +type ParsedVersion = { + major: number; + minor: number; + patch: number; + prerelease: string[] | null; +}; + +type CliVersionPlan = { + defaultVersion: string; + shouldPublishCurrentVersion: boolean; +}; + +type CliDeploymentPlan = { + shouldPublishCli: boolean; + version: string; + plannedChange: string | null; +}; + +type L10nDeploymentPlan = { + plannedChange: string | null; + plannedLocalizers: string[]; +}; + +type GitHubRepository = { + owner: string; + name: string; +}; + +type PullRequestReference = { + number: number; + url: string; +}; + +type PullRequestListItem = PullRequestReference & { + headRepositoryOwner?: { login: string } | null; +}; + +type GitHubReview = { + author?: { login?: string } | null; + state: string; + submittedAt: string; + commit?: { oid: string } | null; +}; + +type PullRequestInfo = PullRequestReference & { + state: string; + reviewDecision: string; + reviewRequests: { login: string }[]; + reviews: GitHubReview[]; + headRefOid: string; + body: string; + title: string; +}; + +type MergeOptions = { + body?: string; + expectedHeadOid?: string; + subject?: string; +}; + +type CliPackage = { + path: string; + contents: { name: string; version: string }; +}; + +type DeploymentRemotes = { + upstream: string; + fork: string; +}; + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); +const repository = 'firefox-devtools/profiler'; +const reviewers = ['canova', 'mstange', 'fatadel']; +const pollIntervalMs = 10_000; +const versionPattern = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/; + +/** + * Run a command from the repository root. + */ +function run( + command: string, + args: string[], + options: RunOptions & { allowFailure: true } +): string | null; +function run(command: string, args: string[], options?: RunOptions): string; +function run( + command: string, + args: string[], + { capture = false, allowFailure = false }: RunOptions = {} +): string | null { + const commandLine = [command, ...args].join(' '); + console.log('[exec]', commandLine); + const result = spawnSync(command, args, { + cwd: repoRoot, + encoding: 'utf8', + stdio: capture ? 'pipe' : 'inherit', + }); + + if (result.error) { + if (allowFailure) { + return null; + } + throw result.error; + } + + if (result.status !== 0) { + if (allowFailure) { + return null; + } + + const details = capture ? `\n${result.stderr.trim()}` : ''; + throw new Error( + `'${commandLine}' exited with status ${result.status}.${details}` + ); + } + + return capture ? result.stdout.trim() : ''; +} + +/** + * Parse the deployment command-line arguments. + */ +export function parseArguments(args: string[]): DeploymentOptions { + const unknownArgument = args.find((argument) => argument !== '--dry-run'); + if (unknownArgument) { + throw new Error(`Unknown argument '${unknownArgument}'.`); + } + + return { dryRun: args.includes('--dry-run') }; +} + +/** + * Run a command that changes GitHub or npm, or print it in dry-run mode. + */ +export function runRemoteMutation( + dryRun: boolean, + command: string, + args: string[], + { capture = false }: { capture?: boolean } = {} +): string | null { + if (dryRun) { + console.log('[dry-run] Would run:', [command, ...args].join(' ')); + return null; + } + + return run(command, args, { capture }); +} + +/** + * Log a deployment action. + */ +function logAction(action: string): void { + console.log(`\n>>> ${action}`); +} + +/** + * Run a Git command. + */ +function git(...args: string[]): string { + return run('git', args); +} + +/** + * Run a Git command and return its output. + */ +function gitOutput(...args: string[]): string { + return run('git', args, { capture: true }); +} + +/** + * Run a GitHub CLI command and return its output. + */ +function ghOutput(...args: string[]): string { + return run('gh', [...args, '--repo', repository], { capture: true }); +} + +/** + * Run a GitHub CLI command that changes the profiler repository. + */ +function ghMutation(dryRun: boolean, ...args: string[]): string | null { + return runRemoteMutation(dryRun, 'gh', [...args, '--repo', repository]); +} + +/** + * Run a GitHub CLI command that changes the profiler repository and return its output. + */ +function ghMutationOutput(dryRun: boolean, ...args: string[]): string | null { + return runRemoteMutation(dryRun, 'gh', [...args, '--repo', repository], { + capture: true, + }); +} + +/** + * Ask the user for a value. + */ +async function ask(question: string, defaultValue = ''): Promise { + const prompt = defaultValue + ? `${question} [${defaultValue}] ` + : `${question} `; + const readline = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + try { + const answer = (await readline.question(prompt)).trim(); + return answer || defaultValue; + } finally { + readline.close(); + } +} + +/** + * Ask the user to confirm an action. + */ +async function confirm(action: string): Promise { + const answer = await ask(`${action}\nType "y" to continue:`); + if (answer.toLowerCase() !== 'y') { + throw new Error('Stopped without performing that action.'); + } +} + +/** + * Wait for a duration. + */ +function wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Format a date for a pull request title. + */ +function formatDate(date: Date = new Date()): string { + return new Intl.DateTimeFormat('en-US', { + month: 'long', + day: 'numeric', + year: 'numeric', + }).format(date); +} + +/** + * Parse a semantic version. + */ +function parseVersion(version: string): ParsedVersion | null { + const match = version.match(versionPattern); + + if (!match) { + return null; + } + + const prerelease = match[4]?.split('.') ?? null; + if ( + prerelease?.some( + (identifier) => + /^\d+$/.test(identifier) && + identifier.length > 1 && + identifier.startsWith('0') + ) + ) { + return null; + } + + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + prerelease, + }; +} + +/** + * Return the next minor version. + */ +export function bumpMinorVersion(version: string): string { + const parsed = parseVersion(version); + + if (!parsed) { + throw new Error(`Cannot calculate a minor bump for '${version}'.`); + } + + return `${parsed.major}.${parsed.minor + 1}.0`; +} + +/** + * Check whether a value is a semantic version. + */ +export function isValidVersion(version: string): boolean { + return parseVersion(version) !== null; +} + +/** + * Compare semantic-version prerelease identifiers. + */ +function comparePrerelease( + left: string[] | null, + right: string[] | null +): number { + if (left === null || right === null) { + if (left === right) { + return 0; + } + return left === null ? 1 : -1; + } + + for (let index = 0; index < Math.max(left.length, right.length); index++) { + const leftIdentifier = left[index]; + const rightIdentifier = right[index]; + if (leftIdentifier === undefined || rightIdentifier === undefined) { + if (leftIdentifier === rightIdentifier) { + return 0; + } + return leftIdentifier === undefined ? -1 : 1; + } + if (leftIdentifier === rightIdentifier) { + continue; + } + + const leftIsNumeric = /^\d+$/.test(leftIdentifier); + const rightIsNumeric = /^\d+$/.test(rightIdentifier); + if (leftIsNumeric && rightIsNumeric) { + return Number(leftIdentifier) - Number(rightIdentifier); + } + if (leftIsNumeric !== rightIsNumeric) { + return leftIsNumeric ? -1 : 1; + } + return leftIdentifier.localeCompare(rightIdentifier); + } + + return 0; +} + +/** + * Check whether a semantic version is newer than another. + */ +export function isVersionGreater( + currentVersion: string, + nextVersion: string +): boolean { + const current = parseVersion(currentVersion); + const next = parseVersion(nextVersion); + if (!current || !next) { + return false; + } + + const numericParts: Array<'major' | 'minor' | 'patch'> = [ + 'major', + 'minor', + 'patch', + ]; + for (const part of numericParts) { + if (current[part] !== next[part]) { + return next[part] > current[part]; + } + } + + return comparePrerelease(next.prerelease, current.prerelease) > 0; +} + +/** + * Determine the default CLI version and whether it needs publication. + */ +export function getCliVersionPlan( + mainVersion: string, + productionVersion: string +): CliVersionPlan { + if (!isValidVersion(mainVersion) || !isValidVersion(productionVersion)) { + throw new Error('The main and production CLI versions must be valid.'); + } + if (mainVersion === productionVersion) { + return { + defaultVersion: bumpMinorVersion(mainVersion), + shouldPublishCurrentVersion: false, + }; + } + if (isVersionGreater(productionVersion, mainVersion)) { + return { + defaultVersion: mainVersion, + shouldPublishCurrentVersion: true, + }; + } + throw new Error( + `The main CLI version ${mainVersion} is older than production ${productionVersion}.` + ); +} + +/** + * Parse a GitHub repository remote URL. + */ +export function parseGitHubRepositoryUrl(url: string): GitHubRepository | null { + const match = url + .replace(/\/$/, '') + .match( + /^(?:git@github\.com:|https:\/\/github\.com\/|ssh:\/\/git@github\.com\/)([^/]+)\/([^/]+)$/i + ); + + if (!match) { + return null; + } + + return { + owner: match[1], + name: match[2].replace(/\.git$/i, ''), + }; +} + +/** + * Find a remote by its GitHub repository and URL direction. + */ +export function findGitHubRemote( + remoteList: string, + expectedRepository: string, + direction: 'fetch' | 'push' +): string | null { + for (const line of remoteList.split('\n')) { + const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/); + if (!match || match[3] !== direction) { + continue; + } + + const parsedUrl = parseGitHubRepositoryUrl(match[2]); + if ( + parsedUrl && + `${parsedUrl.owner}/${parsedUrl.name}`.toLowerCase() === + expectedRepository.toLowerCase() + ) { + return match[1]; + } + } + + return null; +} + +/** + * Collect locale names from changed locale paths. + */ +export function collectUpdatedLocales(changedFiles: string): string[] { + return [ + ...new Set( + changedFiles + .split('\n') + .filter(Boolean) + .map((path) => path.split('/')[1]) + .filter(Boolean) + ), + ].sort((a, b) => a.localeCompare(b)); +} + +/** + * Collect localizer credits from Co-authored-by trailers. + */ +export function collectLocalizerCredits(trailers: string): string[] { + const credits = new Set(); + + for (const trailer of trailers.split('\n')) { + const withoutEmail = trailer.replace(/\s*<[^>]+>\s*/g, ' ').trim(); + const match = withoutEmail.match(/^(.*?)\s+\(([^()]+)\)$/); + + if (match) { + const name = match[1] + .replace(/\s*\[[^\]]*\]\s*/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + credits.add(`${match[2]}: ${name}`); + } + } + + return [...credits].sort((a, b) => a.localeCompare(b)); +} + +/** + * Build the production pull request body. + */ +export function buildDeploymentBody( + changes: string, + localizers: string[] +): string { + return [ + 'Changes:', + '', + changes.trim(), + '', + 'And special thanks to our localizers:', + '', + localizers.join('\n'), + '', + ].join('\n'); +} + +/** + * Include changes that dry-run mode simulates without creating commits. + */ +export function combineDeploymentChanges( + committedChanges: string, + plannedChanges: string[] +): string { + return [committedChanges.trim(), ...plannedChanges] + .filter(Boolean) + .join('\n'); +} + +/** + * Select reviewers for the authenticated user. + */ +export function reviewersFor(login: string): string[] { + const normalizedLogin = login.toLowerCase(); + return reviewers.includes(normalizedLogin) + ? reviewers.filter((reviewer) => reviewer !== normalizedLogin) + : [...reviewers]; +} + +/** + * Ensure the working copy is clean. + */ +function ensureCleanWorkingCopy(): void { + logAction('Checking for a clean working copy.'); + const status = gitOutput( + 'status', + '--porcelain', + '--ignore-submodules', + '-unormal' + ); + + if (status) { + throw new Error( + `The working copy must be clean before deployment:\n\n${status}` + ); + } +} + +/** + * Ensure the GitHub CLI is available and return the authenticated login. + */ +function ensureGhAvailableAndLoggedIn(): string { + logAction('Checking GitHub CLI availability and authentication.'); + if ( + run('gh', ['--version'], { capture: true, allowFailure: true }) === null + ) { + throw new Error("The GitHub CLI 'gh' is not installed or is not on PATH."); + } + + if ( + run('gh', ['auth', 'status', '--hostname', 'github.com'], { + allowFailure: true, + }) === null + ) { + throw new Error("Run 'gh auth login' before starting a deployment."); + } + + return run('gh', ['api', 'user', '--jq', '.login'], { capture: true }); +} + +/** + * Discover the canonical and fork remotes. + */ +function discoverDeploymentRemotes(login: string): DeploymentRemotes { + logAction('Finding the canonical and fork Git remotes.'); + const remoteList = gitOutput('remote', '-v'); + const upstream = findGitHubRemote(remoteList, repository, 'fetch'); + + if (!upstream) { + throw new Error( + 'Could not find a Git remote whose fetch URL points to firefox-devtools/profiler.' + ); + } + + const fork = findGitHubRemote(remoteList, `${login}/profiler`, 'push'); + + if (!fork) { + throw new Error( + `Could not find a Git remote whose push URL points to ${login}/profiler.` + ); + } + + console.log(`Using '${upstream}' as the canonical Git remote.`); + console.log(`Using '${fork}' as the fork Git remote.`); + return { upstream, fork }; +} + +/** + * Refresh the deployment branches and update the local main branch when live. + */ +function updateMain(upstream: string, dryRun: boolean): void { + logAction(`Fetching deployment branches from '${upstream}'.`); + git('fetch', upstream, 'main', 'l10n', 'production'); + if (dryRun) { + console.log( + '[dry-run] Leaving the current branch and working tree unchanged.' + ); + return; + } + + logAction(`Updating the local main branch from '${upstream}'.`); + git('checkout', 'main'); + git('pull', '--ff-only', upstream, 'main'); +} + +/** + * Find an open pull request by base and head. + */ +function getOpenPullRequest( + base: string, + head: string, + headOwner: string | null = null +): PullRequestReference | null { + const result = JSON.parse( + ghOutput( + 'pr', + 'list', + '--state', + 'open', + '--base', + base, + '--head', + head, + '--json', + 'number,url,title,body,headRefOid,headRepositoryOwner' + ) + ) as PullRequestListItem[]; + + return ( + result.find( + (pullRequest) => + headOwner === null || + pullRequest.headRepositoryOwner?.login.toLowerCase() === + headOwner.toLowerCase() + ) ?? null + ); +} + +/** + * Read the current state of a pull request. + */ +function getPullRequest(number: number): PullRequestInfo { + return JSON.parse( + ghOutput( + 'pr', + 'view', + String(number), + '--json', + 'number,url,state,reviewDecision,reviewRequests,reviews,headRefOid,body,title' + ) + ) as PullRequestInfo; +} + +/** + * Create a pull request reference from its URL. + */ +function pullRequestFromUrl(url: string): PullRequestReference { + const number = Number(new URL(url).pathname.split('/').at(-1)); + if (!Number.isInteger(number)) { + throw new Error(`Could not determine a pull request number from ${url}.`); + } + return { number, url }; +} + +/** + * Create a pull request after confirmation, or print the action in dry-run mode. + */ +async function createPullRequest( + { + base, + head, + title, + body, + }: { base: string; head: string; title: string; body: string }, + dryRun: boolean +): Promise { + if (!dryRun) { + await confirm( + `Create pull request '${title}' from '${head}' into '${base}'?` + ); + } + const url = ghMutationOutput( + dryRun, + 'pr', + 'create', + '--base', + base, + '--head', + head, + '--title', + title, + '--body', + body + ); + if (url === null) { + return null; + } + console.log(`Created ${url}`); + return pullRequestFromUrl(url); +} + +/** + * Print the GitHub actions that would follow creation of a pull request. + */ +function previewNewPullRequestCompletion( + pullRequest: string, + requestedReviewers: string[], + method: 'merge' | 'squash' +): void { + console.log( + `[dry-run] Would request review from ${requestedReviewers.join(', ')} on ${pullRequest}.` + ); + console.log( + `[dry-run] Would wait for approval and required checks on ${pullRequest}.` + ); + console.log( + `[dry-run] Would merge ${pullRequest} using the ${method} method.` + ); +} + +/** + * Find each reviewer's latest non-comment review. + */ +function latestReviewByAuthor( + reviewsForPullRequest: GitHubReview[] +): Map { + const latest = new Map(); + const sortedReviews = [...reviewsForPullRequest].sort((a, b) => + a.submittedAt.localeCompare(b.submittedAt) + ); + + for (const review of sortedReviews) { + if (review.author?.login && review.state !== 'COMMENTED') { + latest.set(review.author.login.toLowerCase(), review); + } + } + + return latest; +} + +/** + * Check whether a review approves the current pull request head. + */ +function isCurrentApproval( + review: GitHubReview | undefined, + headRefOid: string +): boolean { + return review?.state === 'APPROVED' && review.commit?.oid === headRefOid; +} + +/** + * Check whether a requested reviewer approved the current head. + */ +function hasRequiredApproval( + info: PullRequestInfo, + requestedReviewers: string[] +): boolean { + const latestReviews = latestReviewByAuthor(info.reviews); + return ( + info.reviewDecision === 'APPROVED' && + requestedReviewers.some((reviewer) => + isCurrentApproval(latestReviews.get(reviewer), info.headRefOid) + ) + ); +} + +/** + * Request reviews that are not already assigned or complete. + */ +async function requestMissingReviewers( + pullRequest: PullRequestReference, + requestedReviewers: string[], + dryRun: boolean +): Promise { + logAction(`Checking reviewers for PR #${pullRequest.number}.`); + const info = getPullRequest(pullRequest.number); + const currentRequests = new Set( + info.reviewRequests.map((request) => request.login.toLowerCase()) + ); + const latestReviews = latestReviewByAuthor(info.reviews); + const missing = requestedReviewers.filter((reviewer) => { + const latestReview = latestReviews.get(reviewer); + return ( + !currentRequests.has(reviewer) && + !isCurrentApproval(latestReview, info.headRefOid) + ); + }); + + if (missing.length === 0) { + console.log('The requested reviewers are already assigned or approved.'); + return; + } + + if (!dryRun) { + await confirm( + `Request review from ${missing.join(', ')} on PR #${pullRequest.number}?` + ); + } + ghMutation( + dryRun, + 'pr', + 'edit', + String(pullRequest.number), + '--add-reviewer', + missing.join(',') + ); +} + +/** + * Wait for an approval of the current pull request head. + */ +async function waitForApproval( + pullRequest: PullRequestReference, + requestedReviewers: string[] +): Promise { + console.log( + `Waiting for a current-head approval from ${requestedReviewers.join(', ')} on ${pullRequest.url}.` + ); + let lastHead: string | null = null; + + for (;;) { + const info = getPullRequest(pullRequest.number); + + if (info.state !== 'OPEN') { + throw new Error(`PR #${pullRequest.number} is no longer open.`); + } + + if (info.headRefOid !== lastHead) { + console.log(`Current PR head: ${info.headRefOid}`); + lastHead = info.headRefOid; + } + + if (hasRequiredApproval(info, requestedReviewers)) { + console.log(`PR #${pullRequest.number} has the required approval.`); + return info; + } + + await wait(pollIntervalMs); + } +} + +/** + * Wait until approvals and checks apply to the same head. + */ +async function waitUntilReady( + pullRequest: PullRequestReference, + requestedReviewers: string[] +): Promise { + for (;;) { + const approvedInfo = await waitForApproval(pullRequest, requestedReviewers); + const checkedInfo = await waitForChecks(pullRequest); + const currentInfo = getPullRequest(pullRequest.number); + + if ( + approvedInfo.headRefOid === checkedInfo.headRefOid && + checkedInfo.headRefOid === currentInfo.headRefOid && + hasRequiredApproval(currentInfo, requestedReviewers) + ) { + return currentInfo; + } + + console.log( + `PR #${pullRequest.number} changed while waiting. Checking its new head again.` + ); + } +} + +/** + * Build the arguments for watching pull request checks. + */ +export function checkWaitArguments( + pullRequestNumber: number, + required: boolean +): string[] { + const args = ['pr', 'checks', String(pullRequestNumber), '--watch']; + + if (required) { + args.push('--fail-fast', '--required'); + } + + return args; +} + +/** + * Check whether GitHub has not reported pull request checks yet. + */ +export function checksHaveNotStarted(stderr: string): boolean { + return /^no (?:required )?checks reported on /m.test(stderr); +} + +/** + * Run the GitHub check watcher while retaining its error message. + */ +function runCheckWatcher(args: string[]): { + status: number | null; + stderr: string; +} { + const commandArgs = [...args, '--repo', repository]; + console.log('[exec]', ['gh', ...commandArgs].join(' ')); + const result = spawnSync('gh', commandArgs, { + cwd: repoRoot, + encoding: 'utf8', + stdio: ['inherit', 'inherit', 'pipe'], + }); + + if (result.error) { + throw result.error; + } + + const stderr = result.stderr.trim(); + if (stderr) { + console.error(stderr); + } + return { status: result.status, stderr }; +} + +/** + * Wait for pull request checks to finish. + */ +async function waitForChecks( + pullRequest: PullRequestReference, + { required = true }: { required?: boolean } = {} +): Promise { + console.log( + `Waiting for ${required ? 'required ' : ''}checks on ${pullRequest.url}.` + ); + + const args = checkWaitArguments(pullRequest.number, required); + for (;;) { + const { status, stderr } = runCheckWatcher(args); + if (status === 0) { + break; + } + if (checksHaveNotStarted(stderr)) { + console.log( + `No checks are reported on PR #${pullRequest.number} yet. Waiting for them to start.` + ); + await wait(pollIntervalMs); + continue; + } + + if (required) { + throw new Error( + `'gh ${args.join(' ')} --repo ${repository}' exited with status ${status}.` + ); + } + await confirm( + `One or more non-required checks failed on PR #${pullRequest.number}. Continue anyway?` + ); + break; + } + + console.log(`Checks finished on PR #${pullRequest.number}.`); + return getPullRequest(pullRequest.number); +} + +/** + * Merge a pull request and wait for completion, or print the action in dry-run mode. + */ +async function mergePullRequest( + pullRequest: PullRequestReference, + method: 'merge' | 'squash', + { body, expectedHeadOid, subject }: MergeOptions = {}, + dryRun = false +): Promise { + logAction(`Preparing to merge PR #${pullRequest.number}.`); + const info = getPullRequest(pullRequest.number); + if (expectedHeadOid && info.headRefOid !== expectedHeadOid) { + throw new Error( + `PR #${pullRequest.number} changed from ${expectedHeadOid} to ${info.headRefOid}.` + ); + } + if (!dryRun) { + await confirm( + `Merge PR #${pullRequest.number} using the ${method} method at ${info.headRefOid}?` + ); + } + const args = [ + 'pr', + 'merge', + String(pullRequest.number), + `--${method}`, + '--match-head-commit', + info.headRefOid, + ]; + + if (subject) { + args.push('--subject', subject); + } + if (body !== undefined) { + args.push('--body', body); + } + + ghMutation(dryRun, ...args); + if (dryRun) { + return info; + } + console.log(`Waiting for PR #${pullRequest.number} to be merged.`); + + for (;;) { + const mergedInfo = getPullRequest(pullRequest.number); + if (mergedInfo.headRefOid !== info.headRefOid) { + throw new Error( + `PR #${pullRequest.number} changed from ${info.headRefOid} to ${mergedInfo.headRefOid}.` + ); + } + if (mergedInfo.state === 'MERGED') { + console.log(`PR #${pullRequest.number} was merged.`); + return mergedInfo; + } + if (mergedInfo.state !== 'OPEN') { + throw new Error( + `PR #${pullRequest.number} was ${mergedInfo.state.toLowerCase()} without being merged.` + ); + } + await wait(pollIntervalMs); + } +} + +/** + * Create, wait for approval, and merge the localization pull request when needed. + */ +async function handleL10nPullRequest( + login: string, + upstream: string, + dryRun: boolean +): Promise { + logAction('Checking for localization updates.'); + const changedFiles = gitOutput( + 'diff', + '--name-only', + `${upstream}/main...${upstream}/l10n` + ); + const locales = collectUpdatedLocales(changedFiles); + + if (locales.length === 0) { + console.log('No updated locales were found. Skipping the l10n PR.'); + return { plannedChange: null, plannedLocalizers: [] }; + } + + const title = `🔃 Sync: l10n -> main (${formatDate()})`; + const plannedLocalizers = dryRun + ? collectLocalizerCredits( + gitOutput( + 'log', + `${upstream}/main..${upstream}/l10n`, + '--grep', + '^Pontoon', + '--format=%(trailers:key=Co-authored-by,valueonly)' + ) + ) + : []; + const plan = { + plannedChange: dryRun ? `[${login}] ${title}` : null, + plannedLocalizers, + }; + console.log(`Updated locales: ${locales.join(', ')}`); + let pullRequest = getOpenPullRequest('main', 'l10n'); + + if (pullRequest) { + console.log(`Using existing l10n PR: ${pullRequest.url}`); + } else { + pullRequest = await createPullRequest( + { + base: 'main', + head: 'l10n', + title, + body: `Updated locales: ${locales.join(', ')}.`, + }, + dryRun + ); + } + + const requestedReviewers = reviewersFor(login); + if (pullRequest === null) { + previewNewPullRequestCompletion( + 'the new localization PR', + requestedReviewers, + 'merge' + ); + return plan; + } + + await requestMissingReviewers(pullRequest, requestedReviewers, dryRun); + if (dryRun) { + console.log( + `[dry-run] Would wait for approval and required checks on PR #${pullRequest.number}.` + ); + await mergePullRequest(pullRequest, 'merge', {}, true); + return plan; + } + + const readyInfo = await waitUntilReady(pullRequest, requestedReviewers); + await mergePullRequest(pullRequest, 'merge', { + expectedHeadOid: readyInfo.headRefOid, + }); + logAction('Updating the local main branch after the localization merge.'); + git('pull', '--ff-only', upstream, 'main'); + return plan; +} + +/** + * Read the profiler-cli package manifest. + */ +function readCliPackage(): CliPackage { + const path = join(repoRoot, 'profiler-cli', 'package.json'); + const contents = JSON.parse( + readFileSync(path, 'utf8') + ) as CliPackage['contents']; + return { path, contents }; +} + +/** + * Check whether a local branch exists. + */ +function localBranchExists(branch: string): boolean { + return ( + run('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { + allowFailure: true, + }) !== null + ); +} + +/** + * Check whether a remote-tracking branch exists. + */ +function remoteBranchExists(remote: string, branch: string): boolean { + return ( + run( + 'git', + ['show-ref', '--verify', '--quiet', `refs/remotes/${remote}/${branch}`], + { allowFailure: true } + ) !== null + ); +} + +/** + * Read the profiler-cli version at a Git ref. + */ +function cliVersionAtRef(ref: string): string { + const contents = JSON.parse( + gitOutput('show', `${ref}:profiler-cli/package.json`) + ) as CliPackage['contents']; + return contents.version; +} + +/** + * Create or resume the profiler-cli version pull request. + */ +async function createVersionBumpPullRequest( + version: string, + forkOwner: string, + forkRemote: string, + dryRun: boolean +): Promise { + const branch = `bump-cli-${version}`; + logAction(`Preparing the profiler-cli version PR for ${version}.`); + let pullRequest = getOpenPullRequest('main', branch, forkOwner); + + if (pullRequest) { + console.log(`Using existing CLI version PR: ${pullRequest.url}`); + return pullRequest; + } + + git('fetch', forkRemote); + const hasLocalBranch = localBranchExists(branch); + const hasRemoteBranch = remoteBranchExists(forkRemote, branch); + + if (dryRun && !hasLocalBranch && !hasRemoteBranch) { + console.log( + `[dry-run] Would create local branch '${branch}', update profiler-cli/package.json, and commit the version bump.` + ); + } else if (hasLocalBranch) { + console.log(`Using existing local branch '${branch}'.`); + if (!dryRun) { + git('checkout', branch); + } + if (cliVersionAtRef(branch) !== version) { + throw new Error( + `Local branch '${branch}' does not contain profiler-cli version ${version}.` + ); + } + + if ( + hasRemoteBranch && + gitOutput('rev-parse', branch) !== + gitOutput('rev-parse', `${forkRemote}/${branch}`) + ) { + throw new Error( + `Local and remote '${branch}' branches point to different commits.` + ); + } + } else if (hasRemoteBranch) { + console.log(`Using existing remote branch '${forkRemote}/${branch}'.`); + if (cliVersionAtRef(`${forkRemote}/${branch}`) !== version) { + throw new Error( + `Remote branch '${branch}' does not contain profiler-cli version ${version}.` + ); + } + } else { + console.log(`Creating local branch '${branch}'.`); + git('checkout', '-b', branch, 'main'); + const { path, contents } = readCliPackage(); + contents.version = version; + writeFileSync(path, `${JSON.stringify(contents, null, 2)}\n`); + git('diff', '--', path); + await confirm(`Commit the profiler-cli version bump to ${version}?`); + git('add', path); + git('commit', '-m', `Bump profiler-cli version to ${version}`); + } + + if (!hasRemoteBranch) { + if (!dryRun) { + await confirm(`Push branch '${branch}' to '${forkRemote}'?`); + } + runRemoteMutation(dryRun, 'git', [ + 'push', + '--set-upstream', + forkRemote, + branch, + ]); + } + + pullRequest = await createPullRequest( + { + base: 'main', + head: `${forkOwner}:${branch}`, + title: `Bump profiler-cli version to ${version}`, + body: '', + }, + dryRun + ); + return pullRequest; +} + +/** + * Select the profiler-cli version and merge its pull request when needed. + */ +async function handleVersionBumpPullRequest( + login: string, + upstream: string, + forkRemote: string, + dryRun: boolean +): Promise { + logAction('Checking the profiler-cli version for this deployment.'); + const mainVersion = cliVersionAtRef(`${upstream}/main`); + const productionVersion = cliVersionAtRef(`${upstream}/production`); + const { defaultVersion, shouldPublishCurrentVersion } = getCliVersionPlan( + mainVersion, + productionVersion + ); + const version = await ask( + `Profiler CLI version (main ${mainVersion}, production ${productionVersion}):`, + defaultVersion + ); + + if (!isValidVersion(version)) { + throw new Error(`'${version}' is not a valid semantic version.`); + } + if (version === mainVersion) { + console.log(`Keeping the existing profiler-cli version ${version}.`); + return { + shouldPublishCli: shouldPublishCurrentVersion, + version, + plannedChange: null, + }; + } + if (!isVersionGreater(mainVersion, version)) { + throw new Error( + `Profiler CLI version ${version} must be newer than ${mainVersion}.` + ); + } + + const pullRequest = await createVersionBumpPullRequest( + version, + login, + forkRemote, + dryRun + ); + const requestedReviewers = reviewersFor(login); + const plan = { + shouldPublishCli: true, + version, + plannedChange: dryRun + ? `[${login}] Bump profiler-cli version to ${version}` + : null, + }; + if (pullRequest === null) { + previewNewPullRequestCompletion( + 'the new profiler-cli version PR', + requestedReviewers, + 'squash' + ); + return plan; + } + + await requestMissingReviewers(pullRequest, requestedReviewers, dryRun); + if (dryRun) { + console.log( + `[dry-run] Would wait for approval and required checks on PR #${pullRequest.number}.` + ); + await mergePullRequest(pullRequest, 'squash', {}, true); + return plan; + } + + const readyInfo = await waitUntilReady(pullRequest, requestedReviewers); + await mergePullRequest(pullRequest, 'squash', { + expectedHeadOid: readyInfo.headRefOid, + }); + logAction('Updating the local main branch after the profiler-cli merge.'); + git('checkout', 'main'); + git('pull', '--ff-only', upstream, 'main'); + return plan; +} + +/** + * Run an action with a temporary file and remove it afterwards. + */ +function withTemporaryFile( + prefix: string, + filename: string, + contents: string, + action: (path: string) => T +): T { + const directory = mkdtempSync(join(tmpdir(), prefix)); + const path = join(directory, filename); + writeFileSync(path, contents); + + try { + return action(path); + } finally { + if (basename(directory).startsWith(prefix)) { + rmSync(directory, { recursive: true, force: true }); + } + } +} + +/** + * Open the deployment pull request body in the configured Git editor. + */ +function editDeploymentBody(initialBody: string): string { + return withTemporaryFile( + 'profiler-deploy-', + 'pull-request-body.md', + initialBody, + (path) => { + const command = `${gitOutput('var', 'GIT_EDITOR')} "${path.replaceAll('"', '\\"')}"`; + console.log('[exec]', command); + const result = spawnSync(command, { + cwd: repoRoot, + shell: true, + stdio: 'inherit', + }); + + if (result.error || result.status !== 0) { + throw ( + result.error ?? new Error(`The editor exited with ${result.status}.`) + ); + } + + const body = readFileSync(path, 'utf8').trim(); + if (!body) { + throw new Error('The deployment PR body cannot be empty.'); + } + return `${body}\n`; + } + ); +} + +/** + * Create or update and merge the production deployment pull request. + */ +async function handleDeploymentPullRequest( + upstream: string, + dryRun: boolean, + plannedChanges: string[], + plannedLocalizers: string[] +): Promise { + logAction('Preparing the production deployment PR.'); + updateMain(upstream, dryRun); + const range = `${upstream}/production..${upstream}/main`; + const changes = combineDeploymentChanges( + gitOutput( + 'log', + range, + '--first-parent', + '--oneline', + '--no-decorate', + '--format=format:[%an] %s', + '--reverse' + ), + plannedChanges + ); + + if (!changes) { + throw new Error('There are no changes to deploy to production.'); + } + + const localizerTrailers = gitOutput( + 'log', + range, + '--grep', + '^Pontoon', + '--format=%(trailers:key=Co-authored-by,valueonly)' + ); + const localizers = [ + ...new Set([ + ...collectLocalizerCredits(localizerTrailers), + ...plannedLocalizers, + ]), + ].sort((a, b) => a.localeCompare(b)); + const generatedBody = buildDeploymentBody(changes, localizers); + console.log('\nOpening the generated deployment PR body in your editor.'); + const body = editDeploymentBody(generatedBody); + const title = `Deploy ${formatDate()}`; + let pullRequest = getOpenPullRequest('production', 'main'); + + if (pullRequest) { + console.log(`An open deployment PR already exists: ${pullRequest.url}`); + if (!dryRun) { + await confirm(`Replace the title and body of PR #${pullRequest.number}?`); + } + const pullRequestNumber = pullRequest.number; + withTemporaryFile( + 'profiler-deploy-body-', + 'pull-request-body.md', + body, + (path) => { + ghMutation( + dryRun, + 'pr', + 'edit', + String(pullRequestNumber), + '--title', + title, + '--body-file', + path + ); + } + ); + if (!dryRun) { + pullRequest = getPullRequest(pullRequestNumber); + } + } else { + pullRequest = await createPullRequest( + { + base: 'production', + head: 'main', + title, + body, + }, + dryRun + ); + } + + if (pullRequest === null) { + console.log('[dry-run] Would wait for checks on the new production PR.'); + console.log( + '[dry-run] Would merge the new production PR using the merge method and its body as the commit message.' + ); + return gitOutput('rev-parse', `${upstream}/main`); + } + + const originalHead = getPullRequest(pullRequest.number).headRefOid; + if (dryRun) { + console.log( + `[dry-run] Would wait for checks on PR #${pullRequest.number}.` + ); + await mergePullRequest( + pullRequest, + 'merge', + { + subject: `${title} (#${pullRequest.number})`, + body, + expectedHeadOid: originalHead, + }, + true + ); + return originalHead; + } + + const finalInfo = await waitForChecks(pullRequest, { required: false }); + if (finalInfo.headRefOid !== originalHead) { + throw new Error( + `The deployment candidate changed from ${originalHead} to ${finalInfo.headRefOid}. ` + + 'Run the script again to regenerate and confirm the deployment notes.' + ); + } + + await mergePullRequest(pullRequest, 'merge', { + subject: `${title} (#${pullRequest.number})`, + body, + expectedHeadOid: originalHead, + }); + return originalHead; +} + +/** + * Verify the deployed commit and publish profiler-cli when needed. + */ +async function finishDeployment( + deployedMainCommit: string, + shouldPublishCli: boolean, + cliVersion: string, + upstream: string, + dryRun: boolean +): Promise { + logAction('Verifying the production deployment before publishing.'); + git('fetch', upstream, 'main'); + const currentMainCommit = gitOutput('rev-parse', `${upstream}/main`); + if (currentMainCommit !== deployedMainCommit) { + throw new Error( + `main moved to ${currentMainCommit} after the deployment candidate ${deployedMainCommit} was selected. ` + + 'Refusing to finish a deployment from a different commit.' + ); + } + + if (dryRun) { + console.log( + `[dry-run] Would check out main and pull the merged deployment changes from '${upstream}'.` + ); + console.log( + `[dry-run] Would ask for confirmation that the Netlify production deployment for ${deployedMainCommit} is live.` + ); + } else { + git('checkout', 'main'); + git('pull', '--ff-only', upstream, 'main'); + await confirm( + `Confirm that the Netlify production deployment for ${deployedMainCommit} is live?` + ); + } + + if (!shouldPublishCli) { + console.log( + 'The profiler-cli version is unchanged from production. Skipping npm publication.' + ); + return; + } + + const { contents } = readCliPackage(); + if (dryRun) { + console.log( + `[dry-run] Would publish ${contents.name}@${cliVersion} to npm.` + ); + } else { + if (contents.version !== cliVersion) { + throw new Error( + `Expected profiler-cli version ${cliVersion}, found ${contents.version}.` + ); + } + await confirm(`Publish ${contents.name}@${contents.version} to npm?`); + } + runRemoteMutation(dryRun, 'yarn', ['publish-cli']); +} + +/** + * Run the production deployment workflow. + */ +export async function main( + args: string[] = process.argv.slice(2) +): Promise { + const { dryRun } = parseArguments(args); + console.log( + `Firefox Profiler production deployment${dryRun ? ' (dry run)' : ''}` + ); + await confirm( + dryRun + ? 'Start the dry run? This script will fetch recent changes, but it will not change the working tree, GitHub, or npm.' + : 'Start the deployment? This script will switch branches, pull recent changes, and may create a local version-bump branch. Commits and remote changes will still require separate confirmation.' + ); + const login = ensureGhAvailableAndLoggedIn(); + console.log(`Authenticated to GitHub as ${login}.`); + ensureCleanWorkingCopy(); + const { upstream, fork: forkRemote } = discoverDeploymentRemotes(login); + updateMain(upstream, dryRun); + const l10nPlan = await handleL10nPullRequest(login, upstream, dryRun); + const cliPlan = await handleVersionBumpPullRequest( + login, + upstream, + forkRemote, + dryRun + ); + const plannedChanges = []; + if (l10nPlan.plannedChange) { + plannedChanges.push(l10nPlan.plannedChange); + } + if (cliPlan.plannedChange) { + plannedChanges.push(cliPlan.plannedChange); + } + const deployedMainCommit = await handleDeploymentPullRequest( + upstream, + dryRun, + plannedChanges, + l10nPlan.plannedLocalizers + ); + await finishDeployment( + deployedMainCommit, + cliPlan.shouldPublishCli, + cliPlan.version, + upstream, + dryRun + ); + if (dryRun) { + console.log( + 'Dry run completed. No GitHub or npm changes, local branches, commits, or working-tree changes were created.' + ); + return; + } + console.log( + cliPlan.shouldPublishCli + ? 'Deployment and profiler-cli publication completed.' + : 'Deployment completed without profiler-cli publication.' + ); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(`\nDeployment stopped: ${error.message}`); + process.exitCode = 1; + }); +} diff --git a/bin/deploy-prod.test.mts b/bin/deploy-prod.test.mts new file mode 100644 index 0000000000..6727418c4b --- /dev/null +++ b/bin/deploy-prod.test.mts @@ -0,0 +1,452 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import assert from 'assert/strict'; +import { spawn } from 'child_process'; +import { + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { delimiter, join } from 'path'; +import { test } from 'node:test'; +import { fileURLToPath } from 'url'; + +import { + buildDeploymentBody, + bumpMinorVersion, + checkWaitArguments, + checksHaveNotStarted, + collectLocalizerCredits, + collectUpdatedLocales, + combineDeploymentChanges, + findGitHubRemote, + getCliVersionPlan, + isValidVersion, + isVersionGreater, + parseArguments, + parseGitHubRepositoryUrl, + reviewersFor, + runRemoteMutation, +} from './deploy-prod.mts'; + +function writeFakeCommand( + directory: string, + name: string, + source: string +): void { + if (process.platform === 'win32') { + writeFileSync(join(directory, `${name}.mjs`), source); + writeFileSync( + join(directory, `${name}.cmd`), + `@node "%~dp0${name}.mjs" %*\r\n` + ); + return; + } + + const path = join(directory, name); + writeFileSync(path, source); + chmodSync(path, 0o755); +} + +test('checkWaitArguments only filters required checks when requested', () => { + assert.deepEqual(checkWaitArguments(123, true), [ + 'pr', + 'checks', + '123', + '--watch', + '--fail-fast', + '--required', + ]); + assert.deepEqual(checkWaitArguments(123, false), [ + 'pr', + 'checks', + '123', + '--watch', + ]); +}); + +test('checksHaveNotStarted only matches missing-check messages', () => { + assert.equal( + checksHaveNotStarted("no checks reported on the 'main' branch"), + true + ); + assert.equal( + checksHaveNotStarted("no required checks reported on the 'main' branch"), + true + ); + assert.equal(checksHaveNotStarted('a check failed'), false); +}); + +test('parseArguments enables dry-run mode and rejects unknown arguments', () => { + assert.deepEqual(parseArguments([]), { dryRun: false }); + assert.deepEqual(parseArguments(['--dry-run']), { dryRun: true }); + assert.throws(() => parseArguments(['--unknown']), /Unknown argument/); +}); + +test('runRemoteMutation only prints commands in dry-run mode', () => { + const logs: unknown[][] = []; + const originalLog = console.log; + console.log = (...args) => logs.push(args); + + try { + assert.equal( + runRemoteMutation(true, 'command-that-does-not-exist', ['--write']), + null + ); + } finally { + console.log = originalLog; + } + + assert.deepEqual(logs, [ + ['[dry-run] Would run:', 'command-that-does-not-exist --write'], + ]); +}); + +test('dry run completes without invoking mutation commands', async () => { + const directory = mkdtempSync(join(tmpdir(), 'deploy-prod-test-')); + const binDirectory = join(directory, 'bin'); + const temporaryDirectory = join(directory, 'tmp'); + const violationsPath = join(directory, 'violations'); + mkdirSync(binDirectory); + mkdirSync(temporaryDirectory); + + writeFakeCommand( + binDirectory, + 'git', + `#!/usr/bin/env node +import { appendFileSync } from 'fs'; + +const args = process.argv.slice(2); +const command = args[0]; +const mutations = new Set(['add', 'branch', 'checkout', 'commit', 'merge', 'pull', 'push', 'reset', 'switch', 'tag']); +if (mutations.has(command)) { + appendFileSync(process.env.DEPLOY_PROD_TEST_VIOLATIONS, \`git \${args.join(' ')}\\n\`); + process.exit(90); +} + +if (command === 'status' || command === 'fetch' || command === 'diff') { + process.exit(0); +} +if (command === 'remote') { + process.stdout.write([ + 'origin\\tgit@github.com:example-maintainer/profiler.git (fetch)', + 'origin\\tgit@github.com:example-maintainer/profiler.git (push)', + 'upstream\\tgit@github.com:firefox-devtools/profiler.git (fetch)', + 'upstream\\tgit@github.com:firefox-devtools/profiler.git (push)', + ].join('\\n')); + process.exit(0); +} +if (command === 'show') { + process.stdout.write(JSON.stringify({ version: '0.12.0' })); + process.exit(0); +} +if (command === 'show-ref') { + process.exit(1); +} +if (command === 'log') { + process.exit(0); +} +if (command === 'var') { + process.stdout.write('node -e ""\\n'); + process.exit(0); +} +if (command === 'rev-parse') { + process.stdout.write('0123456789abcdef0123456789abcdef01234567\\n'); + process.exit(0); +} + +throw new Error(\`Unexpected git command: \${args.join(' ')}\`); +` + ); + + writeFakeCommand( + binDirectory, + 'gh', + `#!/usr/bin/env node +import { appendFileSync } from 'fs'; + +const args = process.argv.slice(2); +if (args[0] === '--version' || (args[0] === 'auth' && args[1] === 'status')) { + process.exit(0); +} +if (args[0] === 'api' && args[1] === 'user') { + process.stdout.write('example-maintainer\\n'); + process.exit(0); +} +if (args[0] === 'pr' && args[1] === 'list') { + process.stdout.write('[]\\n'); + process.exit(0); +} + +appendFileSync(process.env.DEPLOY_PROD_TEST_VIOLATIONS, \`gh \${args.join(' ')}\\n\`); +process.exit(90); +` + ); + + for (const command of ['npm', 'yarn']) { + writeFakeCommand( + binDirectory, + command, + `#!/usr/bin/env node +import { appendFileSync } from 'fs'; + +appendFileSync(process.env.DEPLOY_PROD_TEST_VIOLATIONS, \`${command} \${process.argv.slice(2).join(' ')}\\n\`); +process.exit(90); +` + ); + } + + try { + const child = spawn( + process.execPath, + [ + fileURLToPath(new URL('./deploy-prod.mts', import.meta.url)), + '--dry-run', + ], + { + env: { + ...process.env, + DEPLOY_PROD_TEST_VIOLATIONS: violationsPath, + PATH: `${binDirectory}${delimiter}${process.env.PATH ?? ''}`, + TEMP: temporaryDirectory, + TMPDIR: temporaryDirectory, + TMP: temporaryDirectory, + }, + } + ); + let stdout = ''; + let stderr = ''; + let confirmed = false; + let selectedVersion = false; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout += chunk; + if (!confirmed && stdout.includes('Type "y" to continue:')) { + confirmed = true; + child.stdin.write('y\n'); + } + if ( + confirmed && + !selectedVersion && + stdout.includes( + 'Profiler CLI version (main 0.12.0, production 0.12.0): [0.13.0]' + ) + ) { + selectedVersion = true; + child.stdin.write('\n'); + } + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + + const status = await new Promise((resolve, reject) => { + child.on('error', reject); + child.on('close', resolve); + }); + + assert.equal(status, 0, stderr); + assert.match(stdout, /Bump profiler-cli version to 0\.13\.0/); + assert.match( + stdout, + /Would publish @firefox-devtools\/profiler-cli@0\.13\.0 to npm/ + ); + assert.match(stdout, /Dry run completed/); + assert.equal( + existsSync(violationsPath) ? readFileSync(violationsPath, 'utf8') : '', + '' + ); + assert.deepEqual(readdirSync(temporaryDirectory), []); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test('bumpMinorVersion increments the minor version', () => { + assert.equal(bumpMinorVersion('0.9.0'), '0.10.0'); + assert.equal(bumpMinorVersion('1.2.7'), '1.3.0'); + assert.equal(bumpMinorVersion('1.2.7-next.1'), '1.3.0'); +}); + +test('isValidVersion accepts release and prerelease versions', () => { + assert.equal(isValidVersion('0.10.0'), true); + assert.equal(isValidVersion('1.0.0-next.1'), true); + assert.equal(isValidVersion('1.0.0-0'), true); + assert.equal(isValidVersion('1.0'), false); + assert.equal(isValidVersion('01.0.0'), false); + assert.equal(isValidVersion('1.0.0-next.01'), false); +}); + +test('isVersionGreater compares releases and prereleases', () => { + assert.equal(isVersionGreater('0.9.0', '0.10.0'), true); + assert.equal(isVersionGreater('1.0.0-next.1', '1.0.0'), true); + assert.equal(isVersionGreater('1.0.0', '1.0.0-next.1'), false); + assert.equal(isVersionGreater('1.2.0', '1.1.9'), false); +}); + +test('getCliVersionPlan accounts for the version in production', () => { + assert.deepEqual(getCliVersionPlan('0.10.0', '0.10.0'), { + defaultVersion: '0.11.0', + shouldPublishCurrentVersion: false, + }); + assert.deepEqual(getCliVersionPlan('0.11.0', '0.10.0'), { + defaultVersion: '0.11.0', + shouldPublishCurrentVersion: true, + }); + assert.throws( + () => getCliVersionPlan('0.10.0', '0.11.0'), + /main CLI version 0\.10\.0 is older than production 0\.11\.0/ + ); +}); + +test('parseGitHubRepositoryUrl parses GitHub remote URLs', () => { + assert.deepEqual( + parseGitHubRepositoryUrl('git@github.com:example-user/profiler.git'), + { owner: 'example-user', name: 'profiler' } + ); + assert.deepEqual( + parseGitHubRepositoryUrl('https://github.com/example-user/profiler'), + { owner: 'example-user', name: 'profiler' } + ); + assert.deepEqual( + parseGitHubRepositoryUrl('ssh://git@github.com/example-user/profiler.git'), + { owner: 'example-user', name: 'profiler' } + ); + assert.equal( + parseGitHubRepositoryUrl('git@gitlab.com:example-user/profiler.git'), + null + ); +}); + +test('findGitHubRemote finds remotes by URL direction', () => { + const remoteList = [ + 'personal\tgit@github.com:example-user/profiler.git (fetch)', + 'personal\tgit@github.com:example-user/profiler.git (push)', + 'mozilla\thttps://github.com/firefox-devtools/profiler.git (fetch)', + 'mozilla\thttps://github.com/firefox-devtools/profiler.git (push)', + ].join('\n'); + + assert.equal( + findGitHubRemote(remoteList, 'firefox-devtools/profiler', 'fetch'), + 'mozilla' + ); + assert.equal( + findGitHubRemote(remoteList, 'example-user/profiler', 'push'), + 'personal' + ); + assert.equal( + findGitHubRemote(remoteList, 'example-user/profiler', 'fetch'), + 'personal' + ); + assert.equal( + findGitHubRemote(remoteList, 'missing-user/profiler', 'push'), + null + ); + + const triangularRemoteList = [ + 'combined\tgit@github.com:firefox-devtools/profiler.git (fetch)', + 'combined\tgit@github.com:example-user/profiler.git (push)', + ].join('\n'); + assert.equal( + findGitHubRemote( + triangularRemoteList, + 'firefox-devtools/profiler', + 'fetch' + ), + 'combined' + ); + assert.equal( + findGitHubRemote(triangularRemoteList, 'example-user/profiler', 'push'), + 'combined' + ); +}); + +test('collectUpdatedLocales returns sorted unique locale names', () => { + assert.deepEqual( + collectUpdatedLocales( + [ + 'locales/tr/app.ftl', + 'locales/es-CL/app.ftl', + 'locales/tr/app.ftl', + ].join('\n') + ), + ['es-CL', 'tr'] + ); +}); + +test('collectLocalizerCredits supports locale before and after email', () => { + assert.deepEqual( + collectLocalizerCredits( + [ + 'Localizer One (tr) ', + 'Localizer Two (el)', + 'Localizer Three (tr) ', + 'Localizer Four (fy-NL, nl) ', + 'Localizer Five [:alias] (it) ', + 'Localizer One (tr) ', + ].join('\n') + ), + [ + 'el: Localizer Two', + 'fy-NL, nl: Localizer Four', + 'it: Localizer Five', + 'tr: Localizer One', + 'tr: Localizer Three', + ] + ); +}); + +test('buildDeploymentBody matches the deployment PR shape', () => { + assert.equal( + buildDeploymentBody('[Alice] Add a feature (#1)', ['de: Ger']), + [ + 'Changes:', + '', + '[Alice] Add a feature (#1)', + '', + 'And special thanks to our localizers:', + '', + 'de: Ger', + '', + ].join('\n') + ); +}); + +test('combineDeploymentChanges includes changes simulated by a dry run', () => { + assert.equal( + combineDeploymentChanges('', [ + '[Example Maintainer] Bump profiler-cli version to 0.13.0', + ]), + '[Example Maintainer] Bump profiler-cli version to 0.13.0' + ); + assert.equal( + combineDeploymentChanges('[Example Author] Existing change (#1)', [ + '[Example Maintainer] Sync localizations', + '[Example Maintainer] Bump profiler-cli version to 0.13.0', + ]), + [ + '[Example Author] Existing change (#1)', + '[Example Maintainer] Sync localizations', + '[Example Maintainer] Bump profiler-cli version to 0.13.0', + ].join('\n') + ); +}); + +test('reviewersFor excludes a maintainer running the script', () => { + assert.deepEqual(reviewersFor('fatadel'), ['canova', 'mstange']); + assert.deepEqual(reviewersFor('CANOVA'), ['mstange', 'fatadel']); + assert.deepEqual(reviewersFor('contributor'), [ + 'canova', + 'mstange', + 'fatadel', + ]); +}); diff --git a/docs-developer/deploying.md b/docs-developer/deploying.md index 0c8bec5280..0455488c04 100644 --- a/docs-developer/deploying.md +++ b/docs-developer/deploying.md @@ -30,6 +30,41 @@ change outside `profiler-cli/` can still affect it. Processed or Gecko profile format version bumps, for example, only reach CLI users through a new release. When in doubt, publish. We are not running out of version numbers. +The first four steps can be run interactively with: + +``` +yarn deploy-prod +``` + +The script checks the local environment, creates the l10n and CLI version pull +requests, waits for their reviews and required checks, creates the production +pull request with generated release notes, waits for its checks, and publishes +the CLI when needed after production is merged and you confirm that the Netlify +deployment is live. The `production` branch has no required checks, so the +script waits for every reported check on that pull request. If one fails, it +asks whether to continue. It automatically finds the remote that fetches from +`firefox-devtools/profiler` and the remote that pushes to your fork; the +temporary CLI version branch is pushed only to the latter. Before changing the +working copy, it asks whether to proceed. It also asks for confirmation before +every commit, push, pull request change, merge, and npm publication. If `main` +already has a CLI version that `production` does not, that version is the +default. Enter the current CLI version to skip the version-bump pull request. If +`production` already has that version, CLI publication is skipped as well. Step +5 still needs to be completed manually. + +To inspect the complete workflow without changing GitHub or npm, run: + +``` +yarn deploy-prod --dry-run +``` + +Dry-run mode fetches recent changes, asks for the CLI version, and opens the +generated deployment notes in your editor without changing the current branch +or working tree. It includes simulated l10n and CLI version changes in those +notes, but only prints the branch pushes, pull request changes, merges, and npm +publication it would perform. It does not create local branches or commits, or +change GitHub or npm. + ## 1. Merge l10n into main Our localization process happens inside [Pontoon](https://pontoon.mozilla.org/projects/firefox-profiler/). diff --git a/package.json b/package.json index 83647a04f2..a0e9d98da8 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "build-profile-query:quiet": "yarn build-profile-query", "build-cli": "cross-env NODE_ENV=production node scripts/build-profiler-cli.mjs", "build-cli:quiet": "yarn build-cli", + "deploy-prod": "node bin/deploy-prod.mts", "publish-cli": "node scripts/publish-profiler-cli.mjs", "lint": "node bin/output-fixing-commands.js run-p lint-js lint-css fmt-check", "lint-fix": "run-p lint-fix-js lint-fix-css fmt", @@ -50,13 +51,14 @@ "start-photon": "node scripts/run-photon-dev-server.mjs", "test": "node bin/output-fixing-commands.js cross-env LC_ALL=C TZ=UTC NODE_ENV=test JEST_PROJECTS=browser jest", "test-node": "node bin/output-fixing-commands.js cross-env LC_ALL=C TZ=UTC NODE_ENV=test JEST_ENVIRONMENT=node jest", - "test-all": "run-p --max-parallel 4 test-cli test ts lint license-check test-alex test-lockfile", + "test-all": "run-p --max-parallel 4 test-cli test ts lint license-check test-alex test-lockfile test-deploy-prod", "test-build-coverage": "yarn test --coverage --coverageReporters=html", "test-serve-coverage": "ws -d coverage/ -p 4343", "test-coverage": "run-s test-build-coverage test-serve-coverage", "test-alex": "alex ./docs-* CODE_OF_CONDUCT.md CONTRIBUTING.md README.md", "test-lockfile": "lockfile-lint --path yarn.lock --allowed-hosts yarn --validate-https", "test-debug": "cross-env LC_ALL=C TZ=UTC NODE_ENV=test node --inspect-brk node_modules/.bin/jest --runInBand", + "test-deploy-prod": "node --test bin/deploy-prod.test.mts", "test-cli": "yarn build-cli && node bin/output-fixing-commands.js cross-env LC_ALL=C TZ=UTC NODE_ENV=test JEST_PROJECTS=cli,cli-integration jest", "postinstall": "patch-package", "prepare": "husky" diff --git a/tsconfig.json b/tsconfig.json index f6c0bf5a04..df467d0891 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,6 +29,7 @@ // Module Resolution "resolveJsonModule": true, + "allowImportingTsExtensions": true, "typeRoots": ["node_modules/@types", "src/types/@types"], "paths": { "firefox-profiler/*": ["./src/*"], @@ -42,6 +43,8 @@ "src/**/*.ts", "src/**/*.tsx", "profiler-cli/**/*.ts", + "bin/deploy-prod.mts", + "bin/deploy-prod.test.mts", "__mocks__/**/*.ts" ], "exclude": ["node_modules", "dist"]