import fs, { ensureDir, writeJSON } from 'fs-extra' import * as proc from 'node:child_process' import path, { join } from 'node:path' import { promisify } from 'node:util' import pMap from 'p-map' import prompts from 'prompts' import { ensureNpmAuthentication } from './release-npm-auth' import { getPublishArtifactPaths, getReusablePublishWorkspace, } from './release-publish-cache' import { computePublishTag } from './release-publish-tag' import { spawnify } from './spawnify' process.setMaxListeners(50) process.stdout.setMaxListeners(50) process.stderr.setMaxListeners(50) // --resume would be cool here where it stores the last failed step somewhere and tries resuming const exec = promisify(proc.exec) export const spawn = proc.spawn // for failed publishes that need to re-run const reRun = process.argv.includes('--rerun') const rePublish = reRun || process.argv.includes('--republish') const shouldFinish = process.argv.includes('--finish') // convenience flags const skipAll = process.argv.includes('--skip-all') const undocumented = process.argv.includes('--undocumented') const canary = process.argv.includes('--canary') const isRC = process.argv.includes('--rc') const isBeta = process.argv.includes('--beta') // the prerelease channel requested via flag (null for stable / canary releases) const requestedChannel = isBeta ? 'beta' : isRC ? 'rc' : null // explicit dist-tag override: `--tag `. always wins over version-based // detection (the escape hatch for publishing to an arbitrary tag). const explicitTagIdx = process.argv.indexOf('--tag') const explicitTag = explicitTagIdx !== -1 ? (process.argv[explicitTagIdx + 1] || '').trim() : undefined const skipStarters = canary || skipAll || process.argv.includes('--skip-starters') const skipVersion = shouldFinish || rePublish || process.argv.includes('--skip-version') const shouldPatch = process.argv.includes('--patch') const shouldMinor = process.argv.includes('--minor') const shouldMajor = process.argv.includes('--major') const dirty = shouldFinish || rePublish || undocumented || process.argv.includes('--dirty') const skipPublish = process.argv.includes('--skip-publish') const skipTest = shouldFinish || rePublish || skipAll || process.argv.includes('--skip-test') || process.argv.includes('--skip-tests') const skipNativeTests = process.argv.includes('--skip-native-test') || process.argv.includes('--skip-native-tests') const skipChecks = rePublish || skipAll || process.argv.includes('--skip-checks') const skipBuild = shouldFinish || rePublish || skipAll || process.argv.includes('--skip-build') const buildFast = process.argv.includes('--build-fast') const dryRun = process.argv.includes('--dry-run') const hanzoguiGitUser = process.argv.includes('--hanzogui-git-user') const isCI = shouldFinish || rePublish || undocumented || process.argv.includes('--ci') const skipFinish = rePublish || skipAll || undocumented || process.argv.includes('--skip-finish') const skipPush = process.argv.includes('--skip-push') const forcePublishAll = process.argv.includes('--force-publish-all') // `--only ` patches a single package instead of the lockstep fleet: it // versions and publishes just that one, and every dep it names resolves to // whatever is already on the registry. this is the way to ship one fix without // dragging 168 packages - and without hand-rolling a second publisher, since // only this script rewrites workspace:* into real versions before packing. const onlyIdx = process.argv.indexOf('--only') const onlyPackage = onlyIdx === -1 ? '' : (process.argv[onlyIdx + 1] || '').trim() function findPackageDir(name: string, dir = 'pkgs'): string | null { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (!entry.isDirectory() || entry.name === 'node_modules') continue const next = path.join(dir, entry.name) const manifest = path.join(next, 'package.json') if (fs.existsSync(manifest) && fs.readJSONSync(manifest).name === name) return next const found = findPackageDir(name, next) if (found) return found } return null } const onlyPackageDir = onlyPackage ? findPackageDir(onlyPackage) : null if (onlyPackage && !onlyPackageDir) { throw new Error(`--only: no package named ${onlyPackage} under pkgs/`) } const curVersion = fs.readJSONSync( onlyPackageDir ? path.join(onlyPackageDir, 'package.json') : './pkgs/ui/hanzogui/package.json' ).version async function getLastReleaseRef(): Promise { if (process.env.RELEASE_BASE_REF) { return process.env.RELEASE_BASE_REF } // find the most recent baseline: either a v* tag or a canary commit let tagRef: { ref: string; date: number } | null = null let canaryRef: { ref: string; date: number } | null = null try { const { stdout } = await exec(`git describe --tags --match 'v*' --abbrev=0`) const tag = stdout.trim() const { stdout: dateStr } = await exec(`git log -1 --format=%ct ${tag}`) tagRef = { ref: tag, date: Number(dateStr.trim()) } } catch {} try { const { stdout } = await exec(`git log --grep='^canary' --format='%H %ct' -1`) const [hash, dateStr] = stdout.trim().split(' ') if (hash) { canaryRef = { ref: hash, date: Number(dateStr) } } } catch {} if (!tagRef && !canaryRef) return null if (!tagRef) return canaryRef!.ref if (!canaryRef) return tagRef.ref // use whichever is more recent return canaryRef.date > tagRef.date ? canaryRef.ref : tagRef.ref } async function hasSourceChanges(dir: string, tag: string): Promise { try { await exec(`git diff --quiet ${tag} HEAD -- ${dir}/src/`) return false } catch { // non-zero exit = there are changes return true } } // Detect the current prerelease channel, if any (e.g. 2.0.0-rc.34 or 3.0.0-beta.5). // Strip any canary timestamp suffix first so a canary of X.Y.Z isn't mistaken // for a channel prerelease. const curVersionStripped = curVersion.replace(/-\d{10,}$/, '') const channelMatch = curVersionStripped.match(/^(\d+\.\d+\.\d+)-([a-z]+)\.(\d+)$/i) const currentChannel = channelMatch ? channelMatch[2].toLowerCase() : null const currentChannelBase = channelMatch ? channelMatch[1] : null const currentChannelNumber = channelMatch ? Number.parseInt(channelMatch[3], 10) : 0 const nextVersion = (() => { if (rePublish) { return curVersion } if (canary) { return `${curVersion.replace(/(-\d+)+$/, '')}-${Date.now()}` } // prerelease channel mode (--rc / --beta): bump within the channel or start it if (requestedChannel) { if (currentChannel === requestedChannel && currentChannelBase) { // already on this channel, bump the channel number return `${currentChannelBase}-${requestedChannel}.${currentChannelNumber + 1}` } // switching channels (rc -> beta) or coming from a canary: start the channel // at .0 on the current base version (e.g. canary of X.Y.Z -> X.Y.Z-beta.0) const baseVersion = curVersion.replace(/-.*$/, '') // strip any existing prerelease const isCanaryOfCurrent = /-\d+$/.test(curVersion) if (isCanaryOfCurrent || currentChannel) { return `${baseVersion}-${requestedChannel}.0` } // coming from a stable version - the base is ambiguous, set via prompt return null } // promoting a prerelease to stable: use the base version (e.g. 2.0.0-rc.34 -> 2.0.0) if (currentChannel && currentChannelBase) { return currentChannelBase } let plusVersion = skipVersion ? 0 : 1 const patchAndCanary = curVersion.split('.')[2] const [patch, lastCanary] = patchAndCanary.split('-') // if were publishing another canary no bump version if (lastCanary && canary) { plusVersion = 0 } const curMajor = +curVersion.split('.')[0] || 1 const curMinor = +curVersion.split('.')[1] || 0 if (shouldMajor) { return `${curMajor + plusVersion}.0.0` } if (shouldMinor || !shouldPatch) { return `${curMajor}.${curMinor + plusVersion}.0` } const next = `${curMajor}.${curMinor}.${+patch + plusVersion}` return next })() const sleep = (ms) => { console.info(`Sleeping ${ms}ms`) return new Promise((res) => setTimeout(res, ms)) } if (!skipVersion) { console.info('Current:', curVersion, '\n') } else { console.info(`Re-releasing ${curVersion}`) } const isMain = (await exec(`git rev-parse --abbrev-ref HEAD`)).stdout.trim() === 'main' async function getWorkspacePackages() { const rootPkg = await fs.readJSON('package.json') const workspaceGlobs = rootPkg.workspaces || [] const packages: { name: string; location: string }[] = [] for (const pattern of workspaceGlobs) { // normalize pattern: remove leading ./ and handle glob patterns const normalizedPattern = pattern.replace(/^\.\//, '') if (normalizedPattern.includes('**')) { // for **/* patterns, we need to scan subdirectories // e.g., pkgs/ui/**/* -> scan all subdirs of pkgs/ui/ const baseDir = normalizedPattern.split('**')[0].replace(/\/$/, '') try { const entries = await fs.readdir(baseDir, { withFileTypes: true }) for (const entry of entries) { if (!entry.isDirectory()) continue const pkgPath = path.join(baseDir, entry.name, 'package.json') try { const pkg = await fs.readJSON(pkgPath) if (pkg.name) { packages.push({ name: pkg.name, location: path.join(baseDir, entry.name) }) } } catch { // skip directories without package.json } } } catch { // skip patterns that don't resolve } } else if (normalizedPattern.includes('*')) { // for single * patterns, scan the parent directory const baseDir = normalizedPattern.replace(/\/\*$/, '') try { const entries = await fs.readdir(baseDir, { withFileTypes: true }) for (const entry of entries) { if (!entry.isDirectory()) continue const pkgPath = path.join(baseDir, entry.name, 'package.json') try { const pkg = await fs.readJSON(pkgPath) if (pkg.name) { packages.push({ name: pkg.name, location: path.join(baseDir, entry.name) }) } } catch { // skip directories without package.json } } } catch { // skip patterns that don't resolve } } else { // exact path - just check if it has a package.json try { const pkg = await fs.readJSON(path.join(normalizedPattern, 'package.json')) if (pkg.name) { packages.push({ name: pkg.name, location: normalizedPattern }) } } catch { // skip if no package.json } } } return packages } async function run() { try { let version = curVersion // ensure we are up to date // ensure we are on main (skip branch check for canary releases and dry runs) if (!canary && !rePublish && !dryRun && !process.env.CI) { if (!isMain) { throw new Error(`Not on main`) } } if (!dirty && !rePublish && !shouldFinish && !canary && !dryRun) { await spawnify(`git pull --rebase origin main`) } const packagePaths = await getWorkspacePackages() const allPackageJsons = ( await Promise.all( packagePaths .filter((i) => i.location !== '.' && !i.name.startsWith('@takeout')) .flatMap(async ({ name, location }) => { const cwd = path.join(process.cwd(), location) const json = await fs.readJSON(path.join(cwd, 'package.json')) const item = { name, cwd, json, path: path.join(cwd, 'package.json'), directory: location, } if (json.alsoPublishAs) { console.info( ` ${name}: Also publishing as ${json.alsoPublishAs.join(', ')}` ) return [ item, ...json.alsoPublishAs.map((name) => ({ ...item, json: { ...json, name }, name, })), ] } return [item] }) ) ) .flat() .filter((x) => !x.json['skipPublish']) const packageJsons = allPackageJsons .filter((x) => { return !x.json.private }) // slow things last .sort((a, b) => { if (a.name.includes('font-') || a.name.includes('-icons')) { return 1 } return -1 }) if (!shouldFinish) { console.info( `Publishing in order:\n\n${packageJsons.map((x) => x.name).join('\n')}` ) } async function checkDistDirs() { await Promise.all( packageJsons.map(async ({ cwd, json }) => { const distDir = join(cwd, 'dist') if (!json.scripts || !json.scripts.build || json.scripts.build === 'true') { return } if (!(await fs.pathExists(distDir))) { console.warn('no dist dir!', distDir) process.exit(1) } }) ) } // ensure right user if (hanzoguiGitUser) { await spawnify(`git config --global user.name 'Gui'`) await spawnify(`git config --global user.email 'hanzogui@users.noreply.github.com`) } // get version if (!shouldFinish) { let answer: { version: string } if (isCI || skipVersion) { if (!nextVersion) { throw new Error( `Cannot compute a ${requestedChannel} version from a stable base (${curVersion}) non-interactively.\n` + `Run without --ci to pick the base, or bump to a canary/${requestedChannel} version first.` ) } answer = { version: nextVersion } } else if (requestedChannel && currentChannel !== requestedChannel) { // Starting a new prerelease channel - prompt for which base version to use const baseVersion = curVersion.replace(/-.*$/, '') // strip any existing prerelease const [major, minor, patch] = baseVersion.split('.').map(Number) // check if current version is a canary (has prerelease suffix like -1234567) const isCanaryOfCurrent = /-\d+$/.test(curVersion) const channelChoices = isCanaryOfCurrent || currentChannel ? [ // canary/other-channel of X.Y.Z -> offer X.Y.Z-.0 { title: `${major}.${minor}.${patch}-${requestedChannel}.0`, value: `${major}.${minor}.${patch}-${requestedChannel}.0`, }, ] : [ { title: `${major}.${minor + 1}.0-${requestedChannel}.0 (next minor)`, value: `${major}.${minor + 1}.0-${requestedChannel}.0`, }, { title: `${major}.${minor}.${patch + 1}-${requestedChannel}.0 (next patch)`, value: `${major}.${minor}.${patch + 1}-${requestedChannel}.0`, }, { title: `${major + 1}.0.0-${requestedChannel}.0 (next major)`, value: `${major + 1}.0.0-${requestedChannel}.0`, }, ] const channelAnswer = await prompts({ type: 'select', name: 'version', message: `Which version to release as ${requestedChannel}?`, choices: channelChoices, }) answer = channelAnswer } else { answer = await prompts({ type: 'text', name: 'version', message: 'Version?', initial: nextVersion, }) } version = answer.version console.info('Next:', version, '\n') } // resolve + validate the npm dist-tag up front, so a prerelease can never // slip onto `latest`, and so --dry-run can print the exact publish plan. // throws loudly for an unrecognized prerelease instead of defaulting to latest. const publishTag = computePublishTag(version, { canary, explicitTag }) console.info(`Publishing to npm dist-tag: ${publishTag}\n`) // safety check for major version bumps going to `latest` - require interactive // confirmation. a prerelease of a new major (e.g. 3.0.0-beta.0 -> `beta`) can't // clobber the stable line, so it skips this gate. const curMajor = Number.parseInt(curVersion.split('.')[0], 10) const nextMajor = Number.parseInt(version.split('.')[0], 10) if (nextMajor > curMajor && publishTag === 'latest' && !isCI) { console.info(`\n⚠️ MAJOR VERSION BUMP: ${curVersion} → ${version}\n`) for (let i = 1; i <= 3; i++) { const { confirmed } = await prompts({ type: 'confirm', name: 'confirmed', message: `Confirm major version bump (${i}/3)?`, }) if (!confirmed) { console.info('Major version bump cancelled.') process.exit(0) } } } if (!shouldFinish && !skipPublish && !dryRun) { await ensureNpmAuthentication({ env: process.env, interactive: !!process.stdin.isTTY && !!process.stdout.isTTY, check: () => spawnify(`npm whoami`), login: () => spawnify(`npm login`, { interactive: true }), }) } // dry run only previews the publish plan, so skip the heavy install/build/test if (!dryRun) { console.info('install and build') } if (!rePublish && !shouldFinish && !dryRun) { await spawnify(process.env.CI ? `bun install --frozen-lockfile` : `bun install`) } // build from fresh if (!skipBuild && !shouldFinish && !dryRun) { // lets do a full clean and build:force, to ensure we dont have weird cached or leftover files if (buildFast) { await spawnify(`bun run build`) } else { await spawnify(`bun run clean:build && bun run build -- --force`) } await checkDistDirs() } // run checks if (!shouldFinish && !dryRun) { if (!skipChecks) { console.info('run checks') await Promise.all([ spawnify(`chmod ug+x ./node_modules/.bin/hanzogui`), spawnify(`bun run check`), spawnify(`bun run lint`), ]) await spawnify(`bun run typecheck`) } if (!skipTest) { console.info('run tests') await spawnify(`bun run test`, { env: { ...process.env, ...(isCI ? { CI: 'true' } : {}), ...(skipNativeTests ? { SKIP_NATIVE_TESTS: 'true' } : {}), }, }) } } // check clean git if (!dirty && !dryRun && !rePublish) { const out = await exec(`git status --porcelain`) if (out.stdout) { throw new Error(`Has unsaved git changes: ${out.stdout}`) } } // update version (never write files during a dry run - it's a read-only preview) if (!skipVersion && !shouldFinish && !dryRun) { await Promise.all( allPackageJsons .filter(({ name }) => !onlyPackage || name === onlyPackage) .map(async ({ json, path }) => { const next = { ...json } next.version = version await writeJSON(path, next, { spaces: 2 }) }) ) } // packages with "skipPublishIfUnchanged": true in package.json can be // skipped when their source hasn't changed since the last release. function isSkippablePackage(pkg: (typeof packageJsons)[0]) { return !!pkg.json.skipPublishIfUnchanged } const lastTag = await getLastReleaseRef() const skippedPackages: typeof packageJsons = [] let packagesToPublish = packageJsons if (onlyPackage) { packagesToPublish = packageJsons.filter((p) => p.name === onlyPackage) if (packagesToPublish.length === 0) { throw new Error(`--only: ${onlyPackage} is private or marked skipPublish`) } skippedPackages.push(...packageJsons.filter((p) => p.name !== onlyPackage)) console.info(`--only: publishing ${onlyPackage} alone`) } else if (lastTag && !forcePublishAll) { const lastTagVersion = lastTag.replace(/^v/, '') const lastMajor = Number.parseInt(lastTagVersion.split('.')[0], 10) const nextMajor = Number.parseInt(version.split('.')[0], 10) const isMajorBump = nextMajor > lastMajor if (!isMajorBump) { const changed: typeof packageJsons = [] const unchanged: typeof packageJsons = [] for (const pkg of packageJsons) { if (!isSkippablePackage(pkg)) { changed.push(pkg) continue } const hasChanges = await hasSourceChanges(pkg.directory, lastTag) if (hasChanges) { changed.push(pkg) } else { unchanged.push(pkg) } } if (unchanged.length > 0) { console.info( `\nSkipping ${unchanged.length} unchanged packages:\n${unchanged.map((p) => ` - ${p.name}`).join('\n')}\n` ) skippedPackages.push(...unchanged) packagesToPublish = changed } } else { console.info(`Major version bump detected, publishing all packages`) } } else if (forcePublishAll) { console.info(`--force-publish-all: publishing all packages`) } // for skipped packages, resolve their last published version so deps point // to a version that actually exists on the registry const skippedVersions = new Map() if (skippedPackages.length > 0) { // resolve against the same dist-tag we're publishing to, so a beta release // points beta deps at the last beta, a stable at the last stable, etc. const distTag = publishTag console.info( `Resolving last published versions for skipped packages (tag: ${distTag})...` ) await pMap( skippedPackages, async ({ name }) => { try { const { stdout } = await exec(`npm view ${name} dist-tags.${distTag}`) const lastVersion = stdout.trim() if (lastVersion) { skippedVersions.set(name, lastVersion) console.info(` ${name}: ${lastVersion}`) } else { // no published version, will use new version (force publish) skippedVersions.set(name, version) } } catch { // never published, use new version skippedVersions.set(name, version) } }, { concurrency: 10 } ) } if (!shouldFinish && dryRun) { console.info(`\n── dry run: publish plan ──`) console.info(`version: ${version}`) console.info(`npm dist-tag: ${publishTag}`) console.info(`\npublishing ${packagesToPublish.length} packages:\n`) for (const { name } of packagesToPublish) { const accessOption = name.startsWith('@') ? ' --access public' : '' console.info( ` npm publish ${name}@${version} --tag ${publishTag}${accessOption}` ) } if (skippedPackages.length > 0) { console.info( `\nskipped (unchanged), dist-tag "${publishTag}" will point at last published:` ) for (const { name } of skippedPackages) { console.info(` ${name}@${skippedVersions.get(name) ?? '(unpublished)'}`) } } console.info(`\nDry run, exiting before publish`) return } if (!shouldFinish && !rePublish) { await spawnify(`git diff`) } if (!isCI) { const { confirmed } = await prompts({ type: 'confirm', name: 'confirmed', message: 'Ready to publish?', }) if (!confirmed) { process.exit(0) } } if (!shouldFinish && !skipPublish) { const tmpDir = `/tmp/hanzogui-publish` if (!rePublish) { await fs.remove(tmpDir) } await ensureDir(tmpDir) // publishTag was resolved + validated up front (single source of truth) const publishOptions = `--tag ${publishTag}` const isPublished = async ({ name }: { name: string }) => { try { const { stdout } = await exec(`npm view ${name}@${version} version --json`) const found = JSON.parse(stdout.trim()) return found === version || (Array.isArray(found) && found.includes(version)) } catch (error) { const message = String(error) if (/E404|404 Not Found|is not in this registry/i.test(message)) { return false } throw new Error(`Could not verify ${name}@${version} on npm:\n${message}`) } } console.info(`Checking ${packagesToPublish.length} package versions on npm...`) const publishedChecks = await pMap( packagesToPublish, async (pkg) => ({ pkg, published: await isPublished(pkg) }), { concurrency: 8 } ) const pendingPackages = publishedChecks .filter(({ pkg, published }) => { if (published) { console.info(`Skipping ${pkg.name}: this version is already published`) return false } return true }) .map(({ pkg }) => pkg) const prepareOne = async ({ name, cwd }: { name: string; cwd: string }) => { if (rePublish) { const cachedWorkspace = await getReusablePublishWorkspace(tmpDir, name, version) if (cachedWorkspace) { console.info(`Reusing packed ${name}@${version}`) return cachedWorkspace } } const { tarballPath, workspaceDir } = getPublishArtifactPaths( tmpDir, name, version ) if (rePublish && (await fs.pathExists(tarballPath))) { try { await fs.remove(workspaceDir) await ensureDir(workspaceDir) await spawnify( `tar -xzf ${JSON.stringify(tarballPath)} -C ${JSON.stringify(workspaceDir)} --strip-components=1`, { avoidLog: true } ) const manifest = await fs.readJSON(join(workspaceDir, 'package.json')) if (manifest.name === name && manifest.version === version) { console.info(`Reusing tarball ${name}@${version}`) return path.relative(tmpDir, workspaceDir) } } catch { console.warn(`Could not reuse cached tarball for ${name}@${version}`) } } // Copy to temp directory and replace workspace:* with versions const tmpPackageDir = join(tmpDir, name.replace('/', '_')) await fs.remove(tmpPackageDir) await fs.remove(workspaceDir) await fs.remove(tarballPath) await fs.copy(cwd, tmpPackageDir, { filter: (src) => { // exclude node_modules to avoid symlink issues return !src.includes('node_modules') }, }) // replace workspace:* with version in temp copy const pkgJsonPath = join(tmpPackageDir, 'package.json') const pkgJson = await fs.readJSON(pkgJsonPath) pkgJson.repository = { type: 'git', url: 'git+https://github.com/hanzoai/gui.git', directory: path.relative(process.cwd(), cwd), } for (const field of [ 'dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies', ]) { if (!pkgJson[field]) continue for (const depName in pkgJson[field]) { if (pkgJson[field][depName].startsWith('workspace:')) { // use the skipped package's last published version if it won't be published pkgJson[field][depName] = skippedVersions.get(depName) || version } } } await writeJSON(pkgJsonPath, pkgJson, { spaces: 2 }) await spawnify(`npm pack --pack-destination ${tmpDir}`, { cwd: tmpPackageDir, avoidLog: true, }) await ensureDir(workspaceDir) await spawnify( `tar -xzf ${JSON.stringify(tarballPath)} -C ${JSON.stringify(workspaceDir)} --strip-components=1`, { avoidLog: true } ) return path.relative(tmpDir, workspaceDir) } if (pendingPackages.length > 0) { if (process.stdin.isTTY && process.stdout.isTTY && !process.env.CI) { console.info( 'npm will open the browser for 2FA once. Select “do not challenge for the next 5 minutes” so the same short-lived approval can publish the remaining packages.' ) } const workspaces = await pMap(pendingPackages, prepareOne, { concurrency: 8 }) await writeJSON( join(tmpDir, 'package.json'), { name: 'hanzogui-release', private: true, workspaces, }, { spaces: 2 } ) const webAuthCache = join(process.cwd(), 'scripts/cache-npm-webauth.cjs') const nodeOptions = [process.env.NODE_OPTIONS, `--require=${webAuthCache}`] .filter(Boolean) .join(' ') try { await spawnify( `npm publish --workspaces --ignore-scripts --access public ${publishOptions}`, { cwd: tmpDir, env: { ...process.env, NODE_OPTIONS: nodeOptions }, interactive: true, } ) } catch (error) { const postflight = await pMap( pendingPackages, async (pkg) => ({ pkg, published: await isPublished(pkg) }), { concurrency: 8 } ) const completed = postflight.filter(({ published }) => published) const missing = postflight.filter(({ published }) => !published) throw new Error( `Publish stopped after ${completed.length} packages. Still missing:\n${missing.map(({ pkg }) => pkg.name).join('\n')}\n\nRe-run with --republish to retry only these packages.`, { cause: error } ) } } console.info(`✅ Published\n`) // for a non-latest channel (canary/beta/rc/…), point that dist-tag at the // latest published version of each skipped package so e.g. // `npm install @hanzogui/lucide-icons-2@beta` still resolves if (publishTag !== 'latest' && skippedPackages.length > 0 && !process.env.CI) { console.info( `Updating ${publishTag} dist-tags for ${skippedPackages.length} skipped packages...` ) await pMap( skippedPackages, async ({ name }) => { try { const { stdout } = await exec(`npm view ${name} dist-tags.latest`) const latestVersion = stdout.trim() if (latestVersion) { await spawnify( `npm dist-tag add ${name}@${latestVersion} ${publishTag}`, { avoidLog: true, } ) console.info(` ✓ ${name}@${latestVersion} tagged as ${publishTag}`) } } catch (err) { console.warn(` ✗ ${name}: could not update ${publishTag} tag`, err) } }, { concurrency: 10 } ) } // restore package.json files for undocumented releases if (undocumented) { console.info('Restoring package.json files...') await spawnify(`git checkout -- **/package.json`) console.info(`✅ Restored package.json files (undocumented release)\n`) } } if (!skipFinish) { // then git tag, commit, push if (!shouldFinish) { await spawnify(`bun install`) } const tagPrefix = canary ? 'canary' : 'v' // a single-package patch must not claim the fleet's v tag - that // tag is the baseline getLastReleaseRef() diffs against. const gitTag = onlyPackage ? `${onlyPackage}@${version}` : `${tagPrefix}${version}` if (!shouldFinish) { // longer sleep since npm was missing some deps await sleep(10 * 1000) } // shell issues // if (!canary && !skipStarters) { // const starterFreeDir = join(process.cwd(), '../starter-free') // if (!dirty) { // await spawnify(`git pull --rebase origin HEAD`, { cwd: starterFreeDir }) // } // await spawnify(`bun run upgrade:starters`) // if (!shouldFinish) { // // Run bun test in starter-free directory // await spawnify(`bun run test`, { cwd: starterFreeDir }) // await finishAndCommit(starterFreeDir) // } // } await finishAndCommit() async function finishAndCommit(cwd = process.cwd()) { if (!rePublish || reRun || shouldFinish) { await spawnify(`git add -A`, { cwd }) // check if there are staged changes before committing const hasChanges = await exec(`git diff --cached --quiet`, { cwd }).then( () => false, () => true ) if (!hasChanges) { console.info(`No changes to commit in ${cwd}, skipping`) return } await spawnify(`git commit -m ${gitTag}`, { cwd }) if (!dirty) { // pull once more before pushing so if there was a push in interim we get it const currentBranch = ( await exec(`git rev-parse --abbrev-ref HEAD`, { cwd }) ).stdout.trim() await spawnify(`git pull --rebase origin ${currentBranch}`, { cwd }) } if (!canary) { await spawnify(`git tag ${gitTag}`, { cwd }) } if (!skipPush) { // HEAD, not head: git resolves refnames case-sensitively, so the // lowercase form only ever worked on a case-insensitive filesystem. await spawnify(`git push origin HEAD`, { cwd }) if (!canary) { await spawnify(`git push origin ${gitTag}`, { cwd }) } } console.info(`✅ ${skipPush ? 'Versioned locally' : 'Pushed and versioned'}\n`) } } // console.info(`All done, cleanup up in...`) // await sleep(2 * 1000) // // then remove old prepub tag // await pMap( // packageJsons, // async ({ name, cwd }) => { // await spawnify(`npm dist-tag remove ${name}@${version} prepub`, { // cwd, // }).catch((err) => console.error(err)) // }, // { // concurrency: 20, // } // ) } console.info(`✅ Done\n`) } catch (err) { console.info('\nError:\n', err) process.exit(1) } } // --into : quick local release, packs each package and unpacks into target node_modules const intoIdx = process.argv.indexOf('--into') if (intoIdx !== -1) { const targetArg = process.argv[intoIdx + 1] if (!targetArg) { console.error('Missing directory argument for --into') process.exit(1) } const targetDir = path.resolve(targetArg.replace(/^~/, process.env.HOME!)) ;(async () => { const packages = await getWorkspacePackages() const tmpDir = `/tmp/hanzogui-release-into` await ensureDir(tmpDir) let released = 0 await pMap( packages, async ({ name, location }) => { const destDir = join(targetDir, 'node_modules', name) if (!(await fs.pathExists(destDir))) return const cwd = path.resolve(location) try { await spawnify(`npm pack --pack-destination ${tmpDir}`, { cwd, avoidLog: true, }) // npm pack names files based on version in package.json, find the actual file const files = await fs.readdir(tmpDir) const prefix = name.replace('@', '').replace('/', '-') const packed = files.find((f) => f.startsWith(prefix) && f.endsWith('.tgz')) if (!packed) { console.warn(` skip ${name}: pack produced no tgz`) return } const actualTgz = join(tmpDir, packed) // clear destination and extract await spawnify(`tar -xzf ${actualTgz} -C ${destDir} --strip-components=1`, { avoidLog: true, }) await fs.remove(actualTgz) released++ console.info(` ✓ ${name}`) } catch (err) { console.warn(` ✗ ${name}: ${err}`) } }, { concurrency: 10 } ) console.info(`\n✅ Released ${released} packages into ${targetDir}`) process.exit(0) })() } else { run() }