diff --git a/package.json b/package.json index c5603dd2b7fac4..7f1a326099b895 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "@nrwl/node": "15.9.4", "@nrwl/workspace": "15.9.4", "@octokit/rest": "18.12.0", + "@phenomnomnominal/tsquery": "6.1.2", "@storybook/addon-a11y": "6.5.15", "@storybook/addon-actions": "6.5.15", "@storybook/addon-docs": "6.5.15", diff --git a/tools/generators/prepare-initial-release/README.md b/tools/generators/prepare-initial-release/README.md new file mode 100644 index 00000000000000..d79d8bdcc65592 --- /dev/null +++ b/tools/generators/prepare-initial-release/README.md @@ -0,0 +1,85 @@ +# prepare-initial-release + +Workspace Generator which automates initial release process steps for `preview` and `stable` stages of core @fluentui (v9) packages. + +### V9 Release process flow: + +```mermaid +flowchart TB + +subgraph IRP[1st release preparation for preview] +GP(nx prepare-initial-release --phase=preview) +RP(released to npm as v0.1.0) +GP--ci:npm publish-->RP +end + +subgraph IRS[1st release preparation for stable] +GS(nx prepare-initial-release --phase=stable) +RS(released to npm as v9.0.0) +GS--ci:npm publish-->RS +end + +subgraph KP[kickoff phase] + AA[bootstrap package] + AB[research] + AB[prototyping] +end + +subgraph PP[preview phase] +BA[ongoing development] +BB[uses 0.x.x semver release pattern] +BC[released to npm as *-preview] +end + +subgraph SP[stable phase] +CA[ongoing development] +CB[released as part of react-components suite] +CC[released to npm as stable 9.0.0] +end + +KP-.->IRP-.->PP-.->IRS-.->SP + + +``` + + + +- [Usage](#usage) + - [Examples](#examples) +- [Options](#options) + - [`project`](#project) + - [`phase`](#phase) + + + +## Usage + +```sh +yarn nx workspace-generator prepare-initial-release ... +``` + +Show what will be generated without writing to disk: + +```sh +yarn nx workspace-generator prepare-initial-release --dry-run +``` + +### Examples + +```sh +yarn nx workspace-generator prepare-initial-release +``` + +## Options + +#### `project` + +Type: `string` + +Library name to to be released. + +#### `phase` + +Type: `preview` | 'stable' + +Phase of npm release life cycle for monorepo package diff --git a/tools/generators/prepare-initial-release/index.spec.ts b/tools/generators/prepare-initial-release/index.spec.ts new file mode 100644 index 00000000000000..abcd4ec7a5cdce --- /dev/null +++ b/tools/generators/prepare-initial-release/index.spec.ts @@ -0,0 +1,444 @@ +import { createTreeWithEmptyWorkspace } from '@nrwl/devkit/testing'; +import { + Tree, + addProjectConfiguration, + writeJson, + joinPathFragments, + stripIndents, + readJson, + updateJson, + ProjectGraph, +} from '@nrwl/devkit'; +import * as devkit from '@nrwl/devkit'; +import * as childProcess from 'child_process'; + +import generator from './index'; +import { PackageJson, TsConfig } from '../../types'; + +const blankGraphMock = { + dependencies: {}, + nodes: {}, + externalNodes: {}, +}; +let graphMock: ProjectGraph; +const codeownersPath = joinPathFragments('.github', 'CODEOWNERS'); + +jest.mock('@nrwl/devkit', () => { + async function createProjectGraphAsyncMock(): Promise { + return graphMock; + } + + return { + ...jest.requireActual('@nrwl/devkit'), + createProjectGraphAsync: createProjectGraphAsyncMock, + }; +}); + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const noop = () => {}; + +let execSyncSpy: jest.SpyInstance; +let installPackagesTaskSpy: jest.SpyInstance; + +describe('prepare-initial-release generator', () => { + let tree: Tree; + + beforeEach(() => { + execSyncSpy = jest.spyOn(childProcess, 'execSync').mockImplementation( + // @ts-expect-error - no need to mock whole execSync API + noop, + ); + installPackagesTaskSpy = jest.spyOn(devkit, 'installPackagesTask').mockImplementation(noop); + graphMock = { + ...blankGraphMock, + }; + tree = createTreeWithEmptyWorkspace(); + tree.write(codeownersPath, `@proj/foo @org/all`); + writeJson(tree, 'tsconfig.base.v8.json', { compilerOptions: { paths: {} } }); + writeJson(tree, 'tsconfig.base.v0.json', { compilerOptions: { paths: {} } }); + writeJson(tree, 'tsconfig.base.all.json', { compilerOptions: { paths: {} } }); + }); + + it(`should throw error if executed on invalid project`, async () => { + createProject(tree, 'react-one-stable', { + root: 'packages/react-one-stable', + pkgJson: { + version: '9.0.0-alpha.0', + }, + }); + + await expect(generator(tree, { project: '@proj/react-one-stable', phase: 'stable' })).rejects.toMatchInlineSnapshot( + `[Error: @proj/react-one-stable is already prepared for stable release. Please trigger RELEASE pipeline.]`, + ); + + updateJson(tree, 'packages/react-one-stable/package.json', json => { + json.version = '9.0.0'; + return json; + }); + + await expect(generator(tree, { project: '@proj/react-one-stable', phase: 'stable' })).rejects.toMatchInlineSnapshot( + `[Error: @proj/react-one-stable is already released as stable.]`, + ); + }); + + describe(`--phase`, () => { + describe(`preview`, () => { + it(`should prepare preview package for initial release`, async () => { + const utils = { + project: createProject(tree, 'react-one-preview', { + root: 'packages/react-one-preview', + pkgJson: { + version: '0.0.0', + private: true, + }, + renameRoot: false, + }), + docsite: createProject(tree, 'public-docsite-v9', { + root: 'apps/public-docsite-v9', + pkgJson: { version: '9.0.123', private: true }, + renameRoot: false, + }), + }; + + const sideEffects = await generator(tree, { project: '@proj/react-one-preview', phase: 'preview' }); + + expect(utils.project.pkgJson()).toMatchInlineSnapshot(` + Object { + "name": "@proj/react-one-preview", + "version": "0.0.0", + } + `); + + expect(utils.docsite.pkgJson().dependencies).toEqual( + expect.objectContaining({ + '@proj/react-one-preview': '*', + }), + ); + + sideEffects(); + + expect(execSyncSpy.mock.calls.flat()).toMatchInlineSnapshot(` + Array [ + "yarn change --message 'feat: release preview package' --type minor --package @proj/react-one-preview", + ] + `); + }); + }); + + describe(`stable`, () => { + const projectName = '@proj/react-one-preview'; + type Utils = ReturnType; + const utils = { project: {} as Utils, suite: {} as Utils, docsite: {} as Utils, vrTest: {} as Utils }; + + beforeEach(() => { + utils.project = createProject(tree, 'react-one-preview', { + root: 'packages/react-one-preview', + pkgJson: { + version: '0.12.33', + }, + files: [ + { + filePath: 'packages/react-one-preview/src/index.ts', + content: stripIndents` + export {One} from './one'; + export type {OneType} from './one'; + + export {Two} from './two'; + export type {TwoType} from './two'; + `, + }, + { + filePath: 'packages/react-one-preview/stories/One.stories.tsx', + content: stripIndents` + import { One } from '@proj/react-one-preview'; + + export const App = () => { return }; + `, + }, + ], + }); + utils.suite = createProject(tree, 'react-components', { + root: 'packages/react-components/react-components', + pkgJson: { version: '9.0.1' }, + }); + utils.docsite = createProject(tree, 'public-docsite-v9', { + root: 'apps/public-docsite-v9', + pkgJson: { version: '9.0.123', private: true }, + files: [ + { + filePath: 'apps/public-docsite-v9/src/example.stories.tsx', + content: stripIndents` + import { One } from '${projectName}'; + import * as suite from '@proj/react-components'; + + export const Example = () => { return ; } + `, + }, + ], + }); + utils.vrTest = createProject(tree, 'vr-tests-react-components', { + root: 'apps/vr-tests-react-components', + pkgJson: { version: '9.0.77', private: true }, + files: [ + { + filePath: 'apps/vr-tests-react-components/src/stories/One.stories.tsx', + content: stripIndents` + import { One } from '${projectName}'; + import * as suite from '@proj/react-components'; + + export const VrTest = () => { return ; } + `, + }, + ], + }); + }); + + it(`should prepare preview package for stable release`, async () => { + const sideEffects = await generator(tree, { project: projectName, phase: 'stable' }); + + expect(utils.project.pkgJson()).toMatchInlineSnapshot(` + Object { + "name": "@proj/react-one", + "version": "9.0.0-alpha.0", + } + `); + expect(utils.project.projectJson()).toEqual( + expect.objectContaining({ + name: '@proj/react-one', + sourceRoot: 'packages/react-one/src', + }), + ); + expect(utils.project.jest()).toEqual(expect.stringContaining(`displayName: 'react-one'`)); + expect(utils.project.md.readme()).toMatchInlineSnapshot(` + "# @proj/react-one + + **React Tags components for [Fluent UI React](https://react.fluentui.dev/)** + + These are not production-ready components and **should never be used in product**. This space is useful for testing new components whose APIs might change before final release. + " + `); + expect(utils.project.md.api()).toMatchInlineSnapshot(` + "## API Report File for \\"@proj/react-one\\" + + > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + " + `); + expect(tree.read('packages/react-one/stories/One.stories.tsx', 'utf-8')).toMatchInlineSnapshot(` + "import { One } from '@proj/react-one-preview'; + + export const App = () => { + return ; + }; + " + `); + + expect(tree.children('packages/react-one-preview')).toEqual([]); + + expect(utils.project.global.codeowners()).toEqual( + expect.stringContaining('packages/react-one @org/universe @johnwick'), + ); + expect(utils.project.global.tsBase().compilerOptions.paths).toEqual( + expect.objectContaining({ + '@proj/react-one': ['packages/react-one/src/index.ts'], + }), + ); + expect(utils.project.global.tsBaseAll().compilerOptions.paths).toEqual( + expect.objectContaining({ + '@proj/react-one': ['packages/react-one/src/index.ts'], + }), + ); + + // project updates + + expect(utils.docsite.pkgJson().dependencies).not.toEqual( + expect.objectContaining({ '@proj/react-one-preview': '*' }), + ); + expect(tree.read('apps/public-docsite-v9/src/example.stories.tsx', 'utf-8')).toEqual( + expect.stringContaining(stripIndents` + import { One } from '@proj/react-components'; + import * as suite from '@proj/react-components'; + `), + ); + + const vrTestDeps = utils.vrTest.pkgJson().dependencies ?? {}; + expect(vrTestDeps).toEqual(expect.objectContaining({ '@proj/react-one': '*' })); + expect(vrTestDeps[projectName]).toEqual(undefined); + expect(tree.read('apps/vr-tests-react-components/src/stories/One.stories.tsx', 'utf-8')).toEqual( + expect.stringContaining(stripIndents` + import { One } from '@proj/react-one'; + import * as suite from '@proj/react-components'; + `), + ); + + expect(utils.suite.pkgJson().dependencies).toEqual( + expect.objectContaining({ '@proj/react-one': '9.0.0-alpha.0' }), + ); + expect(tree.read('packages/react-components/react-components/src/index.ts', 'utf-8')).toEqual( + expect.stringContaining(stripIndents` + export { One, Two } from '@proj/react-one'; + export type { OneType, TwoType } from '@proj/react-one'; + `), + ); + + sideEffects(); + + expect(execSyncSpy.mock.calls.flat()).toMatchInlineSnapshot(` + Array [ + "yarn change --message 'feat: release stable' --type minor --package @proj/react-one", + "yarn change --message 'feat: add @proj/react-one to suite' --type minor --package @proj/react-components", + ] + `); + expect(installPackagesTaskSpy).toHaveBeenCalled(); + }); + + it(`should update also other packages besides known ones if preview was used there`, async () => { + // eslint-disable-next-line @typescript-eslint/no-shadow + const utils = createProject(tree, 'react-another-app', { + root: 'apps/react-another-app', + pkgJson: { version: '9.2.0', dependencies: { '@proj/react-one-preview': '*' } }, + files: [ + { + filePath: 'apps/react-another-app/src/index.ts', + content: stripIndents` + import * as React from 'react'; + import { One } from '@proj/react-one-preview'; + `, + }, + ], + }); + + await generator(tree, { project: projectName, phase: 'stable' }); + + const dependencies = utils.pkgJson().dependencies ?? {}; + expect(dependencies[projectName]).toEqual(undefined); + expect(dependencies).toEqual( + expect.objectContaining({ + '@proj/react-components': '*', + }), + ); + + expect(tree.read('apps/react-another-app/src/index.ts', 'utf-8')).toEqual( + expect.stringContaining(stripIndents` + import { One } from '@proj/react-components'; + `), + ); + }); + }); + }); +}); + +function createProject( + tree: Tree, + projectName: string, + options: { + root: string; + pkgJson: Partial; + files?: Array<{ filePath: string; content: string }>; + renameRoot?: boolean; + }, +) { + const projectType = options.root.startsWith('apps/') ? 'application' : 'library'; + const npmName = `@proj/${projectName}`; + const pkgJsonPath = joinPathFragments(options.root, 'package.json'); + const sourceRoot = joinPathFragments(options.root, 'src'); + const indexFile = joinPathFragments(sourceRoot, 'index.ts'); + const jestPath = joinPathFragments(options.root, 'jest.config.js'); + const readmePath = joinPathFragments(options.root, 'README.md'); + const apiMdPath = joinPathFragments(options.root, `etc/${projectName}.api.md`); + const tsConfigBaseAllPath = 'tsconfig.base.all.json'; + const tsConfigBasePath = 'tsconfig.base.json'; + + writeJson(tree, pkgJsonPath, { + ...options.pkgJson, + name: npmName, + }); + + addProjectConfiguration(tree, npmName, { root: options.root, sourceRoot, tags: ['vNext'] }); + + tree.write( + indexFile, + stripIndents` + export {}; + `, + ); + + tree.write( + readmePath, + stripIndents` + # ${npmName} + +**React Tags components for [Fluent UI React](https://react.fluentui.dev/)** + +These are not production-ready components and **should never be used in product**. This space is useful for testing new components whose APIs might change before final release. + + `, + ); + tree.write( + apiMdPath, + stripIndents` + ## API Report File for "${npmName}" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + `, + ); + tree.write( + jestPath, + stripIndents` + module.exports = { + displayName: '${projectName}', + }; + `, + ); + + const currentCodeowners = tree.read(codeownersPath, 'utf-8'); + const updatedCodeowners = currentCodeowners + `${options.root} @org/universe @johnwick\n`; + tree.write(codeownersPath, updatedCodeowners); + updateJson(tree, tsConfigBasePath, json => { + json.compilerOptions.paths![npmName] = [indexFile]; + return json; + }); + updateJson(tree, tsConfigBaseAllPath, json => { + json.compilerOptions.paths![npmName] = [indexFile]; + return json; + }); + + const depKeys = [...Object.keys(options.pkgJson.dependencies ?? {})]; + + graphMock.dependencies[npmName] = depKeys.map(value => { + return { source: npmName, target: value, type: 'static' }; + }); + graphMock.nodes[npmName] = { + name: npmName, + type: projectType === 'library' ? 'lib' : 'app', + data: { name: npmName, root: npmName, files: [] }, + }; + + if (options.files) { + options.files.forEach(fileEntry => { + tree.write(fileEntry.filePath, fileEntry.content); + }); + } + + const newRoot = options.renameRoot === false ? options.root : options.root.replace('-preview', ''); + + return { + pkgJson: () => { + return readJson(tree, joinPathFragments(newRoot, 'package.json')); + }, + projectJson: () => { + return readJson(tree, joinPathFragments(newRoot, 'project.json')); + }, + jest: () => { + return tree.read(joinPathFragments(newRoot, 'jest.config.js'), 'utf-8'); + }, + md: { + readme: () => tree.read(joinPathFragments(newRoot, 'README.md'), 'utf-8'), + api: () => tree.read(joinPathFragments(newRoot, `etc/${projectName.replace('-preview', '')}.api.md`), 'utf-8'), + }, + global: { + tsBase: () => readJson(tree, tsConfigBasePath), + tsBaseAll: () => readJson(tree, tsConfigBaseAllPath), + codeowners: () => tree.read(codeownersPath, 'utf-8'), + }, + }; +} diff --git a/tools/generators/prepare-initial-release/index.ts b/tools/generators/prepare-initial-release/index.ts new file mode 100644 index 00000000000000..1ce0425b0b100c --- /dev/null +++ b/tools/generators/prepare-initial-release/index.ts @@ -0,0 +1,328 @@ +import { + Tree, + formatFiles, + names, + updateJson, + ProjectConfiguration, + joinPathFragments, + visitNotIgnoredFiles, + createProjectGraphAsync, + reverse, + installPackagesTask, + readJson, + stripIndents, +} from '@nrwl/devkit'; + +import * as tsquery from '@phenomnomnominal/tsquery'; + +import { getProjectConfig, workspacePaths } from '../../utils'; + +import { PackageJson, TsConfig } from '../../types'; + +import tsConfigBaseAll from '../tsconfig-base-all'; + +import { ReleasePackageGeneratorSchema } from './schema'; +import { execSync } from 'child_process'; + +interface NormalizedSchema extends ReturnType {} + +export default async function (tree: Tree, schema: ReleasePackageGeneratorSchema) { + const options = normalizeOptions(tree, schema); + + assertProject(tree, options); + + const tasks: Array<(tree: Tree) => void> = []; + + if (options.phase === 'preview') { + tasks.push(initialRelease(tree, options)); + } + if (options.phase === 'stable') { + tasks.push(await stableRelease(tree, options)); + } + + await formatFiles(tree); + + return () => { + tasks.forEach(task => { + task(tree); + }); + }; +} + +function normalizeOptions(tree: Tree, options: ReleasePackageGeneratorSchema) { + const project = getProjectConfig(tree, { packageName: options.project }); + + return { + ...options, + ...project, + ...names(options.project), + }; +} + +function initialRelease(tree: Tree, options: NormalizedSchema) { + updateJson(tree, options.paths.packageJson, json => { + delete json.private; + return json; + }); + + const docsiteProjectName = '@' + options.workspaceConfig.npmScope + '/public-docsite-v9'; + const docsite = getProjectConfig(tree, { packageName: docsiteProjectName }); + + updateJson(tree, docsite.paths.packageJson, json => { + json.dependencies = json.dependencies ?? {}; + json.dependencies[options.project] = '*'; + return json; + }); + + return (_tree: Tree) => { + generateChangefileTask(tree, options.project, { message: 'feat: release preview package' }); + }; +} + +async function stableRelease(tree: Tree, options: NormalizedSchema) { + const suitePackageName = '@' + options.workspaceConfig.npmScope + '/react-components'; + const currentPackageName = options.projectConfig.name as string; + const newPackage = { + name: currentPackageName.replace('-preview', ''), + normalizedName: options.normalizedPkgName.replace('-preview', ''), + version: '9.0.0-alpha.0', + root: options.projectConfig.root.replace('-preview', ''), + sourceRoot: options.projectConfig.sourceRoot?.replace('-preview', '') as string, + }; + + updateJson(tree, options.paths.packageJson, json => { + delete json.private; + json.name = newPackage.name; + json.version = newPackage.version; + return json; + }); + updateJson(tree, options.paths.projectJson, json => { + json.name = newPackage.name; + json.sourceRoot = newPackage.sourceRoot; + + return json; + }); + + const contentNameUpdater = (content: string) => { + const regexp = new RegExp(options.normalizedPkgName, 'g'); + return content.replace(regexp, newPackage.normalizedName); + }; + const contentNameToSuiteUpdater = (content: string) => { + const regexp = new RegExp(options.normalizedPkgName, 'g'); + return content.replace(regexp, 'react-components'); + }; + + updateFileContent(tree, { filePath: options.paths.jestConfig, updater: contentNameUpdater }); + + const mdFilePath = { + readme: joinPathFragments(options.projectConfig.root, 'README.md'), + api: joinPathFragments(options.projectConfig.root, 'etc', options.normalizedPkgName + '.api.md'), + apiNew: joinPathFragments(options.projectConfig.root, 'etc', newPackage.normalizedName + '.api.md'), + }; + + updateFileContent(tree, { + filePath: mdFilePath.readme, + updater: contentNameUpdater, + }); + updateFileContent(tree, { filePath: mdFilePath.api, newFilePath: mdFilePath.apiNew, updater: contentNameUpdater }); + + // update stories + visitNotIgnoredFiles(tree, options.paths.stories, filePath => { + if (filePath.indexOf('index.stories.tsx') !== -1) { + updateFileContent(tree, { + filePath, + updater: content => { + let newContent = content.replace(`'Preview Components/`, 'Components/'); + + newContent = contentNameUpdater(content); + + return newContent; + }, + }); + } + }); + + // global updates + updateJson(tree, options.paths.rootTsconfig, json => { + json.compilerOptions.paths = json.compilerOptions.paths ?? {}; + + delete json.compilerOptions.paths[currentPackageName]; + json.compilerOptions.paths[newPackage.name] = [joinPathFragments(newPackage.sourceRoot, 'index.ts')]; + + return json; + }); + + await tsConfigBaseAll(tree, {}); + + updateFileContent(tree, { filePath: workspacePaths.github.codeowners, updater: contentNameUpdater }); + + // add to suite (react-components) + const reactComponentsProject = getProjectConfig(tree, { + packageName: suitePackageName, + }); + updateJson(tree, reactComponentsProject.paths.packageJson, json => { + json.dependencies = json.dependencies ?? {}; + json.dependencies[newPackage.name] = newPackage.version; + return json; + }); + + updateFileContent(tree, { + filePath: joinPathFragments(reactComponentsProject.projectConfig.sourceRoot as string, 'index.ts'), + updater: content => { + const currentBarrelFilePath = joinPathFragments(options.projectConfig.sourceRoot as string, 'index.ts'); + const currentBarrelFile = tree.read(currentBarrelFilePath, 'utf-8') as string; + return content + '\n' + createExportsInSuite(currentBarrelFile, newPackage.name); + }, + }); + + const knownProjectsToBeUpdated = { + docsite: '@' + options.workspaceConfig.npmScope + '/public-docsite-v9', + vrTests: '@' + options.workspaceConfig.npmScope + '/vr-tests-react-components', + }; + + // update other projects that might still contain dependency to old -preview package + const unknownProjectsToBeUpdated = (await getProjectThatNeedsToBeUpdated(tree, options))?.filter(projectName => { + const knownKeys = Object.values(knownProjectsToBeUpdated); + return !knownKeys.includes(projectName); + }); + + // update public-docsite-v9 + const reactComponentsDocsiteProject = getProjectConfig(tree, { + packageName: knownProjectsToBeUpdated.docsite, + }); + updateJson(tree, reactComponentsDocsiteProject.paths.packageJson, json => { + json.dependencies = json.dependencies ?? {}; + delete json.dependencies[currentPackageName]; + return json; + }); + visitNotIgnoredFiles(tree, joinPathFragments(reactComponentsDocsiteProject.projectConfig.root, 'src'), filePath => { + updateFileContent(tree, { filePath, updater: contentNameToSuiteUpdater }); + }); + + // update vr-tests-react-components + const reactComponentsVrTestsProject = getProjectConfig(tree, { + packageName: knownProjectsToBeUpdated.vrTests, + }); + updateJson(tree, reactComponentsVrTestsProject.paths.packageJson, json => { + json.dependencies = json.dependencies ?? {}; + delete json.dependencies[currentPackageName]; + json.dependencies[newPackage.name] = '*'; + return json; + }); + visitNotIgnoredFiles( + tree, + joinPathFragments(reactComponentsVrTestsProject.projectConfig.root, 'src/stories'), + filePath => { + updateFileContent(tree, { filePath, updater: contentNameUpdater }); + }, + ); + + unknownProjectsToBeUpdated?.forEach(projectName => { + const projectConfig = getProjectConfig(tree, { + packageName: projectName, + }); + visitNotIgnoredFiles(tree, joinPathFragments(projectConfig.projectConfig.root, 'src'), filePath => { + updateFileContent(tree, { filePath, updater: contentNameToSuiteUpdater }); + }); + updateJson(tree, joinPathFragments(projectConfig.projectConfig.root, 'package.json'), json => { + json.dependencies = json.dependencies ?? {}; + delete json.dependencies[currentPackageName]; + json.dependencies[suitePackageName] = '*'; + return json; + }); + }); + + // AFTER updates are done - rename project folder + tree.rename(options.projectConfig.root, newPackage.root); + + return (_tree: Tree) => { + generateChangefileTask(tree, newPackage.name, { message: 'feat: release stable' }); + generateChangefileTask(tree, suitePackageName, { message: `feat: add ${newPackage.name} to suite` }); + installPackagesTask(tree); + }; +} + +function updateFileContent( + tree: Tree, + options: { + filePath: string; + updater: (content: string) => string; + newFilePath?: string; + }, +) { + const { filePath, newFilePath, updater } = options; + const oldContent = tree.read(filePath, 'utf-8') as string; + + const newContent = updater(oldContent); + + if (newFilePath) { + tree.rename(filePath, newFilePath); + tree.write(newFilePath, newContent); + } else { + tree.write(filePath, newContent); + } + + return tree; +} + +async function getProjectThatNeedsToBeUpdated(tree: Tree, options: NormalizedSchema) { + const projectName = options.projectConfig.name as string; + + const graph = await createProjectGraphAsync(); + const reverseGraph = reverse(graph); + + const deps = reverseGraph.dependencies[projectName] || []; + + if (deps.length > 0) { + return deps.map(dep => dep.target); + } +} + +function generateChangefileTask(tree: Tree, projectName: string, options: { message: string }) { + const cmd = `yarn change --message '${options.message}' --type minor --package ${projectName}`; + return execSync(cmd); +} + +function assertProject(tree: Tree, options: NormalizedSchema) { + const pkgJson = readJson(tree, options.paths.packageJson); + + const isVnextPackage = options.projectConfig.tags?.includes('vNext'); + const isPreviewPackage = pkgJson.version.startsWith('0') && pkgJson.name.endsWith('-preview'); + const isPreparedForStableAlready = pkgJson.version === '9.0.0-alpha.0'; + + if (!isVnextPackage) { + throw new Error(`${options.project} is not a v9 package.`); + } + + if (isPreviewPackage) { + return; + } + + if (isPreparedForStableAlready) { + throw new Error(`${options.project} is already prepared for stable release. Please trigger RELEASE pipeline.`); + } + + throw new Error(`${options.project} is already released as stable.`); +} + +function createExportsInSuite(content: string, packageName: string) { + const ast = tsquery.ast(content); + const exports = tsquery.query(ast, 'ExportDeclaration[isTypeOnly=false] ExportSpecifier'); + const exportsTypes = tsquery.query(ast, 'ExportDeclaration[isTypeOnly=true] ExportSpecifier'); + + const exportExpression = exports + .map(exp => { + return tsquery.print(exp); + }) + .join(','); + const exportTypeExpression = exportsTypes + .map(exp => { + return tsquery.print(exp); + }) + .join(','); + + return stripIndents` + export { ${exportExpression} } from '${packageName}'; + export type { ${exportTypeExpression} } from '${packageName}'; + `; +} diff --git a/tools/generators/prepare-initial-release/lib/.gitkeep b/tools/generators/prepare-initial-release/lib/.gitkeep new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tools/generators/prepare-initial-release/schema.json b/tools/generators/prepare-initial-release/schema.json new file mode 100644 index 00000000000000..47ed8748844ec5 --- /dev/null +++ b/tools/generators/prepare-initial-release/schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "http://json-schema.org/schema", + "cli": "nx", + "id": "prepare-initial-release", + "type": "object", + "properties": { + "project": { + "type": "string", + "description": "Library name", + "x-prompt": "What project should be released", + "$default": { + "$source": "argv", + "index": 0 + } + }, + "phase": { + "type": "string", + "description": "Phase of npm release life cycle for fluent v9 core package", + "x-prompt": { + "message": "Which initial phase of release life cycle you wanna trigger?", + "type": "list", + "items": [ + { + "value": "preview", + "label": "preview - prepare 1st release for preview phase (shipping as -preview)" + }, + { + "value": "stable", + "label": "stable - prepare 1st release for stable phase (shipping from react-components suite)" + } + ] + } + } + }, + "required": ["project", "phase"] +} diff --git a/tools/generators/prepare-initial-release/schema.ts b/tools/generators/prepare-initial-release/schema.ts new file mode 100644 index 00000000000000..30d61ef1283205 --- /dev/null +++ b/tools/generators/prepare-initial-release/schema.ts @@ -0,0 +1,17 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export interface ReleasePackageGeneratorSchema { + /** + * Library name + */ + project: string; + /** + * Phase of npm release life cycle for fluent v9 core package + */ + phase: 'preview' | 'stable'; +} diff --git a/tools/utils.ts b/tools/utils.ts index ac3b8b4cd4da25..7c4bb09914f90f 100644 --- a/tools/utils.ts +++ b/tools/utils.ts @@ -88,6 +88,7 @@ export function getProjectConfig(tree: Tree, options: { packageName: string }) { const paths = { configRoot: joinPathFragments(projectConfig.root, 'config'), packageJson: joinPathFragments(projectConfig.root, 'package.json'), + projectJson: joinPathFragments(projectConfig.root, 'project.json'), tsconfig: { main: joinPathFragments(projectConfig.root, 'tsconfig.json'), lib: joinPathFragments(projectConfig.root, 'tsconfig.lib.json'), diff --git a/yarn.lock b/yarn.lock index b3f0952163bbf5..4ef8761da63db2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3690,6 +3690,14 @@ dependencies: esquery "^1.0.1" +"@phenomnomnominal/tsquery@6.1.2": + version "6.1.2" + resolved "https://registry.yarnpkg.com/@phenomnomnominal/tsquery/-/tsquery-6.1.2.tgz#37ec13373ec144f524958770ebc294d0b5e2909e" + integrity sha512-NahxUvas4D4iRV1NqlL6Z3mIl2Fo+rw1x77wgZpYyaQjQnS4svv6XoVzjcRRtnP5cfY6XuVKLZki8Zltkz8z0w== + dependencies: + "@types/esquery" "^1.5.0" + esquery "^1.5.0" + "@pmmmwh/react-refresh-webpack-plugin@^0.5.3": version "0.5.7" resolved "https://registry.yarnpkg.com/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.7.tgz#58f8217ba70069cc6a73f5d7e05e85b458c150e2" @@ -5351,6 +5359,13 @@ "@types/estree" "*" "@types/json-schema" "*" +"@types/esquery@^1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@types/esquery/-/esquery-1.5.0.tgz#928ccc6e61786dcd7c0759c06b803589855dd75a" + integrity sha512-MNQ5gCt3j1idWHlj/dEF+WPS1kl6Woe0Agzwy96JvrwDQdDadqeIBhY7mUca51CCUzxf7BsnXzcyKi6ENpEtmQ== + dependencies: + "@types/estree" "*" + "@types/estree@*", "@types/estree@^1.0.0": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.1.tgz#aa22750962f3bf0e79d753d3cc067f010c95f194" @@ -12391,10 +12406,10 @@ esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.0.1, esquery@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.4.0.tgz#2148ffc38b82e8c7057dfed48425b3e61f0f24a5" - integrity sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w== +esquery@^1.0.1, esquery@^1.4.0, esquery@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" + integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== dependencies: estraverse "^5.1.0"