Files
gui/scripts/release.ts
T
zeekay 77e8398deb Build every package with the TypeScript compiler alone
Babel and esbuild were never here for TypeScript. Babel carried the static
optimizing compiler and one plugin that appended extensions to esbuild's
emitted imports; source now names its own extensions and the compiler rewrites
them on emit. A sixty-line runner replaces the tool: an ESM emit with
declarations, a CommonJS emit where a manifest states a require entry, and a
native emit where a .native sibling takes its sibling's name. The compiler
packages keep Babel as their own substrate; nothing else runs over the code.
Eighteen packages still carry type errors the previous tool never checked.
2026-09-03 17:14:57 -07:00

1042 lines
36 KiB
TypeScript

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 <name>`. 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 <pkg>` 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<string | null> {
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<boolean> {
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 '[email protected]`)
}
// 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-<channel>.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<string, string>()
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<version> 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 <dir>: 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()
}