diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 000000000..877c80240 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,48 @@ +name: Setup node and pnpm +description: Configure the Node.js and pnpm versions + +inputs: + node-version: + description: 'The Node.js version to use' + required: true + default: 18.20.2 + pnpm-version: + description: 'The pnpm version to use' + required: true + default: 8.15.7 + +runs: + using: composite + steps: + # https://github.com/actions/virtual-environments/issues/1187 + - name: tune linux network + shell: bash + run: sudo ethtool -K eth0 tx off rx off + + - name: Setup Node@${{ inputs.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + + - name: Install pnpm + uses: pnpm/action-setup@v3 + with: + version: ${{ inputs.pnpm-version }} + run_install: false + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + pnpm-store- + pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + + - shell: bash + run: pnpm install diff --git a/.github/workflows/release-canary.yml b/.github/workflows/release-canary.yml new file mode 100644 index 000000000..982fb90f3 --- /dev/null +++ b/.github/workflows/release-canary.yml @@ -0,0 +1,33 @@ +name: release-canary + +on: + workflow_dispatch: + branches: + - beta + +env: + NODE_VERSION: 18.20.2 + PNPM_VERSION: 8.15.7 + DO_NOT_TRACK: 1 # Disable Turbopack telemetry + NEXT_TELEMETRY_DISABLED: 1 # Disable Next telemetry + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup + uses: ./.github/actions/setup + with: + node-version: ${{ env.NODE_VERSION }} + pnpm-version: ${{ env.PNPM_VERSION }} + - name: Load npm token + run: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" >> ~/.npmrc + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Canary release script + # dry run hard-coded to true for testing and no npm token provided + run: pnpm tsx ./scripts/publish-canary.ts + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/scripts/lib/getPackageRegistryVersions.ts b/scripts/lib/getPackageRegistryVersions.ts index faea119ea..f931cb468 100644 --- a/scripts/lib/getPackageRegistryVersions.ts +++ b/scripts/lib/getPackageRegistryVersions.ts @@ -2,12 +2,12 @@ import chalk from 'chalk' import pLimit from 'p-limit' import { getPackageDetails } from './getPackageDetails.js' -import { packageWhitelist } from './whitelist.js' +import { packagePublishList } from './publishList.js' const npmRequestLimit = pLimit(40) export const getPackageRegistryVersions = async (): Promise => { - const packageDetails = await getPackageDetails(packageWhitelist) + const packageDetails = await getPackageDetails(packagePublishList) const results = await Promise.all( packageDetails.map(async (pkg) => diff --git a/scripts/lib/getWorkspace.ts b/scripts/lib/getWorkspace.ts new file mode 100644 index 000000000..b20207de0 --- /dev/null +++ b/scripts/lib/getWorkspace.ts @@ -0,0 +1,231 @@ +import type { ReleaseType } from 'semver' + +import { execSync } from 'child_process' +import execa from 'execa' +import fse from 'fs-extra' +import { fileURLToPath } from 'node:url' +import pLimit from 'p-limit' +import path from 'path' +import semver from 'semver' + +import { getPackageDetails } from './getPackageDetails.js' +import { packagePublishList } from './publishList.js' + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) +const projectRoot = path.resolve(dirname, '../../') +const rootPackageJsonPath = path.resolve(projectRoot, 'package.json') +const npmPublishLimit = pLimit(5) +const cwd = path.resolve(dirname, '../../') + +const execaOpts: execa.Options = { stdio: 'inherit' } + +type PackageDetails = { + /** Name in package.json / npm registry */ + name: string + /** Full path to package relative to project root */ + packagePath: `packages/${string}` + /** Short name is the directory name */ + shortName: string + /** Version in package.json */ + version: string +} + +type PackageReleaseType = 'canary' | ReleaseType + +type PublishResult = { + name: string + success: boolean + details?: string +} + +type PublishOpts = { + dryRun?: boolean + tag?: 'beta' | 'canary' | 'latest' +} + +type Workspace = { + version: () => Promise + tag: string + packages: PackageDetails[] + showVersions: () => Promise + bumpVersion: (type: PackageReleaseType) => Promise + build: () => Promise + publish: (opts: PublishOpts) => Promise + publishSync: (opts: PublishOpts) => Promise +} + +export const getWorkspace = async () => { + const build = async () => { + await execa('pnpm', ['install'], execaOpts) + + const buildResult = await execa('pnpm', ['build:all', '--output-logs=errors-only'], execaOpts) + if (buildResult.exitCode !== 0) { + console.error('Build failed') + console.log(buildResult.stderr) + throw new Error('Build failed') + } + } + + // Publish one package at a time + const publishSync: Workspace['publishSync'] = async ({ dryRun, tag = 'canary' }) => { + const packageDetails = await getPackageDetails(packagePublishList) + const results: PublishResult[] = [] + for (const pkg of packageDetails) { + const res = await publishSinglePackage(pkg, { dryRun, tag }) + results.push(res) + } + + console.log(`\n\nResults:\n`) + + console.log( + results + .map((result) => { + if (!result.success) { + console.error(result.details) + return ` ❌ ${result.name}` + } + return ` ✅ ${result.name}` + }) + .join('\n') + '\n', + ) + } + + const publish = async () => { + const packageDetails = await getPackageDetails(packagePublishList) + const results = await Promise.allSettled( + packageDetails.map((pkg) => publishPackageThrottled(pkg, { dryRun: true })), + ) + + console.log(`\n\nResults:\n`) + + console.log( + results + .map((result) => { + if (result.status === 'rejected') { + console.error(result.reason) + return ` ❌ ${String(result.reason)}` + } + const { name, success, details } = result.value + let summary = ` ${success ? '✅' : '❌'} ${name}` + if (details) { + summary += `\n ${details}\n` + } + return summary + }) + .join('\n') + '\n', + ) + } + + const showVersions = async () => { + const { packages, version } = await getCurrentPackageState() + console.log(`\n Version: ${version}\n`) + console.log(` Changes (${packages.length} packages):\n`) + console.log(`${packages.map((p) => ` - ${p.name.padEnd(32)} ${p.version}`).join('\n')}\n`) + } + + const setVersion = async (version: string) => { + const rootPackageJson = await fse.readJSON(rootPackageJsonPath) + rootPackageJson.version = version + await fse.writeJSON(rootPackageJsonPath, rootPackageJson, { spaces: 2 }) + + const packageJsons = await getPackageDetails(packagePublishList) + await Promise.all( + packageJsons.map(async (pkg) => { + const packageJson = await fse.readJSON(`${pkg.packagePath}/package.json`) + packageJson.version = version + await fse.writeJSON(`${pkg.packagePath}/package.json`, packageJson, { spaces: 2 }) + }), + ) + } + + const bumpVersion = async (bumpType: PackageReleaseType) => { + const { version: monorepoVersion, packages: packageDetails } = await getCurrentPackageState() + + let nextReleaseVersion + if (bumpType === 'canary') { + const hash = execSync('git rev-parse --short HEAD', { encoding: 'utf8' }).trim().slice(0, 7) + nextReleaseVersion = semver.inc(monorepoVersion, 'patch') + `-canary.${hash}` + } else { + nextReleaseVersion = semver.inc(monorepoVersion, bumpType) + } + + console.log(`\n Version: ${monorepoVersion} => ${nextReleaseVersion}\n`) + console.log(` Bump: ${bumpType}`) + console.log(` Changes (${packageDetails.length} packages):\n`) + console.log( + `${packageDetails.map((p) => ` - ${p.name.padEnd(32)} ${p.version} => ${nextReleaseVersion}`).join('\n')}\n`, + ) + + await setVersion(nextReleaseVersion) + } + + const workspace: Workspace = { + version: async () => (await fse.readJSON(rootPackageJsonPath)).version, + tag: 'latest', + packages: await getPackageDetails(packagePublishList), + showVersions, + bumpVersion, + build, + publish, + publishSync, + } + + return workspace +} + +async function getCurrentPackageState(): Promise<{ + packages: PackageDetails[] + version: string +}> { + const packageDetails = await getPackageDetails(packagePublishList) + const rootPackageJson = await fse.readJSON(rootPackageJsonPath) + return { packages: packageDetails, version: rootPackageJson.version } +} + +/** Publish with promise concurrency throttling */ +async function publishPackageThrottled(pkg: PackageDetails, opts?: { dryRun?: boolean }) { + const { dryRun = true } = opts ?? {} + return npmPublishLimit(() => publishSinglePackage(pkg, { dryRun })) +} + +async function publishSinglePackage(pkg: PackageDetails, opts: PublishOpts) { + console.log(`🚀 ${pkg.name} publishing...`) + + const { dryRun, tag = 'canary' } = opts + + try { + const cmdArgs = ['publish', '-C', pkg.packagePath, '--no-git-checks', '--tag', tag] + if (dryRun) { + cmdArgs.push('--dry-run') + } + const { exitCode, stderr } = await execa('pnpm', cmdArgs, { + cwd, + // stdio: ['ignore', 'ignore', 'pipe'], + stdio: 'inherit', + }) + + if (exitCode !== 0) { + console.log(`\n\n❌ ${pkg.name} ERROR: pnpm publish failed\n\n${stderr}`) + + return { + name: pkg.name, + success: false, + details: `Exit Code: ${exitCode}, stderr: ${stderr}`, + } + } + + console.log(`✅ ${pkg.name} published`) + return { name: pkg.name, success: true } + } catch (err: unknown) { + console.error(err) + return { + name: pkg.name, + success: false, + details: + err instanceof Error + ? `Error publishing ${pkg.name}: ${err.message}` + : `Unexpected error publishing ${pkg.name}: ${String(err)}`, + } + } +} diff --git a/scripts/lib/whitelist.ts b/scripts/lib/publishList.ts similarity index 62% rename from scripts/lib/whitelist.ts rename to scripts/lib/publishList.ts index 1415be2da..fd4b6fe3d 100644 --- a/scripts/lib/whitelist.ts +++ b/scripts/lib/publishList.ts @@ -1,5 +1,9 @@ -// Update this list with any packages to publish -export const packageWhitelist = [ +/** + * Packages that should be published to NPM + * + * Note that this does not include all packages in the monorepo + */ +export const packagePublishList = [ 'payload', 'translations', 'ui', @@ -32,5 +36,11 @@ export const packageWhitelist = [ 'plugin-search', 'plugin-seo', 'plugin-stripe', - // 'plugin-sentry', + + // Unpublished + // 'plugin-sentry' + // 'storage-uploadthing', + // 'eslint-config-payload', + // 'eslint-plugin-payload', + // 'live-preview-vue', ] diff --git a/scripts/publish-canary.ts b/scripts/publish-canary.ts new file mode 100755 index 000000000..f4efc9c9f --- /dev/null +++ b/scripts/publish-canary.ts @@ -0,0 +1,72 @@ +import type { ExecSyncOptions } from 'child_process' +import type execa from 'execa' + +import chalk from 'chalk' +import { loadChangelogConfig } from 'changelogen' +import { execSync } from 'child_process' +import fse from 'fs-extra' +import minimist from 'minimist' +import { fileURLToPath } from 'node:url' +import pLimit from 'p-limit' +import path from 'path' +import prompts from 'prompts' +import semver from 'semver' + +import type { PackageDetails } from './lib/getPackageDetails.js' + +import { getPackageDetails } from './lib/getPackageDetails.js' +import { getPackageRegistryVersions } from './lib/getPackageRegistryVersions.js' +import { getWorkspace } from './lib/getWorkspace.js' +import { packagePublishList } from './lib/publishList.js' +import { getRecommendedBump } from './utils/getRecommendedBump.js' +import { updateChangelog } from './utils/updateChangelog.js' + +const npmPublishLimit = pLimit(5) + +const filename = fileURLToPath(import.meta.url) +const dirname = path.dirname(filename) +const cwd = path.resolve(dirname, '..') + +const execOpts: ExecSyncOptions = { stdio: 'inherit' } +const execaOpts: execa.Options = { stdio: 'inherit' } + +const args = minimist(process.argv.slice(2)) + +// const { +// bump = 'patch', // Semver release type +// changelog = false, // Whether to update the changelog. WARNING: This gets throttled on too many commits +// 'dry-run': dryRun, +// 'git-tag': gitTag = true, // Whether to run git tag and commit operations +// 'git-commit': gitCommit = true, // Whether to run git commit operations +// tag = 'latest', +// } = args + +const dryRun = true + +const logPrefix = dryRun ? chalk.bold.magenta('[dry-run] >') : '' + +async function main() { + const workspace = await getWorkspace() + await workspace.bumpVersion('canary') + await workspace.build() + await workspace.publishSync({ dryRun: false, tag: 'canary' }) + + header('🎉 Done!') +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) + +function abort(message = 'Abort', exitCode = 1) { + console.error(chalk.bold.red(`\n${message}\n`)) + process.exit(exitCode) +} + +function header(message: string, opts?: { enable?: boolean }) { + const { enable } = opts ?? {} + if (!enable) return + + console.log(chalk.bold.green(`${message}\n`)) +} diff --git a/scripts/release.ts b/scripts/release.ts index f6b7cc01c..400106c70 100755 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -16,7 +16,7 @@ import type { PackageDetails } from './lib/getPackageDetails.js' import { getPackageDetails } from './lib/getPackageDetails.js' import { getPackageRegistryVersions } from './lib/getPackageRegistryVersions.js' -import { packageWhitelist } from './lib/whitelist.js' +import { packagePublishList } from './lib/publishList.js' import { getRecommendedBump } from './utils/getRecommendedBump.js' import { updateChangelog } from './utils/updateChangelog.js' @@ -143,7 +143,7 @@ async function main() { console.log(chalk.gray(changelogContent) + '\n\n') console.log(`Release URL: ${chalk.dim(releaseUrl)}`) - let packageDetails = await getPackageDetails(packageWhitelist) + let packageDetails = await getPackageDetails(packagePublishList) console.log(chalk.bold(`\n Version: ${monorepoVersion} => ${chalk.green(nextReleaseVersion)}\n`)) console.log(chalk.bold.yellow(` Bump: ${bump}`))