import { info, warning } from '@actions/core'; import type { PackageJSON, PackumentVersion } from '@npm/types'; import { $, file, write } from 'bun'; interface pnpmTreeDependency { from: string; path: string; version: string; } interface pnpmTree { dependencies?: Record; name?: string; path: string; private?: boolean; unsavedDependencies?: Record; version?: string; } export interface ReleaseEntry { changelog?: string; dependsOn?: string[]; name: string; version: string; } async function fetchDevVersion(pkg: string, tag: string) { try { const res = await fetch(`https://registry.npmjs.org/${pkg}/${tag}`); if (!res.ok) return null; const packument = (await res.json()) as PackumentVersion; return packument.version; } catch { return null; } } async function getReleaseEntries(dry: boolean, devTag?: string) { const releaseEntries: ReleaseEntry[] = []; const packageList: pnpmTree[] = await $`pnpm list --recursive --only-projects --filter {packages/\*} --prod --json`.json(); const commitHash = (await $`git rev-parse --short HEAD`.text()).trim(); const timestamp = Math.round(Date.now() / 1_000); for (const pkg of packageList) { // Don't release private packages ever (npm will error anyways) if (pkg.private) continue; // Just in case if (!pkg.version || !pkg.name) continue; const release: ReleaseEntry = { name: pkg.name, version: pkg.version, }; if (devTag) { // Replace workspace dependencies with * to pin to associated dev versions if (!dry) { const pkgJsonString = await file(`${pkg.path}/package.json`).text(); await write(`${pkg.path}/package.json`, pkgJsonString.replaceAll(/workspace:[\^~]/g, 'workspace:*')); } const devVersion = await fetchDevVersion(pkg.name, devTag); if (devVersion?.endsWith(commitHash)) { // Write the currently released dev version so when pnpm publish runs on dependents they depend on the dev versions if (dry) { info(`[DRY] ${pkg.name}@${devVersion} already released. Editing package.json version.`); } else { const pkgJson = (await file(`${pkg.path}/package.json`).json()) as PackageJSON; pkgJson.version = devVersion; await write(`${pkg.path}/package.json`, JSON.stringify(pkgJson, null, '\t')); } release.version = devVersion; } else if (dry) { info(`[DRY] Bumping ${pkg.name} via git-cliff.`); release.version = `${pkg.version}.DRY-${devTag}.${timestamp}-${commitHash}`; } else { await $`pnpm --filter=${pkg.name} run release --preid "${devTag}.${timestamp}-${commitHash}" --skip-changelog`; // Read again instead of parsing the output to be sure we're matching when checking against npm const pkgJson = (await file(`${pkg.path}/package.json`).json()) as PackageJSON; release.version = pkgJson.version; } } // Only need changelog for releases published to github else { try { // Find and parse changelog to post in github release const changelogFile = await file(`${pkg.path}/CHANGELOG.md`).text(); let changelogLines: string[] = []; let foundChangelog = false; for (const line of changelogFile.split('\n')) { if (line.startsWith('# [')) { if (foundChangelog) { if (changelogLines.at(-1) === '') { changelogLines = changelogLines.slice(2, -1); } break; } // Check changelog release version and assume no changelog if version does not match if (!line.startsWith(`# [${release.name === 'discord.js' ? `` : `${release.name}@`}${release.version}]`)) { break; } foundChangelog = true; } if (foundChangelog) { changelogLines.push(line); } } release.changelog = changelogLines.join('\n'); } catch (error) { // Probably just no changelog file but log just in case warning(`Error parsing changelog for ${pkg.name}, will use auto generated: ${error}`); } } if (pkg.dependencies) { release.dependsOn = Object.keys(pkg.dependencies); } releaseEntries.push(release); } return releaseEntries; } export async function generateReleaseTree(dry: boolean, devTag?: string, packageName?: string, exclude?: string[]) { let releaseEntries = await getReleaseEntries(dry, devTag); // Try to early return if the package doesn't have deps if (packageName && packageName !== 'all') { const releaseEntry = releaseEntries.find((entry) => entry.name === packageName); if (!releaseEntry) { throw new Error(`Package ${packageName} not releaseable`); } if (!releaseEntry.dependsOn) { return [[releaseEntry]]; } } // Generate the whole tree first, then prune if specified const releaseTree: ReleaseEntry[][] = []; const didRelease = new Set(); while (releaseEntries.length) { const nextBranch: ReleaseEntry[] = []; const unreleased: ReleaseEntry[] = []; for (const entry of releaseEntries) { if (!entry.dependsOn) { nextBranch.push(entry); continue; } const allDepsReleased = entry.dependsOn.every((dep) => didRelease.has(dep)); if (allDepsReleased) { nextBranch.push(entry); } else { unreleased.push(entry); } } // Update didRelease in a second loop to avoid loop order issues for (const release of nextBranch) { didRelease.add(release.name); } if (releaseEntries.length === unreleased.length) { throw new Error( `One or more packages have dependents that can't be released: ${unreleased.map((entry) => entry.name).join(',')}`, ); } releaseTree.push(nextBranch); releaseEntries = unreleased; } // Prune exclusions if ((!packageName || packageName === 'all') && Array.isArray(exclude) && exclude.length) { const neededPackages = new Set(); const excludedReleaseTree: ReleaseEntry[][] = []; for (const releaseBranch of releaseTree.reverse()) { const newThisBranch: ReleaseEntry[] = []; for (const entry of releaseBranch) { if (exclude.includes(entry.name) && !neededPackages.has(entry.name)) { continue; } newThisBranch.push(entry); for (const dep of entry.dependsOn ?? []) { neededPackages.add(dep); } } if (newThisBranch.length) excludedReleaseTree.unshift(newThisBranch); } return excludedReleaseTree; } if (!packageName || packageName === 'all') { return releaseTree; } // Prune the tree for the specified package const neededPackages = new Set([packageName]); const packageReleaseTree: ReleaseEntry[][] = []; for (const releaseBranch of releaseTree.reverse()) { const newThisBranch: ReleaseEntry[] = []; for (const entry of releaseBranch) { if (neededPackages.has(entry.name)) { newThisBranch.push(entry); for (const dep of entry.dependsOn ?? []) { neededPackages.add(dep); } } } if (newThisBranch.length) packageReleaseTree.unshift(newThisBranch); } return packageReleaseTree; }