UNPKG

@puls-atlas/cli

Version:

The Puls Atlas CLI tool for managing Atlas projects

469 lines 13.5 kB
import fs from 'fs'; import chalk from 'chalk'; import inquirer from 'inquirer'; import { diff, getCurrentBranch, hasLocalChanges, isReleaseBranch } from '../../utils/git.js'; import { logger } from '../../utils/logger.js'; import { execSync, selectProject } from '../../utils/index.js'; import { normalizeOptionalString } from '../../utils/value.js'; const DEFAULT_INITIAL_RELEASE_TAG = '0.0.0'; const RELEASE_TAG_FORMAT = /^(?<prefix>v?)(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?:-(?<revision>\d+))?$/u; const getCloudBuildUrl = projectId => `https://console.cloud.google.com/cloud-build/builds?project=${projectId}`; const parseReleaseTag = versionTag => { const normalizedTag = normalizeOptionalString(versionTag); if (!normalizedTag) { return null; } const parts = RELEASE_TAG_FORMAT.exec(normalizedTag); if (!parts?.groups) { return null; } return { major: Number.parseInt(parts.groups.major, 10), minor: Number.parseInt(parts.groups.minor, 10), patch: Number.parseInt(parts.groups.patch, 10), prefix: parts.groups.prefix ?? '', tag: normalizedTag }; }; export const getAvailableVersions = latestReleaseTag => { const parsedVersion = parseReleaseTag(latestReleaseTag) ?? parseReleaseTag(DEFAULT_INITIAL_RELEASE_TAG); if (!parsedVersion) { throw new Error('The latest GitHub release tag must use semantic versioning, for example 1.2.3 or v1.2.3.'); } const { major, minor, patch, prefix } = parsedVersion; return [{ name: 'Patch', version: `${prefix}${major}.${minor}.${patch + 1}` }, { name: 'Minor', version: `${prefix}${major}.${minor + 1}.0` }, { name: 'Major', version: `${prefix}${major + 1}.0.0` }]; }; const getLatestReleaseContext = (dependencies = {}) => { const { execSyncImpl = execSync, loggerImpl = logger } = dependencies; const spinner = loggerImpl.spinner('Fetching current release from GitHub...'); try { const releaseList = JSON.parse(execSyncImpl('gh release list', { excludeDrafts: true, json: 'tagName', limit: 1, stdio: 'pipe' }).toString()); const latestReleaseTag = normalizeOptionalString(releaseList[0]?.tagName); if (!latestReleaseTag) { spinner.stop(); loggerImpl.warning('No releases found on GitHub. This is the first release.'); return { isFirstRelease: true, latestReleaseTag: null }; } if (!parseReleaseTag(latestReleaseTag)) { spinner.stop(); throw new Error(`Latest GitHub release tag ${latestReleaseTag} is not a supported semantic version. Use a tag like 1.2.3 or v1.2.3.`); } spinner.succeed(`Latest release: ${latestReleaseTag}`); return { isFirstRelease: false, latestReleaseTag }; } catch (error) { spinner.fail('Failed to fetch the latest release from GitHub.'); throw error; } }; const ensureGitHubAuth = async (dependencies = {}) => { const { execSyncImpl = execSync, loggerImpl = logger, promptImpl = inquirer.prompt } = dependencies; try { execSyncImpl('gh --version', { stdio: 'ignore' }); } catch (error) { throw new Error(`GitHub CLI is required to continue. Install it first: https://cli.github.com/. ${error.message}`); } try { execSyncImpl('gh auth status', { stdio: 'ignore' }); } catch (error) { loggerImpl.warning(`GitHub CLI is not authenticated: ${error.message}`); const { shouldLogin } = await promptImpl([{ type: 'confirm', name: 'shouldLogin', default: true, message: 'GitHub authentication is required to create a release and trigger Google Cloud Build. Log in now?' }]); if (!shouldLogin) { throw new Error('GitHub authentication is required to create the release and trigger the production deploy.'); } execSyncImpl('gh auth login', { stdio: 'inherit', web: true }); } }; const outputReleasePlanSummary = ({ createDraft, currentBranch, hasWorkingTreeChanges, latestReleaseTag, loggerImpl = logger, projectId, releaseType, version }) => { loggerImpl.summary('Release plan', [{ label: 'Project', value: projectId, tone: 'warning' }, { label: 'Branch', value: currentBranch, tone: 'accent' }, { label: 'Latest release', value: latestReleaseTag ?? 'none (first release)', tone: latestReleaseTag ? 'accent' : 'warning' }, { label: 'Release type', value: releaseType, tone: 'warning' }, { label: 'Next release', value: version, tone: 'warning' }, { label: 'GitHub release', value: createDraft ? 'draft' : 'published', tone: createDraft ? 'warning' : 'success' }, { label: 'Cloud Build', value: 'Triggered automatically after the GitHub release is created', tone: 'accent' }, { label: 'Working tree', value: hasWorkingTreeChanges ? 'local changes will not be included' : 'clean', tone: hasWorkingTreeChanges ? 'warning' : 'success' }], { spacing: 'after' }); }; const outputDeploymentOutcomeSummary = ({ createDraft, loggerImpl = logger, projectId, version }) => { loggerImpl.summary('Deployment outcome', [{ label: 'Project', value: projectId, tone: 'warning' }, { label: 'Release tag', value: version, tone: 'accent' }, { label: 'GitHub release', value: createDraft ? 'draft created' : 'release created', tone: createDraft ? 'warning' : 'success' }, { label: 'Cloud Build', value: getCloudBuildUrl(projectId), tone: 'accent' }], { spacing: 'after' }); }; const outputFollowUpSummary = ({ deployCronJobs, deployFirestoreIndexes, deployFirestoreRules, loggerImpl = logger }) => { loggerImpl.summary('Optional follow-up deploys', [{ label: 'Firestore rules', value: deployFirestoreRules ? 'enabled' : 'skipped', tone: deployFirestoreRules ? 'warning' : null }, { label: 'Firestore indexes', value: deployFirestoreIndexes ? 'enabled' : 'skipped', tone: deployFirestoreIndexes ? 'warning' : null }, { label: 'Cron jobs', value: deployCronJobs ? 'enabled' : 'skipped', tone: deployCronJobs ? 'warning' : null }], { spacing: 'after' }); }; const warnAboutChangedFunctionsSinceRelease = ({ currentBranch, diffImpl = diff, latestReleaseTag, loggerImpl = logger }) => { if (!latestReleaseTag) { return; } const hasChanges = normalizeOptionalString(diffImpl(latestReleaseTag, currentBranch, { path: 'functions' })); if (!hasChanges) { return; } loggerImpl.summary('Manual follow-up recommended', [{ label: 'Directory', value: 'functions', tone: 'accent' }, { label: 'Since release', value: latestReleaseTag, tone: 'warning' }, { label: 'Action', value: 'Deploy changed Firebase functions manually if needed', tone: 'warning' }], { spacing: 'after' }); }; const promptForDeploymentPlan = async ({ projectId, promptImpl = inquirer.prompt, versionChoices }) => { const { releaseSelection } = await promptImpl([{ type: 'select', name: 'releaseSelection', message: 'Select the version you want to release:', choices: versionChoices.map(({ name, version }) => ({ name: `${version} (${name})`, value: { releaseType: name, version }, short: version })) }]); const { shouldPublishRelease } = await promptImpl([{ type: 'confirm', name: 'shouldPublishRelease', default: true, message: 'Publish the GitHub release immediately? If not, a draft release will be created instead.' }]); const { isConfirmed } = await promptImpl([{ type: 'confirm', name: 'isConfirmed', default: false, message: `Create ${shouldPublishRelease ? 'and publish' : 'as draft'} GitHub release ` + `${chalk.yellow(releaseSelection.version)} in ${chalk.yellow(projectId)}? ` + 'This triggers the production Google Cloud Build/App Engine deploy.' }]); if (!isConfirmed) { return { createDraft: !shouldPublishRelease, deployCronJobs: false, deployFirestoreIndexes: false, deployFirestoreRules: false, isConfirmed, releaseType: releaseSelection.releaseType, version: releaseSelection.version }; } const { deployFirestoreRules } = await promptImpl([{ type: 'confirm', name: 'deployFirestoreRules', default: false, message: `Do you want to deploy ${chalk.yellow('Firestore rules')} ` + 'from firebase.json as well?' }]); const { deployFirestoreIndexes } = await promptImpl([{ type: 'confirm', name: 'deployFirestoreIndexes', default: false, message: `Do you want to deploy ${chalk.yellow('Firestore indexes')} ` + 'from firebase.json as well?' }]); const { deployCronJobs } = await promptImpl([{ type: 'confirm', name: 'deployCronJobs', default: false, message: `Do you want to deploy ${chalk.yellow('cron jobs')} from cron.yaml as well?` }]); return { createDraft: !shouldPublishRelease, deployCronJobs, deployFirestoreIndexes, deployFirestoreRules, isConfirmed, releaseType: releaseSelection.releaseType, version: releaseSelection.version }; }; export const createDeployAppHandler = (dependencies = {}) => { const { diffImpl = diff, execSyncImpl = execSync, existsSyncImpl = fs.existsSync, getCurrentBranchImpl = getCurrentBranch, hasLocalChangesImpl = hasLocalChanges, isReleaseBranchImpl = isReleaseBranch, loggerImpl = logger, promptImpl = inquirer.prompt, selectProjectImpl = selectProject, unlinkSyncImpl = fs.unlinkSync } = dependencies; return async () => { try { if (!isReleaseBranchImpl()) { loggerImpl.error('You can only deploy from a release branch (main, master or x.y.x).', { exit: true, exitCode: 1 }); return false; } if (!existsSyncImpl('./cloudbuild.yaml')) { loggerImpl.error('No cloudbuild.yaml file found. Make sure you run this command in the root of the Atlas project.', { exit: true, exitCode: 1 }); return false; } const currentBranch = getCurrentBranchImpl(); const hasWorkingTreeChanges = hasLocalChangesImpl(); const { projectId } = await selectProjectImpl('.firebaserc', { environment: 'production' }); await ensureGitHubAuth({ execSyncImpl, loggerImpl, promptImpl }); const { isFirstRelease, latestReleaseTag } = getLatestReleaseContext({ execSyncImpl, loggerImpl }); const versionChoices = getAvailableVersions(latestReleaseTag); const releasePlan = await promptForDeploymentPlan({ projectId, promptImpl, versionChoices }); outputReleasePlanSummary({ createDraft: releasePlan.createDraft, currentBranch, hasWorkingTreeChanges, latestReleaseTag, loggerImpl, projectId, releaseType: releasePlan.releaseType, version: releasePlan.version }); if (!releasePlan.isConfirmed) { loggerImpl.warning('Deployment cancelled by the user.'); return false; } const releaseOptions = { generateNotes: true, target: currentBranch, title: releasePlan.version }; if (releasePlan.createDraft) { releaseOptions.draft = true; } if (!isFirstRelease && latestReleaseTag) { releaseOptions.notesStartTag = latestReleaseTag; } execSyncImpl(`gh release create ${releasePlan.version}`, releaseOptions); outputDeploymentOutcomeSummary({ createDraft: releasePlan.createDraft, loggerImpl, projectId, version: releasePlan.version }); outputFollowUpSummary({ deployCronJobs: releasePlan.deployCronJobs, deployFirestoreIndexes: releasePlan.deployFirestoreIndexes, deployFirestoreRules: releasePlan.deployFirestoreRules, loggerImpl }); if (releasePlan.deployFirestoreRules || releasePlan.deployFirestoreIndexes) { const firebaseTargets = []; if (releasePlan.deployFirestoreRules) { firebaseTargets.push('firestore:rules'); } if (releasePlan.deployFirestoreIndexes) { firebaseTargets.push('firestore:indexes'); } execSyncImpl('firebase deploy', { only: firebaseTargets.join(','), project: projectId }); } if (releasePlan.deployCronJobs) { execSyncImpl('gcloud app deploy cron.yaml', { project: projectId }); } warnAboutChangedFunctionsSinceRelease({ currentBranch, diffImpl, latestReleaseTag, loggerImpl }); loggerImpl.success('Done!'); return true; } catch (error) { if (error instanceof Error && error.name === 'ExitPromptError') { loggerImpl.error('Deployment cancelled by the user.', { exit: true, exitCode: 130 }); return false; } loggerImpl.error(error, { exit: true, exitCode: 1 }); return false; } finally { if (existsSyncImpl('release.txt')) { unlinkSyncImpl('release.txt'); } } }; }; export default createDeployAppHandler();