// Most of this code was adapted from https://github.com/changesets/action, // which unfortunately doesn't support more granular usage of their code import fs from 'node:fs/promises'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import * as core from '@actions/core'; import { type ExecOutput, exec, getExecOutput } from '@actions/exec'; import * as github from '@actions/github'; import { readPreState } from '@changesets/pre'; import { getPackages, type Package } from '@manypkg/get-packages'; import { toString as mdastToString } from 'mdast-util-to-string'; import { remark } from 'remark'; // `getOctokit` requires a non-empty token; the fallback keeps importing this // module (e.g. from the test suite) from throwing when no token is present. const octokit = github.getOctokit(process.env.GITHUB_TOKEN || 'placeholder'); const processor = remark(); const LATEST_GITHUB_RELEASE_PACKAGE_NAME = 'react-email'; export const BumpLevels = { dep: 0, patch: 1, minor: 2, major: 3, } as const; export function getChangelogEntry(changelog: string, version: string) { const ast = processor.parse(changelog); let highestLevel: number = BumpLevels.dep; const nodes = ast.children; let headingStartInfo: | { index: number; depth: number; } | undefined; let endIndex: number | undefined; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (node.type === 'heading') { const stringified: string = mdastToString(node); const match = stringified.toLowerCase().match(/(major|minor|patch)/); if (match !== null) { const level = BumpLevels[match[0] as 'major' | 'minor' | 'patch']; highestLevel = Math.max(level, highestLevel); } if (headingStartInfo === undefined && stringified === version) { headingStartInfo = { index: i, depth: node.depth, }; continue; } if ( endIndex === undefined && headingStartInfo !== undefined && headingStartInfo.depth === node.depth ) { endIndex = i; break; } } } if (headingStartInfo) { ast.children = ast.children.slice(headingStartInfo.index + 1, endIndex); } return { content: processor.stringify(ast), highestLevel, }; } const createRelease = async ({ pkg, tagName, }: { pkg: Package; tagName: string; }) => { const changelog = await fs.readFile( path.join(pkg.dir, 'CHANGELOG.md'), 'utf8', ); const changelogEntry = getChangelogEntry(changelog, pkg.packageJson.version); if (!changelogEntry) { // We can find a changelog but not the entry for this version // if this is true, something has probably gone wrong throw new Error( `Could not find changelog entry for ${pkg.packageJson.name}@${pkg.packageJson.version}`, ); } const isPrerelease = pkg.packageJson.version.includes('-'); const shouldMarkAsLatest = pkg.packageJson.name === LATEST_GITHUB_RELEASE_PACKAGE_NAME && !isPrerelease; await octokit.rest.repos.createRelease({ name: tagName, tag_name: tagName, body: changelogEntry.content, prerelease: isPrerelease, make_latest: shouldMarkAsLatest ? 'true' : 'false', ...github.context.repo, }); }; const releaseAlreadyExists = async (tagName: string) => { try { await octokit.rest.repos.getReleaseByTag({ ...github.context.repo, tag: tagName, }); return true; } catch (error: unknown) { if ( typeof error === 'object' && error !== null && 'status' in error && (error as { status?: number }).status === 404 ) { return false; } throw error; } }; const ensureReleaseForPackage = async (pkg: Package) => { const tagName = `${pkg.packageJson.name}@${pkg.packageJson.version}`; await octokit.rest.git .createRef({ ...github.context.repo, ref: `refs/tags/${tagName}`, sha: github.context.sha, }) .catch((error: unknown) => { core.warning(`Failed to create tag ${tagName}: ${error}`); }); if (await releaseAlreadyExists(tagName)) { console.log(`Release for ${tagName} already exists, skipping`); return; } console.log(`Creating release for ${tagName}`); await createRelease({ pkg, tagName }); }; const isTruthyEnv = (value: string | undefined) => value !== undefined && /^(1|true|yes)$/i.test(value); // changesets publishes every package whose version isn't on npm yet, but it // does so in no particular order and keeps going when one fails. That can leave // `@react-email/components` published while a component it re-exports is not // (see GitHub #3045). The pipeline below replaces `changeset publish`: it // publishes in dependency order and, when a package fails, skips its dependents // so a broken dependency can never produce a broken dependent. /** * Parses the output of `npm view versions --json`. A missing package * (npm 404) means nothing is published yet, so we return an empty list. Any * other failure is re-thrown — a flaky registry must never be mistaken for an * unpublished package, which would make us republish or mis-tag. */ export function parseNpmVersions( packageName: string, result: Pick, ): string[] { if (result.exitCode === 0) { const stdout = result.stdout.trim(); if (stdout.length === 0) { return []; } const parsed = JSON.parse(stdout) as string | string[]; return Array.isArray(parsed) ? parsed : [parsed]; } const output = `${result.stderr}\n${result.stdout}`; if ( /\bE404\b|404 Not Found|is not in (?:this|the npm) registry/i.test(output) ) { return []; } throw new Error( `Failed to read published versions for ${packageName}: ${ result.stderr.trim() || `npm exited with code ${result.exitCode}` }`, ); } const getPublishedVersions = async (packageName: string): Promise => { const result = await getExecOutput( 'npm', ['view', packageName, 'versions', '--json'], { ignoreReturnCode: true, silent: true }, ); return parseNpmVersions(packageName, result); }; const prereleaseTagOf = (version: string): string | undefined => version.split('-')[1]?.split('.')[0]; /** * The npm dist-tag for a version, taken from the version itself: a plain * `x.y.z` release goes to `latest`, while a prerelease goes to its prerelease * identifier (`6.0.0-canary.2` -> `canary`, `1.0.0-alpha.7` -> `alpha`). This * matches the dist-tags this repo already publishes and guarantees a prerelease * never overwrites `latest`. */ export function selectDistTag(version: string): string { return prereleaseTagOf(version) ?? 'latest'; } type PackageJsonWithPeerMeta = Package['packageJson'] & { peerDependenciesMeta?: Record; }; /** * Runtime dependencies of a package: regular, optional, and required peer * dependencies. devDependencies are excluded — they needn't exist on npm for * the package to install, so they must not constrain publish order. */ const runtimeDependenciesOf = ( packageJson: PackageJsonWithPeerMeta, ): string[] => { const { peerDependencies = {}, peerDependenciesMeta = {} } = packageJson; const requiredPeers = Object.keys(peerDependencies).filter( (name) => peerDependenciesMeta[name]?.optional !== true, ); return [ ...Object.keys(packageJson.dependencies ?? {}), ...Object.keys(packageJson.optionalDependencies ?? {}), ...requiredPeers, ]; }; /** * Maps each package to the set of packages it depends on *within the publish * set*. External dependencies (already on npm) don't affect publish order. */ export function buildPublishGraph( packages: Package[], ): Map> { const inSet = new Set(packages.map((pkg) => pkg.packageJson.name)); return new Map( packages.map((pkg) => [ pkg.packageJson.name, new Set( runtimeDependenciesOf(pkg.packageJson).filter((name) => inSet.has(name), ), ), ]), ); } /** * Orders packages so each one comes after the dependencies it has within the * set (leaves first). Throws if the graph contains a cycle. */ export function topologicalSort(graph: Map>): string[] { const remaining = new Map( [...graph].map(([name, deps]) => [name, new Set(deps)]), ); const ordered: string[] = []; while (remaining.size > 0) { const ready = [...remaining] .filter(([, deps]) => deps.size === 0) .map(([name]) => name); if (ready.length === 0) { throw new Error( `Cannot publish: dependency cycle between ${[...remaining.keys()].join(', ')}`, ); } for (const name of ready) { ordered.push(name); remaining.delete(name); } for (const deps of remaining.values()) { for (const name of ready) { deps.delete(name); } } } return ordered; } /** * Publishes packages in the given order, containing failures: if a package * errors — or any of its in-set dependencies already failed — it is marked * failed and skipped, so its own dependents are skipped in turn. */ export async function publishInOrder( ordered: string[], graph: Map>, publish: (name: string) => Promise, ): Promise<{ published: string[]; failed: string[] }> { const published: string[] = []; const failed = new Set(); for (const name of ordered) { const failedDependency = [...(graph.get(name) ?? [])].find((dep) => failed.has(dep), ); if (failedDependency !== undefined) { console.log( `Skipping ${name}: its dependency ${failedDependency} failed to publish`, ); failed.add(name); continue; } if (await publish(name)) { published.push(name); } else { failed.add(name); } } return { published, failed: [...failed] }; } interface PublishTarget { pkg: Package; distTag: string; } /** * Determines which non-private packages still need publishing (version not yet * on npm) and the order to publish them in, with the dist-tag for each. */ const planPublish = async (options: { packages: Package[]; getPublishedVersions: (name: string) => Promise; }): Promise<{ ordered: PublishTarget[]; graph: Map> }> => { const targets: PublishTarget[] = []; for (const pkg of options.packages) { if (pkg.packageJson.private === true) { continue; } const publishedVersions = await options.getPublishedVersions( pkg.packageJson.name, ); if (publishedVersions.includes(pkg.packageJson.version)) { console.log( `${pkg.packageJson.name}@${pkg.packageJson.version} is already published, skipping`, ); continue; } targets.push({ pkg, distTag: selectDistTag(pkg.packageJson.version), }); } const graph = buildPublishGraph(targets.map((target) => target.pkg)); const byName = new Map( targets.map((target) => [target.pkg.packageJson.name, target]), ); const ordered = topologicalSort(graph).map((name) => byName.get(name)!); return { ordered, graph }; }; /** * Full publish pipeline: figure out what needs publishing, in what order, then * publish with fail-fast. Returns the names that were published and that failed. */ export async function publishPackages(options: { packages: Package[]; getPublishedVersions: (name: string) => Promise; publish: (pkg: Package, distTag: string) => Promise; }): Promise<{ published: string[]; failed: string[] }> { const { ordered, graph } = await planPublish(options); if (ordered.length === 0) { console.log('No packages need publishing'); return { published: [], failed: [] }; } console.log(`Publishing ${ordered.length} package(s) in dependency order:`); for (const { pkg, distTag } of ordered) { console.log( ` ${pkg.packageJson.name}@${pkg.packageJson.version} -> ${distTag}`, ); } const byName = new Map( ordered.map((target) => [target.pkg.packageJson.name, target]), ); return publishInOrder( ordered.map((target) => target.pkg.packageJson.name), graph, (name) => { const { pkg, distTag } = byName.get(name)!; return options.publish(pkg, distTag); }, ); } /** * Prints what `publishPackages` would do, without publishing anything. */ export async function printPublishPlan(options: { packages: Package[]; getPublishedVersions: (name: string) => Promise; }): Promise { const { ordered, graph } = await planPublish(options); if (ordered.length === 0) { console.log('No packages need publishing'); return; } console.log( `Would publish ${ordered.length} package(s) in dependency order:`, ); for (const { pkg, distTag } of ordered) { const deps = [...(graph.get(pkg.packageJson.name) ?? [])]; const dependsOn = deps.length > 0 ? ` (depends on ${deps.join(', ')})` : ''; console.log( ` ${pkg.packageJson.name}@${pkg.packageJson.version} -> ${distTag}${dependsOn}`, ); } } const publishPackage = async ( pkg: Package, distTag: string, env: Record, ): Promise => { try { await exec( 'pnpm', [ 'publish', '--no-git-checks', '--access', 'public', '--provenance', '--tag', distTag, ], { cwd: pkg.dir, env }, ); console.log(`Published ${pkg.packageJson.name}@${pkg.packageJson.version}`); return true; } catch (error) { core.error( `Failed to publish ${pkg.packageJson.name}@${pkg.packageJson.version}: ${error}`, ); return false; } }; const main = async () => { const isDryRun = process.argv.includes('--dry-run'); const skipNpmPublish = isTruthyEnv(process.env.SKIP_NPM_PUBLISH) || process.argv.includes('--skip-npm-publish') || process.argv.includes('--only-github-releases'); if (!isDryRun && (!github.context.repo.owner || !github.context.repo.repo)) { throw new Error( 'GitHub context is missing. This script must be run in a GitHub Actions workflow.', ); } const preState = await readPreState(process.cwd()); // Branch / prerelease gating (irrelevant to dry runs and GitHub-only runs). if (!isDryRun && !skipNpmPublish) { const { ref } = github.context; if (ref === 'refs/heads/canary') { console.log('Detected canary branch, checking prerelease state'); if (preState?.mode !== 'pre') { console.log( 'Was not in prerelease, skipping automated release. To release this you should rebase onto main', ); return; } console.log('Is in prerelease mode, proceeding with automated release'); } else if (ref === 'refs/heads/main') { console.log('Detected main branch, proceeding with stable release'); } else { throw new Error( `Unexpected branch/ref: ${ref}. Expected refs/heads/main or refs/heads/canary`, ); } } const { packages } = await getPackages(process.cwd()); const packagesByName = new Map( packages.map((pkg) => [pkg.packageJson.name, pkg]), ); let releasedPackages: Package[] = []; let failedPackages: string[] = []; if (skipNpmPublish) { console.log( 'SKIP_NPM_PUBLISH is set, skipping npm publish and only ensuring GitHub releases exist', ); releasedPackages = packages.filter( (pkg) => pkg.packageJson.private !== true, ); } else if (isDryRun) { // A dry run only inspects the registry and prints the plan, so there's no // need to build anything first. await printPublishPlan({ packages, getPublishedVersions }); return; } else { // Build every publishable package before touching the registry. await exec('pnpm', ['release:build']); // https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions // Provenance itself is requested per-publish via `pnpm publish --provenance`. const npmIdToken = await core.getIDToken('npm:registry.npmjs.org'); const publishEnv: Record = { ...(process.env as Record), NPM_ID_TOKEN: npmIdToken, }; const { published, failed } = await publishPackages({ packages, getPublishedVersions, publish: (pkg, distTag) => publishPackage(pkg, distTag, publishEnv), }); releasedPackages = published.map((name) => packagesByName.get(name)!); failedPackages = failed; if (failedPackages.length > 0) { core.error( `Failed to publish ${failedPackages.length} package(s): ${failedPackages.join(', ')}`, ); } } await exec('git', ['config', 'user.name', `"github-actions[bot]"`]); await exec('git', [ 'config', 'user.email', `"41898282+github-actions[bot]@users.noreply.github.com"`, ]); for (const pkg of releasedPackages) { await ensureReleaseForPackage(pkg); } // Fail the workflow so a broken release is never silently accepted. if (failedPackages.length > 0) { process.exitCode = 1; } }; // Only run the release flow when executed directly, so the test suite can // import the helpers above without triggering a real release. if ( process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href ) { await main(); }