Files
discord.js/packages/actions/src/releasePackages/generateReleaseTree.ts
Almeida 3c9d90ad7e chore: move proxy-container to apps (#11435)
* chore: move proxy-container to apps

* chore: limit next/react rules to next apps

* chore: add standalone issue template

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2026-03-02 19:34:45 +00:00

235 lines
6.8 KiB
TypeScript

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<string, pnpmTreeDependency>;
name?: string;
path: string;
private?: boolean;
unsavedDependencies?: Record<string, pnpmTreeDependency>;
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<string>();
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<string>();
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<string>([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;
}