mirror of
https://github.com/hanzoai/gui.git
synced 2026-10-25 02:31:41 +00:00
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.
1042 lines
36 KiB
TypeScript
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()
|
|
}
|