From 54169e2403d2d4a1bf97cb4b194a49ea4e96f50d Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Wed, 27 May 2026 17:01:26 -0300 Subject: [PATCH 1/2] fix(ci): publish packages in dependency order, fail fast on failure Replace `changeset publish` (which publishes in arbitrary order and keeps going when one package fails) with an explicit pipeline in release.mts that: - publishes packages in topological (dependency) order - skips a package's dependents when it fails, so a broken dependency can never produce a broken dependent published against a version that isn't on npm - derives each npm dist-tag from the version itself (x.y.z -> latest, a prerelease -> its own tag), so a prerelease can never overwrite latest - adds a --dry-run flag that prints the publish plan without publishing - exits non-zero when any package fails Adds unit tests (scripts/release.spec.mts) for the pure pipeline helpers and wires them into the tests workflow, since scripts/ isn't a workspace and turbo run test doesn't cover it. --- .github/workflows/tests.yml | 4 + package.json | 2 +- scripts/release.mts | 432 +++++++++++++++++++++++++++++++----- scripts/release.spec.mts | 249 +++++++++++++++++++++ scripts/tsconfig.json | 5 +- 5 files changed, 631 insertions(+), 61 deletions(-) create mode 100644 scripts/release.spec.mts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4ebbf028e9..7b115f01dc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -49,3 +49,7 @@ jobs: SPAM_ASSASSIN_PORT: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.SPAM_ASSASSIN_PORT || '' }} TURBO_TOKEN: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.TURBO_TOKEN || '' }} TURBO_TEAM: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && secrets.TURBO_TEAM || '' }} + # The release script lives outside the workspaces, so `turbo run test` + # doesn't cover it. Run its unit tests explicitly. + - name: Run release script tests + run: pnpm vitest run scripts/release.spec.mts diff --git a/package.json b/package.json index 7e92a0c5c1..e267c26791 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "canary:exit": "changeset pre exit", "lint": "biome check", "lint:fix": "biome check --write .", - "release": "turbo run build --filter=./packages/* && pnpm changeset publish", + "release": "turbo run build --filter=./packages/*", "test": "turbo run test", "test:watch": "turbo run test:watch", "typecheck": "turbo run typecheck", diff --git a/scripts/release.mts b/scripts/release.mts index 762ab3d36c..290cdf68d9 100644 --- a/scripts/release.mts +++ b/scripts/release.mts @@ -2,15 +2,18 @@ // 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 { exec, getExecOutput } from '@actions/exec'; +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'; -const octokit = github.getOctokit(process.env.GITHUB_TOKEN || ''); +// `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'; @@ -149,42 +152,324 @@ const ensureReleaseForPackage = async (pkg: Package) => { const isTruthyEnv = (value: string | undefined) => value !== undefined && /^(1|true|yes)$/i.test(value); -(async () => { - if (!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.', - ); +// 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 skipNpmPublish = - isTruthyEnv(process.env.SKIP_NPM_PUBLISH) || - process.argv.includes('--skip-npm-publish') || - process.argv.includes('--only-github-releases'); + const output = `${result.stderr}\n${result.stdout}`; + if ( + /\bE404\b|404 Not Found|is not in (?:this|the npm) registry/i.test(output) + ) { + return []; + } - const { packages } = await getPackages(process.cwd()); - const publishablePackages = packages.filter( - (pkg) => pkg.packageJson.private !== true, + throw new Error( + `Failed to read published versions for ${packageName}: ${ + result.stderr.trim() || `npm exited with code ${result.exitCode}` + }`, ); +} - let releasedPackages: Package[]; +const getPublishedVersions = async (packageName: string): Promise => { + const result = await getExecOutput( + 'npm', + ['view', packageName, 'versions', '--json'], + { ignoreReturnCode: true, silent: true }, + ); + return parseNpmVersions(packageName, result); +}; - if (skipNpmPublish) { - console.log( - 'SKIP_NPM_PUBLISH is set, skipping npm publish and only ensuring GitHub releases exist', +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), ); - releasedPackages = publishablePackages; - } else { - // https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions - const npmIdToken = await core.getIDToken('npm:registry.npmjs.org'); + if (failedDependency !== undefined) { + console.log( + `Skipping ${name}: its dependency ${failedDependency} failed to publish`, + ); + failed.add(name); + continue; + } - const isCanaryBranch = github.context.ref === 'refs/heads/canary'; - const isMainBranch = github.context.ref === 'refs/heads/main'; + if (await publish(name)) { + published.push(name); + } else { + failed.add(name); + } + } + + return { published, failed: [...failed] }; +} - if (isCanaryBranch) { +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( - 'Detected running in canary branch, checking prerelease state', + `${pkg.packageJson.name}@${pkg.packageJson.version} is already published, skipping`, ); - const preState = await readPreState(process.cwd()); + 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', '--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', @@ -192,45 +477,60 @@ const isTruthyEnv = (value: string | undefined) => return; } console.log('Is in prerelease mode, proceeding with automated release'); - } else if (isMainBranch) { - console.log( - 'Detected running in main branch, proceeding with stable release', - ); + } else if (ref === 'refs/heads/main') { + console.log('Detected main branch, proceeding with stable release'); } else { throw new Error( - `Unexpected branch/ref: ${github.context.ref}. Expected refs/heads/main or refs/heads/canary`, + `Unexpected branch/ref: ${ref}. Expected refs/heads/main or refs/heads/canary`, ); } + } - const changesetPublishOutput = await getExecOutput('pnpm', ['release'], { - env: { - ...process.env, - NPM_ID_TOKEN: npmIdToken, - // https://docs.npmjs.com/generating-provenance-statements#using-third-party-package-publishing-tools - NPM_CONFIG_PROVENANCE: 'true', - }, - }); + const { packages } = await getPackages(process.cwd()); + const packagesByName = new Map( + packages.map((pkg) => [pkg.packageJson.name, pkg]), + ); - const newTagRegex = /New tag:\s+(@[^/]+\/[^@]+|[^/]+)@([^\s]+)/; - const packagesByName = new Map( - packages.map((x) => [x.packageJson.name, x]), + 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']); - releasedPackages = []; - for (const line of changesetPublishOutput.stdout.split('\n')) { - const match = line.match(newTagRegex); - if (match === null) { - continue; - } - const pkgName = match[1]; - const pkg = packagesByName.get(pkgName); - if (pkg === undefined) { - throw new Error( - `Package "${pkgName}" not found.` + - 'This is probably a bug in the action, please open an issue', - ); - } - releasedPackages.push(pkg); + // https://docs.npmjs.com/generating-provenance-statements#publishing-packages-with-provenance-via-github-actions + const npmIdToken = await core.getIDToken('npm:registry.npmjs.org'); + const publishEnv: Record = { + ...(process.env as Record), + NPM_ID_TOKEN: npmIdToken, + // https://docs.npmjs.com/generating-provenance-statements#using-third-party-package-publishing-tools + NPM_CONFIG_PROVENANCE: 'true', + }; + + 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(', ')}`, + ); } } @@ -243,4 +543,18 @@ const isTruthyEnv = (value: string | undefined) => 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(); +} diff --git a/scripts/release.spec.mts b/scripts/release.spec.mts new file mode 100644 index 0000000000..273bed4bf9 --- /dev/null +++ b/scripts/release.spec.mts @@ -0,0 +1,249 @@ +// @vitest-environment node +import type { Package } from '@manypkg/get-packages'; +import { describe, expect, it, vi } from 'vitest'; +import { + buildPublishGraph, + parseNpmVersions, + publishInOrder, + publishPackages, + selectDistTag, + topologicalSort, +} from './release.mts'; + +type PackageJson = Package['packageJson'] & { + peerDependenciesMeta?: Record; +}; + +const pkg = (name: string, packageJson: Partial = {}): Package => + ({ + dir: `/fake/${name}`, + packageJson: { name, version: '1.0.0', ...packageJson }, + }) as Package; + +describe('parseNpmVersions', () => { + it('parses a single published version', () => { + expect( + parseNpmVersions('pkg', { exitCode: 0, stdout: '"1.0.0"\n', stderr: '' }), + ).toEqual(['1.0.0']); + }); + + it('parses a list of published versions', () => { + expect( + parseNpmVersions('pkg', { + exitCode: 0, + stdout: '["1.0.0","1.0.1"]', + stderr: '', + }), + ).toEqual(['1.0.0', '1.0.1']); + }); + + it('treats a npm 404 as an unpublished package', () => { + expect( + parseNpmVersions('pkg', { + exitCode: 1, + stdout: '', + stderr: + 'npm error code E404\nnpm error 404 Not Found - GET https://registry.npmjs.org/pkg', + }), + ).toEqual([]); + }); + + it('throws on any other registry error', () => { + expect(() => + parseNpmVersions('pkg', { + exitCode: 1, + stdout: '', + stderr: 'npm error code E429\nToo Many Requests', + }), + ).toThrow('Failed to read published versions for pkg'); + }); +}); + +describe('selectDistTag', () => { + it('tags a plain x.y.z release as latest', () => { + expect(selectDistTag('6.5.0')).toBe('latest'); + }); + + it('tags a prerelease with its prerelease identifier', () => { + expect(selectDistTag('6.0.0-canary.2')).toBe('canary'); + expect(selectDistTag('1.0.0-alpha.7')).toBe('alpha'); + expect(selectDistTag('0.0.0-experimental.47')).toBe('experimental'); + }); + + it('never tags a prerelease as latest', () => { + expect(selectDistTag('6.4.0-local.0')).toBe('local'); + }); +}); + +describe('buildPublishGraph', () => { + it('keeps only dependencies within the publish set', () => { + const graph = buildPublishGraph([ + pkg('a'), + pkg('b', { dependencies: { a: '1.0.0', react: '19.0.0' } }), + ]); + expect(graph.get('a')).toEqual(new Set()); + expect(graph.get('b')).toEqual(new Set(['a'])); + }); + + it('includes optional and required peer dependencies', () => { + const graph = buildPublishGraph([ + pkg('a'), + pkg('b'), + pkg('c', { + optionalDependencies: { a: '1.0.0' }, + peerDependencies: { b: '1.0.0' }, + }), + ]); + expect(graph.get('c')).toEqual(new Set(['a', 'b'])); + }); + + it('ignores optional peer dependencies and devDependencies', () => { + const graph = buildPublishGraph([ + pkg('a'), + pkg('b'), + pkg('c', { + devDependencies: { a: '1.0.0' }, + peerDependencies: { b: '1.0.0' }, + peerDependenciesMeta: { b: { optional: true } }, + }), + ]); + expect(graph.get('c')).toEqual(new Set()); + }); +}); + +describe('topologicalSort', () => { + it('orders dependencies before their dependents', () => { + const graph = new Map([ + ['components', new Set(['button', 'text'])], + ['button', new Set()], + ['text', new Set()], + ]); + const ordered = topologicalSort(graph); + expect(ordered.indexOf('button')).toBeLessThan( + ordered.indexOf('components'), + ); + expect(ordered.indexOf('text')).toBeLessThan(ordered.indexOf('components')); + }); + + it('throws when the graph has a cycle', () => { + const graph = new Map([ + ['a', new Set(['b'])], + ['b', new Set(['a'])], + ]); + expect(() => topologicalSort(graph)).toThrow('dependency cycle'); + }); +}); + +describe('publishInOrder', () => { + it('publishes every package when none fail', async () => { + const graph = new Map([ + ['button', new Set()], + ['components', new Set(['button'])], + ]); + const publish = vi.fn().mockResolvedValue(true); + + const result = await publishInOrder( + ['button', 'components'], + graph, + publish, + ); + + expect(result).toEqual({ + published: ['button', 'components'], + failed: [], + }); + expect(publish).toHaveBeenCalledTimes(2); + }); + + it('skips dependents when a dependency fails (the #3045 fix)', async () => { + const graph = new Map([ + ['button', new Set()], + ['text', new Set()], + ['components', new Set(['button', 'text'])], + ]); + const publish = vi.fn(async (name: string) => name !== 'button'); + + const result = await publishInOrder( + ['button', 'text', 'components'], + graph, + publish, + ); + + expect(result.published).toEqual(['text']); + expect(result.failed).toEqual(['button', 'components']); + // components must never be attempted once its dependency failed. + expect(publish).not.toHaveBeenCalledWith('components'); + }); + + it('propagates failures transitively down the dependency chain', async () => { + const graph = new Map([ + ['a', new Set()], + ['b', new Set(['a'])], + ['c', new Set(['b'])], + ]); + const publish = vi.fn(async (name: string) => name !== 'a'); + + const result = await publishInOrder(['a', 'b', 'c'], graph, publish); + + expect(result.published).toEqual([]); + expect(result.failed).toEqual(['a', 'b', 'c']); + expect(publish).toHaveBeenCalledTimes(1); + }); +}); + +describe('publishPackages', () => { + it('publishes only unpublished versions, in dependency order', async () => { + const packages = [ + pkg('@react-email/components', { + name: '@react-email/components', + dependencies: { '@react-email/button': '1.0.0' }, + }), + pkg('@react-email/button', { name: '@react-email/button' }), + pkg('@react-email/private', { + name: '@react-email/private', + private: true, + }), + ]; + const publishOrder: string[] = []; + + const result = await publishPackages({ + packages, + // button is new, components already has this version on npm. + getPublishedVersions: async (name) => + name === '@react-email/components' ? ['1.0.0'] : [], + publish: async (target) => { + publishOrder.push(target.packageJson.name); + return true; + }, + }); + + expect(publishOrder).toEqual(['@react-email/button']); + expect(result.published).toEqual(['@react-email/button']); + expect(result.failed).toEqual([]); + }); + + it('passes the version-derived dist-tag through to publish', async () => { + const tags: Record = {}; + + await publishPackages({ + packages: [ + pkg('@react-email/button', { + name: '@react-email/button', + version: '1.0.0-canary.3', + }), + pkg('@react-email/render', { + name: '@react-email/render', + version: '2.0.0', + }), + ], + getPublishedVersions: async () => [], + publish: async (target, distTag) => { + tags[target.packageJson.name] = distTag; + return true; + }, + }); + + expect(tags['@react-email/button']).toBe('canary'); + expect(tags['@react-email/render']).toBe('latest'); + }); +}); diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index b84036cf3e..88f2bb9a3e 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -4,7 +4,10 @@ "lib": ["ESNext"], "types": ["node"], "module": "esnext", - "moduleResolution": "bundler" + "moduleResolution": "bundler", + "target": "esnext", + "noEmit": true, + "allowImportingTsExtensions": true }, "include": ["./*.ts", "./*.mts"] } From e8a3a5c74c4d3f0ccab6088c59703a8a4295865a Mon Sep 17 00:00:00 2001 From: Felipe Freitag Date: Wed, 27 May 2026 17:11:36 -0300 Subject: [PATCH 2/2] fix(ci): request npm provenance explicitly via pnpm publish --provenance pnpm publish has no implicit provenance; the NPM_CONFIG_PROVENANCE env var carried over from the changeset publish path is replaced with the documented --provenance flag so each package is published with provenance attestation. --- scripts/release.mts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/scripts/release.mts b/scripts/release.mts index 290cdf68d9..c57a641b09 100644 --- a/scripts/release.mts +++ b/scripts/release.mts @@ -437,7 +437,15 @@ const publishPackage = async ( try { await exec( 'pnpm', - ['publish', '--no-git-checks', '--access', 'public', '--tag', distTag], + [ + 'publish', + '--no-git-checks', + '--access', + 'public', + '--provenance', + '--tag', + distTag, + ], { cwd: pkg.dir, env }, ); console.log(`Published ${pkg.packageJson.name}@${pkg.packageJson.version}`); @@ -511,12 +519,11 @@ const main = async () => { await exec('pnpm', ['release']); // 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, - // https://docs.npmjs.com/generating-provenance-statements#using-third-party-package-publishing-tools - NPM_CONFIG_PROVENANCE: 'true', }; const { published, failed } = await publishPackages({