|
| 1 | +/* eslint-disable prefer-template */ |
| 2 | +/* eslint-disable no-restricted-syntax */ |
| 3 | +/* eslint-disable no-console */ |
| 4 | +import { resolve, dirname } from 'node:path'; |
| 5 | +import { fileURLToPath } from 'node:url'; |
| 6 | +import { readFile, writeFile, appendFile } from 'node:fs/promises'; |
| 7 | +import * as readline from 'node:readline/promises'; |
| 8 | +import yargs from 'yargs'; |
| 9 | +import { hideBin } from 'yargs/helpers'; |
| 10 | +import { $ } from 'execa'; |
| 11 | +import chalk from 'chalk'; |
| 12 | + |
| 13 | +const $$ = $({ stdio: 'inherit' }); |
| 14 | + |
| 15 | +const currentDirectory = dirname(fileURLToPath(import.meta.url)); |
| 16 | +const workspaceRoot = resolve(currentDirectory, '..'); |
| 17 | + |
| 18 | +interface PackageInfo { |
| 19 | + name: string; |
| 20 | + path: string; |
| 21 | + version: string; |
| 22 | + private: boolean; |
| 23 | +} |
| 24 | + |
| 25 | +interface RunOptions { |
| 26 | + accessToken?: string; |
| 27 | + baseline?: string; |
| 28 | + dryRun: boolean; |
| 29 | + skipLastCommitComparison: boolean; |
| 30 | + yes: boolean; |
| 31 | + ignore: string[]; |
| 32 | +} |
| 33 | + |
| 34 | +async function run({ |
| 35 | + dryRun, |
| 36 | + accessToken, |
| 37 | + baseline, |
| 38 | + skipLastCommitComparison, |
| 39 | + yes, |
| 40 | + ignore, |
| 41 | +}: RunOptions) { |
| 42 | + await ensureCleanWorkingDirectory(); |
| 43 | + |
| 44 | + const changedPackages = await getChangedPackages(baseline, skipLastCommitComparison, ignore); |
| 45 | + if (changedPackages.length === 0) { |
| 46 | + return; |
| 47 | + } |
| 48 | + |
| 49 | + await confirmPublishing(changedPackages, yes); |
| 50 | + |
| 51 | + try { |
| 52 | + await setAccessToken(accessToken); |
| 53 | + await setVersion(changedPackages); |
| 54 | + await buildPackages(); |
| 55 | + await publishPackages(changedPackages, dryRun); |
| 56 | + } finally { |
| 57 | + await cleanUp(); |
| 58 | + } |
| 59 | +} |
| 60 | + |
| 61 | +async function ensureCleanWorkingDirectory() { |
| 62 | + try { |
| 63 | + await $`git diff --quiet`; |
| 64 | + await $`git diff --quiet --cached`; |
| 65 | + } catch (error) { |
| 66 | + console.error('❌ Working directory is not clean.'); |
| 67 | + process.exit(1); |
| 68 | + } |
| 69 | +} |
| 70 | + |
| 71 | +async function listPublicChangedPackages(baseline: string) { |
| 72 | + const { stdout: packagesJson } = |
| 73 | + await $`pnpm list --recursive --filter ...[${baseline}] --depth -1 --only-projects --json`; |
| 74 | + const packages = JSON.parse(packagesJson) as PackageInfo[]; |
| 75 | + return packages.filter((pkg) => !pkg.private); |
| 76 | +} |
| 77 | + |
| 78 | +async function getChangedPackages( |
| 79 | + baseline: string | undefined, |
| 80 | + skipLastCommitComparison: boolean, |
| 81 | + ignore: string[], |
| 82 | +): Promise<PackageInfo[]> { |
| 83 | + if (!skipLastCommitComparison) { |
| 84 | + const publicPackagesUpdatedInLastCommit = await listPublicChangedPackages('HEAD~1'); |
| 85 | + if (publicPackagesUpdatedInLastCommit.length === 0) { |
| 86 | + console.log('No public packages changed in the last commit.'); |
| 87 | + return []; |
| 88 | + } |
| 89 | + } |
| 90 | + |
| 91 | + if (!baseline) { |
| 92 | + const { stdout: latestTag } = await $`git describe --abbrev=0`; |
| 93 | + baseline = latestTag; |
| 94 | + } |
| 95 | + |
| 96 | + console.log(`Looking for changed public packages since ${chalk.yellow(baseline)}...`); |
| 97 | + |
| 98 | + const changedPackages = (await listPublicChangedPackages(baseline)).filter( |
| 99 | + (p) => !ignore.includes(p.name), |
| 100 | + ); |
| 101 | + |
| 102 | + if (changedPackages.length === 0) { |
| 103 | + console.log('Nothing found.'); |
| 104 | + } |
| 105 | + |
| 106 | + return changedPackages; |
| 107 | +} |
| 108 | + |
| 109 | +async function confirmPublishing(changedPackages: PackageInfo[], yes: boolean) { |
| 110 | + if (!yes) { |
| 111 | + const rl = readline.createInterface({ |
| 112 | + input: process.stdin, |
| 113 | + output: process.stdout, |
| 114 | + }); |
| 115 | + |
| 116 | + console.log('\nFound changes in the following packages:'); |
| 117 | + for (const pkg of changedPackages) { |
| 118 | + console.log(` - ${pkg.name}`); |
| 119 | + } |
| 120 | + |
| 121 | + console.log('\nThis will publish the above packages to the npm registry.'); |
| 122 | + const answer = await rl.question('Do you want to proceed? (y/n) '); |
| 123 | + |
| 124 | + rl.close(); |
| 125 | + |
| 126 | + if (answer.toLowerCase() !== 'y') { |
| 127 | + console.log('Aborted.'); |
| 128 | + process.exit(0); |
| 129 | + } |
| 130 | + } |
| 131 | +} |
| 132 | + |
| 133 | +async function setAccessToken(npmAccessToken: string | undefined) { |
| 134 | + if (!npmAccessToken && !process.env.NPM_TOKEN) { |
| 135 | + console.error( |
| 136 | + '❌ NPM access token is required. Either pass it as an --access-token argument or set it as an NPM_TOKEN environment variable.', |
| 137 | + ); |
| 138 | + process.exit(1); |
| 139 | + } |
| 140 | + |
| 141 | + const npmrcPath = resolve(workspaceRoot, '.npmrc'); |
| 142 | + |
| 143 | + await appendFile( |
| 144 | + npmrcPath, |
| 145 | + `//registry.npmjs.org/:_authToken=${npmAccessToken ?? process.env.NPM_TOKEN}\n`, |
| 146 | + ); |
| 147 | +} |
| 148 | + |
| 149 | +async function setVersion(packages: PackageInfo[]) { |
| 150 | + const { stdout: currentRevisionSha } = await $`git rev-parse --short HEAD`; |
| 151 | + const { stdout: commitTimestamp } = await $`git show --no-patch --format=%ct HEAD`; |
| 152 | + const timestamp = formatDate(new Date(+commitTimestamp * 1000)); |
| 153 | + let hasError = false; |
| 154 | + |
| 155 | + const tasks = packages.map(async (pkg) => { |
| 156 | + const packageJsonPath = resolve(pkg.path, './package.json'); |
| 157 | + try { |
| 158 | + const packageJson = JSON.parse(await readFile(packageJsonPath, { encoding: 'utf8' })); |
| 159 | + const version = packageJson.version; |
| 160 | + const dashIndex = version.indexOf('-'); |
| 161 | + let newVersion = version; |
| 162 | + if (dashIndex !== -1) { |
| 163 | + newVersion = version.slice(0, dashIndex); |
| 164 | + } |
| 165 | + |
| 166 | + newVersion = `${newVersion}-dev.${timestamp}-${currentRevisionSha}`; |
| 167 | + packageJson.version = newVersion; |
| 168 | + |
| 169 | + await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); |
| 170 | + } catch (error) { |
| 171 | + console.error(`${chalk.red(`❌ ${packageJsonPath}`)}`, error); |
| 172 | + hasError = true; |
| 173 | + } |
| 174 | + }); |
| 175 | + |
| 176 | + await Promise.allSettled(tasks); |
| 177 | + if (hasError) { |
| 178 | + throw new Error('Failed to update package versions'); |
| 179 | + } |
| 180 | +} |
| 181 | + |
| 182 | +function formatDate(date: Date) { |
| 183 | + // yyyyMMdd-HHmmss |
| 184 | + return date |
| 185 | + .toISOString() |
| 186 | + .replace(/[-:Z.]/g, '') |
| 187 | + .replace('T', '-') |
| 188 | + .slice(0, 15); |
| 189 | +} |
| 190 | + |
| 191 | +function buildPackages() { |
| 192 | + if (process.env.CI) { |
| 193 | + return $$`pnpm build:public:ci`; |
| 194 | + } |
| 195 | + |
| 196 | + return $$`pnpm build:public`; |
| 197 | +} |
| 198 | + |
| 199 | +async function publishPackages(packages: PackageInfo[], dryRun: boolean) { |
| 200 | + console.log(`\nPublishing packages${dryRun ? ' (dry run)' : ''}`); |
| 201 | + const tasks = packages.map(async (pkg) => { |
| 202 | + try { |
| 203 | + const args = [pkg.path, '--tag', 'canary', '--no-git-checks']; |
| 204 | + if (dryRun) { |
| 205 | + args.push('--dry-run'); |
| 206 | + } |
| 207 | + await $$`pnpm publish ${args}`; |
| 208 | + } catch (error: any) { |
| 209 | + console.error(chalk.red(`❌ ${pkg.name}`), error.shortMessage); |
| 210 | + } |
| 211 | + }); |
| 212 | + |
| 213 | + await Promise.allSettled(tasks); |
| 214 | +} |
| 215 | + |
| 216 | +async function cleanUp() { |
| 217 | + await $`git restore .`; |
| 218 | +} |
| 219 | + |
| 220 | +yargs(hideBin(process.argv)) |
| 221 | + .command<RunOptions>( |
| 222 | + '$0', |
| 223 | + 'Publishes packages that have changed since the last release (or a specified commit).', |
| 224 | + (command) => { |
| 225 | + return command |
| 226 | + .option('dryRun', { |
| 227 | + default: false, |
| 228 | + describe: 'If true, no packages will be published to the registry.', |
| 229 | + type: 'boolean', |
| 230 | + }) |
| 231 | + .option('accessToken', { |
| 232 | + describe: 'NPM access token', |
| 233 | + type: 'string', |
| 234 | + }) |
| 235 | + .option('baseline', { |
| 236 | + describe: 'Baseline tag or commit to compare against (for example `master`).', |
| 237 | + type: 'string', |
| 238 | + }) |
| 239 | + .option('skipLastCommitComparison', { |
| 240 | + default: false, |
| 241 | + describe: |
| 242 | + 'By default, the script exits when there are no changes in public packages in the latest commit. Setting this flag will skip this check and compare only against the baseline.', |
| 243 | + type: 'boolean', |
| 244 | + }) |
| 245 | + .option('yes', { |
| 246 | + default: false, |
| 247 | + describe: "If set, the script doesn't ask for confirmation before publishing packages", |
| 248 | + type: 'boolean', |
| 249 | + }) |
| 250 | + .option('ignore', { |
| 251 | + describe: 'List of packages to ignore', |
| 252 | + type: 'string', |
| 253 | + array: true, |
| 254 | + default: [], |
| 255 | + }); |
| 256 | + }, |
| 257 | + run, |
| 258 | + ) |
| 259 | + .help() |
| 260 | + .strict(true) |
| 261 | + .version(false) |
| 262 | + .parse(); |
0 commit comments