ci: better release workflow (#10325)

* ci: better release workflow

* ci: simplify + use changelog

* ci(release): better parsing and exclusions

* ci(release): remove tree log

* ci(release): improve logs

* ci(release): properly check inputs

* ci(release): better promise handling

Co-authored-by: Aura <kyradiscord@gmail.com>

* ci: refactor release to use bun

* ci(release): whitespace

Co-authored-by: Vlad Frangu <kingdgrizzle@gmail.com>

* ci(release): add dev release handling

* ci(release): fixes from testing

* ci(release): make the promise run

* ci(release): when specifying package, skip exclusions

* ci(dev): create-discord-bot dev release

* ci(release): improve changelog detection

* ci: fix typo and allow releasing branches

* ci(release): set make_latest for gh releases

* ci(release): add ssh_key so pushed tags run workflow

---------

Co-authored-by: Aura <kyradiscord@gmail.com>
Co-authored-by: Vlad Frangu <kingdgrizzle@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
ckohen
2025-07-25 02:56:02 -07:00
committed by GitHub
parent f2fec9177f
commit 6cdfa3864b
12 changed files with 646 additions and 22 deletions

View File

@@ -42,12 +42,14 @@
"funding": "https://github.com/discordjs/discord.js?sponsor",
"dependencies": {
"@actions/core": "^1.11.1",
"@actions/github": "^6.0.1",
"@actions/glob": "^0.5.0",
"@aws-sdk/client-s3": "^3.844.0",
"@discordjs/scripts": "workspace:^",
"@vercel/blob": "^1.1.1",
"@vercel/postgres": "^0.10.0",
"cloudflare": "^4.4.1",
"commander": "^14.0.0",
"meilisearch": "^0.38.0",
"p-limit": "^6.2.0",
"p-queue": "^8.1.0",
@@ -55,6 +57,8 @@
"undici": "7.11.0"
},
"devDependencies": {
"@npm/types": "^2.1.0",
"@types/bun": "^1.2.19",
"@types/node": "^22.16.3",
"@vitest/coverage-v8": "^3.2.4",
"cross-env": "^7.0.3",

View File

@@ -0,0 +1,24 @@
name: 'Release Packages'
description: 'Tags and releases any unreleased packages'
inputs:
dev:
description: 'Releases development versions of packages (skips tagging and github releases)'
default: false
dry:
descrption: 'Perform a dry run that skips publishing and outputs logs indicating what would have happened'
default: false
package:
description: 'The published name of a single package to release'
exclude:
description: 'Comma separated list of packages to exclude from release (if not depended upon)'
runs:
using: composite
steps:
- uses: oven-sh/setup-bun@v1
- run: bun packages/actions/src/releasePackages/index.ts
shell: bash
env:
INPUT_DEV: ${{ inputs.dev }}
INPUT_DRY: ${{ inputs.dry }}
INPUT_PACKAGE: ${{ inputs.package }}
INPUT_EXCLUDE: ${{ inputs.exclude }}

View File

@@ -0,0 +1,230 @@
import { info, warning } from '@actions/core';
import type { PackageJSON, PackumentVersion } from '@npm/types';
import { $, file, write } from 'bun';
const nonNodePackages = new Set(['@discordjs/proxy-container']);
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) {
try {
const res = await fetch(`https://registry.npmjs.org/${pkg}/dev`);
if (!res.ok) return null;
const packument = (await res.json()) as PackumentVersion;
return packument.version;
} catch {
return null;
}
}
async function getReleaseEntries(dev: boolean, dry: boolean) {
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();
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;
if (nonNodePackages.has(pkg.name)) continue;
const release: ReleaseEntry = {
name: pkg.name,
version: pkg.version,
};
if (dev) {
const devVersion = await fetchDevVersion(pkg.name);
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-dev.${Math.round(Date.now() / 1_000)}-${commitHash}`;
} else {
await $`pnpm --filter=${pkg.name} run release --preid "dev.${Math.round(Date.now() / 1_000)}-${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(dev: boolean, dry: boolean, packageName?: string, exclude?: string[]) {
let releaseEntries = await getReleaseEntries(dev, dry);
// 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;
}

View File

@@ -0,0 +1,47 @@
import { getInput, startGroup, endGroup, getBooleanInput, info } from '@actions/core';
import { program } from 'commander';
import { generateReleaseTree } from './generateReleaseTree.js';
import { releasePackage } from './releasePackage.js';
const excludeInput = getInput('exclude');
let dryInput = false;
let devInput = false;
try {
devInput = getBooleanInput('dev');
} catch {
// We're not running in actions
}
try {
dryInput = getBooleanInput('dry');
} catch {
// We're not running in actions or the input isn't set (cron)
}
program
.name('release packages')
.description('releases monorepo packages with proper sequencing')
.argument('[package]', "release a specific package (and it's dependencies)", getInput('package'))
.option(
'-e, --exclude <packages...>',
'exclude specific packages from releasing (will still release if necessary for another package)',
excludeInput ? excludeInput.split(',') : [],
)
.option('--dry', 'skips actual publishing and outputs logs instead', dryInput)
.option('--dev', 'publishes development versions and skips tagging / github releases', devInput)
.parse();
const { exclude, dry, dev } = program.opts<{ dev: boolean; dry: boolean; exclude: string[] }>();
const packageName = program.args[0]!;
const tree = await generateReleaseTree(dev, dry, packageName, exclude);
for (const branch of tree) {
startGroup(`Releasing ${branch.map((entry) => `${entry.name}@${entry.version}`).join(', ')}`);
await Promise.all(branch.map(async (release) => releasePackage(release, dev, dry)));
endGroup();
}
info(
`Successfully released ${tree.map((branch) => branch.map((entry) => `${entry.name}@${entry.version}`).join(', ')).join(', ')}`,
);

View File

@@ -0,0 +1,87 @@
import process from 'node:process';
import { setInterval, clearInterval } from 'node:timers';
import { info, warning } from '@actions/core';
import { getOctokit, context } from '@actions/github';
import { $ } from 'bun';
import type { ReleaseEntry } from './generateReleaseTree.js';
let octokit: ReturnType<typeof getOctokit> | undefined;
if (process.env.GITHUB_TOKEN) {
octokit = getOctokit(process.env.GITHUB_TOKEN);
}
async function checkRegistry(release: ReleaseEntry) {
const res = await fetch(`https://registry.npmjs.org/${release.name}/${release.version}`);
return res.ok;
}
async function gitTagAndRelease(release: ReleaseEntry, dry: boolean) {
const tagName = `${release.name === 'discord.js' ? `` : `${release.name}@`}${release.version}`;
// Don't throw, if this exits non-zero it's probably because the tag already exists
await $`git tag ${tagName}`.nothrow();
if (dry) {
info(`[DRY] Tag "${tagName}" created, skipping push and release creation.`);
return;
}
await $`git push origin ${tagName}`;
try {
await octokit?.rest.repos.createRelease({
...context.repo,
tag_name: tagName,
name: tagName,
body: release.changelog ?? '',
generate_release_notes: release.changelog === undefined,
make_latest: release.name === 'discord.js' ? 'true' : 'false',
});
} catch (error) {
warning(`Failed to create github release: ${error}`);
}
}
export async function releasePackage(release: ReleaseEntry, dev: boolean, dry: boolean) {
// Sanity check against the registry first
if (await checkRegistry(release)) {
info(`${release.name}@${release.version} already published, skipping.`);
return;
}
if (dry) {
info(`[DRY] Releasing ${release.name}@${release.version}`);
} else {
await $`pnpm --filter=${release.name} publish --provenance --no-git-checks ${dev ? '--tag=dev' : ''}`;
}
if (!dev) await gitTagAndRelease(release, dry);
if (dry) return;
const before = performance.now();
// Poll registry to ensure next publishes won't fail
await new Promise<void>((resolve, reject) => {
const interval = setInterval(async () => {
if (await checkRegistry(release)) {
clearInterval(interval);
resolve();
return;
}
if (performance.now() > before + 5 * 60 * 1_000) {
clearInterval(interval);
reject(new Error(`Release for ${release.name} failed.`));
}
}, 15_000);
});
if (dev) {
// Send and forget, deprecations are less important than releasing other dev versions and can be done manually
void $`pnpm exec npm-deprecate --name "*dev*" --message "This version is deprecated. Please use a newer version." --package ${release.name}`
.nothrow()
// eslint-disable-next-line promise/prefer-await-to-then
.then(() => {});
}
}

View File

@@ -1,6 +1,10 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "../../tsconfig.json",
"compilerOptions": {
"types": ["node"],
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.js", "src/**/*.cjs", "src/**/*.mjs", "bin"],
"exclude": ["node_modules"]
}

View File

@@ -4,6 +4,7 @@ export default createTsupConfig({
entry: [
'src/index.ts',
'src/formatTag/index.ts',
'src/releasePackages/index.ts',
'src/uploadDocumentation/index.ts',
'src/uploadSearchIndices/index.ts',
'src/uploadSplitDocumentation/index.ts',
@@ -11,4 +12,5 @@ export default createTsupConfig({
dts: false,
format: 'esm',
minify: 'terser',
target: 'esnext',
});