diff --git a/scripts/package.json b/scripts/package.json index ce9c23e1428..9602127ae9b 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -12,6 +12,18 @@ "lint:js:cmd": "cross-env NODE_ENV=production eslint --cache --cache-location=../.cache/eslint --ext .js,.jsx,.json,.html,.ts,.tsx,.mjs --report-unused-disable-directives", "lint:package": "sort-package-json", "migrate-docs": "node --require esbuild-register ./ts-to-ts49.ts", + "release:generate-pr-description": "ts-node --swc ./release/generate-pr-description.ts", + "release:get-changelog-from-file": "ts-node --swc ./release/get-changelog-from-file.ts", + "release:get-current-version": "ts-node --swc ./release/get-current-version.ts", + "release:get-version-changelog": "ts-node --swc ./release/get-version-changelog.ts", + "release:is-pr-frozen": "ts-node --swc ./release/is-pr-frozen.ts", + "release:is-prerelease": "ts-node --swc ./release/is-prerelease.ts", + "release:is-version-published": "ts-node --swc ./release/is-version-published.ts", + "release:pick-patches": "ts-node --swc ./release/pick-patches.ts", + "release:publish": "ts-node --swc ./release/publish.ts", + "release:unreleased-changes-exists": "ts-node --swc ./release/unreleased-changes-exists.ts", + "release:version": "ts-node --swc ./release/version.ts", + "release:write-changelog": "ts-node --swc ./release/write-changelog.ts", "strict-ts": "node --require esbuild-register ./strict-ts.ts", "task": "ts-node --swc ./task.ts", "test": "jest --config ./jest.config.js", diff --git a/scripts/release/__tests__/generate-pr-description.test.ts b/scripts/release/__tests__/generate-pr-description.test.ts new file mode 100644 index 00000000000..e270e2e2a3d --- /dev/null +++ b/scripts/release/__tests__/generate-pr-description.test.ts @@ -0,0 +1,391 @@ +import { + generateReleaseDescription, + generateNonReleaseDescription, + mapToChangelist, + mapCherryPicksToTodo, +} from '../generate-pr-description'; +import type { Change } from '../utils/get-changes'; + +describe('Generate PR Description', () => { + const changes: Change[] = [ + { + user: 'JReinhold', + title: 'Some PR title for a bug', + labels: ['bug', 'build', 'other label', 'patch'], + commit: 'abc123', + pull: '42', + links: { + commit: '[abc123](https://github.com/storybookjs/storybook/commit/abc123)', + pull: '[#42](https://github.com/storybookjs/storybook/pull/42)', + user: '[@JReinhold](https://github.com/JReinhold)', + }, + }, + { + // this Bump version commit should be ignored + user: 'github-actions[bot]', + pull: null, + commit: '012b58140c3606efeacbe99c0c410624b0a1ed1f', + title: 'Bump version on `next`: preminor (alpha) from 7.2.0 to 7.3.0-alpha.0', + labels: null, + links: { + commit: + '[`012b58140c3606efeacbe99c0c410624b0a1ed1f`](https://github.com/storybookjs/monorepo-release-tooling-prototype/commit/012b58140c3606efeacbe99c0c410624b0a1ed1f)', + pull: null, + user: '[@github-actions[bot]](https://github.com/github-actions%5Bbot%5D)', + }, + }, + { + user: 'shilman', + title: 'Some title for a "direct commit"', + labels: null, + commit: '22bb11', + pull: null, + links: { + commit: '[22bb11](https://github.com/storybookjs/storybook/commit/22bb11)', + pull: null, + user: '[@shilman](https://github.com/shilman)', + }, + }, + { + user: 'shilman', + title: 'Another PR `title` for docs', + labels: ['another label', 'documentation', 'patch'], + commit: 'ddd222', + pull: '11', + links: { + commit: '[ddd222](https://github.com/storybookjs/storybook/commit/ddd222)', + pull: '[#11](https://github.com/storybookjs/storybook/pull/11)', + user: '[@shilman](https://github.com/shilman)', + }, + }, + { + user: 'JReinhold', + title: "Some PR title for a 'new' feature", + labels: ['feature request', 'other label'], + commit: 'wow1337', + pull: '48', + links: { + commit: '[wow1337](https://github.com/storybookjs/storybook/commit/wow1337)', + pull: '[#48](https://github.com/storybookjs/storybook/pull/48)', + user: '[@JReinhold](https://github.com/JReinhold)', + }, + }, + { + user: 'JReinhold', + title: 'Some PR title with a missing label', + labels: ['incorrect label', 'other label'], + commit: 'bad999', + pull: '77', + links: { + commit: '[bad999](https://github.com/storybookjs/storybook/commit/bad999)', + pull: '[#77](https://github.com/storybookjs/storybook/pull/77)', + user: '[@JReinhold](https://github.com/JReinhold)', + }, + }, + ]; + describe('mapToChangelist', () => { + it('should return a correct string for releases', () => { + expect(mapToChangelist({ changes, isRelease: true })).toMatchInlineSnapshot(` + "- **πŸ› Bug**: Some PR title for a bug [#42](https://github.com/storybookjs/storybook/pull/42) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + - **⚠️ Direct commit**: Some title for a "direct commit" [22bb11](https://github.com/storybookjs/storybook/commit/22bb11) + - [ ] The change is appropriate for the version bump + - **πŸ“ Documentation**: Another PR \`title\` for docs [#11](https://github.com/storybookjs/storybook/pull/11) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + - **✨ Feature Request**: Some PR title for a 'new' feature [#48](https://github.com/storybookjs/storybook/pull/48) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + - **❔ Missing Label**: Some PR title with a missing label [#77](https://github.com/storybookjs/storybook/pull/77) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct" + `); + }); + it('should return a correct string for non-releases', () => { + expect(mapToChangelist({ changes, isRelease: false })).toMatchInlineSnapshot(` + "- **πŸ› Bug**: Some PR title for a bug [#42](https://github.com/storybookjs/storybook/pull/42) + - **⚠️ Direct commit**: Some title for a "direct commit" [22bb11](https://github.com/storybookjs/storybook/commit/22bb11) + - **πŸ“ Documentation**: Another PR \`title\` for docs [#11](https://github.com/storybookjs/storybook/pull/11) + - **✨ Feature Request**: Some PR title for a 'new' feature [#48](https://github.com/storybookjs/storybook/pull/48) + - **❔ Missing Label**: Some PR title with a missing label [#77](https://github.com/storybookjs/storybook/pull/77)" + `); + }); + }); + + describe('mapCherryPicksToTodo', () => { + it('should return a correct string for releases', () => { + expect(mapCherryPicksToTodo({ changes, commits: ['abc123'] })).toMatchInlineSnapshot(` + "## πŸ’ Manual cherry picking needed! + + The following pull requests could not be cherry-picked automatically because it resulted in merge conflicts. + For each pull request below, you need to either manually cherry pick it, or discard it by removing the "patch" label from the PR and re-generate this PR. + + - [ ] [#42](https://github.com/storybookjs/storybook/pull/42): \`git cherry-pick -m1 -x abc123\`" + `); + }); + }); + + describe('description generator', () => { + const changeList = `- **πŸ› Bug**: Some PR title for a bug [#42](https://github.com/storybookjs/storybook/pull/42) +\t- [ ] The change is appropriate for the version bump +\t- [ ] The PR is labeled correctly +\t- [ ] The PR title is correct +- **⚠️ Direct commit**: Some title for a \\"direct commit\\" [22bb11](https://github.com/storybookjs/storybook/commit/22bb11) +\t- [ ] The change is appropriate for the version bump +- **πŸ“ Documentation**: Another PR \\\`title\\\` for docs [#11](https://github.com/storybookjs/storybook/pull/11) +\t- [ ] The change is appropriate for the version bump +\t- [ ] The PR is labeled correctly +\t- [ ] The PR title is correct +- **✨ Feature Request**: Some PR title for a \\'new\\' feature [#48](https://github.com/storybookjs/storybook/pull/48) +\t- [ ] The change is appropriate for the version bump +\t- [ ] The PR is labeled correctly +\t- [ ] The PR title is correct +- **⚠️ Missing Label**: Some PR title with a missing label [#77](https://github.com/storybookjs/storybook/pull/77) +\t- [ ] The change is appropriate for the version bump +\t- [ ] The PR is labeled correctly +\t- [ ] The PR title is correct`; + + const manualCherryPicks = `## πŸ’ Manual cherry picking needed! + +The following pull requests could not be cherry-picked automatically because it resulted in merge conflicts. +For each pull request below, you need to either manually cherry pick it, or discard it by removing the "patch" label from the PR and re-generate this PR. + +- [ ] [#42](https://github.com/storybookjs/storybook/pull/42): \`git cherry-pick -m1 -x abc123\``; + + it('should return a correct string with cherry picks for releases', () => { + const changelogText = `## 7.1.0-alpha.11 + +- Some PR \`title\` for a bug [#42](https://github.com/storybookjs/storybook/pull/42), thanks [@JReinhold](https://github.com/JReinhold) +- Some PR 'title' for a feature request [#48](https://github.com/storybookjs/storybook/pull/48), thanks [@JReinhold](https://github.com/JReinhold) +- Antoher PR "title" for maintainance [#49](https://github.com/storybookjs/storybook/pull/49), thanks [@JReinhold](https://github.com/JReinhold)`; + expect( + generateReleaseDescription({ + currentVersion: '7.1.0-alpha.10', + nextVersion: '7.1.0-alpha.11', + changeList, + changelogText, + manualCherryPicks, + }) + ).toMatchInlineSnapshot(` + "This is an automated pull request that bumps the version from \\\`7.1.0-alpha.10\\\` to \\\`7.1.0-alpha.11\\\`. + Once this pull request is merged, it will trigger a new release of version \\\`7.1.0-alpha.11\\\`. + If you\\'re not a core maintainer with permissions to release you can ignore this pull request. + + ## To do + + Before merging the PR, there are a few QA steps to go through: + + - [ ] Add the \\"freeze\\" label to this PR, to ensure it doesn\\'t get automatically forced pushed by new changes. + + And for each change below: + + 1. Ensure the change is appropriate for the version bump. E.g. patch release should only contain patches, not new or de-stabilizing features. If a change is not appropriate, revert the PR. + 2. Ensure the PR is labeled correctly with \\"BREAKING CHANGE\\", \\"feature request\\", \\"maintainance\\", \\"bug\\", \\"build\\" or \\"documentation\\". + 3. Ensure the PR title is correct, and follows the format \\"[Area]: [Summary]\\", e.g. *\\"React: Fix hooks in CSF3 render functions\\"*. If it is not correct, change the title in the PR. + - Areas include: React, Vue, Core, Docs, Controls, etc. + - First word of summary indicates the type: β€œAdd”, β€œFix”, β€œUpgrade”, etc. + - The entire title should fit on a line + + This is a list of all the PRs merged and commits pushed directly to \\\`next\\\`, that will be part of this release: + + - **πŸ› Bug**: Some PR title for a bug [#42](https://github.com/storybookjs/storybook/pull/42) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + - **⚠️ Direct commit**: Some title for a \\\\"direct commit\\\\" [22bb11](https://github.com/storybookjs/storybook/commit/22bb11) + - [ ] The change is appropriate for the version bump + - **πŸ“ Documentation**: Another PR \\\\\`title\\\\\` for docs [#11](https://github.com/storybookjs/storybook/pull/11) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + - **✨ Feature Request**: Some PR title for a \\\\'new\\\\' feature [#48](https://github.com/storybookjs/storybook/pull/48) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + - **⚠️ Missing Label**: Some PR title with a missing label [#77](https://github.com/storybookjs/storybook/pull/77) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + + ## πŸ’ Manual cherry picking needed! + + The following pull requests could not be cherry-picked automatically because it resulted in merge conflicts. + For each pull request below, you need to either manually cherry pick it, or discard it by removing the \\"patch\\" label from the PR and re-generate this PR. + + - [ ] [#42](https://github.com/storybookjs/storybook/pull/42): \\\`git cherry-pick -m1 -x abc123\\\` + + If you\\'ve made any changes doing the above QA (change PR titles, revert PRs), manually trigger a re-generation of this PR with [this workflow](https://github.com/storybookjs/monorepo-release-tooling-prototype/actions/workflows/prepare-prerelease.yml) and wait for it to finish. It will wipe your progress in this to do, which is expected. + + When everything above is done: + - [ ] Merge this PR + - [ ] [Follow the publish workflow run and see it finishes succesfully](https://github.com/storybookjs/monorepo-release-tooling-prototype/actions/workflows/publish.yml) + + --- + + # Generated changelog + + ## 7.1.0-alpha.11 + + - Some PR \\\`title\\\` for a bug [#42](https://github.com/storybookjs/storybook/pull/42), thanks [@ JReinhold](https://github.com/JReinhold) + - Some PR \\'title\\' for a feature request [#48](https://github.com/storybookjs/storybook/pull/48), thanks [@ JReinhold](https://github.com/JReinhold) + - Antoher PR \\"title\\" for maintainance [#49](https://github.com/storybookjs/storybook/pull/49), thanks [@ JReinhold](https://github.com/JReinhold)" + `); + }); + + it('should return a correct string for non-releases with cherry picks', () => { + expect(generateNonReleaseDescription(changeList, manualCherryPicks)).toMatchInlineSnapshot(` + "This is an automated pull request. None of the changes requires a version bump, they are only internal or documentation related. Merging this PR will not trigger a new release, but documentation will be updated. + If you\\'re not a core maintainer with permissions to release you can ignore this pull request. + + This is a list of all the PRs merged and commits pushed directly to \\\`next\\\` since the last release: + + - **πŸ› Bug**: Some PR title for a bug [#42](https://github.com/storybookjs/storybook/pull/42) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + - **⚠️ Direct commit**: Some title for a \\\\"direct commit\\\\" [22bb11](https://github.com/storybookjs/storybook/commit/22bb11) + - [ ] The change is appropriate for the version bump + - **πŸ“ Documentation**: Another PR \\\\\`title\\\\\` for docs [#11](https://github.com/storybookjs/storybook/pull/11) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + - **✨ Feature Request**: Some PR title for a \\\\'new\\\\' feature [#48](https://github.com/storybookjs/storybook/pull/48) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + - **⚠️ Missing Label**: Some PR title with a missing label [#77](https://github.com/storybookjs/storybook/pull/77) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + + ## πŸ’ Manual cherry picking needed! + + The following pull requests could not be cherry-picked automatically because it resulted in merge conflicts. + For each pull request below, you need to either manually cherry pick it, or discard it by removing the \\"patch\\" label from the PR and re-generate this PR. + + - [ ] [#42](https://github.com/storybookjs/storybook/pull/42): \\\`git cherry-pick -m1 -x abc123\\\` + + If you\\'ve made any changes (change PR titles, revert PRs), manually trigger a re-generation of this PR with [this workflow](https://github.com/storybookjs/monorepo-release-tooling-prototype/actions/workflows/prepare-prerelease.yml) and wait for it to finish. + + When everything above is done: + - [ ] Merge this PR + - [ ] [Approve the publish workflow run](https://github.com/storybookjs/monorepo-release-tooling-prototype/actions/workflows/publish.yml)" + `); + }); + + it('should return a correct string without cherry picks for releases', () => { + const changelogText = `## 7.1.0-alpha.11 + +- Some PR \`title\` for a bug [#42](https://github.com/storybookjs/storybook/pull/42), thanks [@JReinhold](https://github.com/JReinhold) +- Some PR 'title' for a feature request [#48](https://github.com/storybookjs/storybook/pull/48), thanks [@JReinhold](https://github.com/JReinhold) +- Antoher PR "title" for maintainance [#49](https://github.com/storybookjs/storybook/pull/49), thanks [@JReinhold](https://github.com/JReinhold)`; + expect( + generateReleaseDescription({ + currentVersion: '7.1.0-alpha.10', + nextVersion: '7.1.0-alpha.11', + changeList, + changelogText, + }) + ).toMatchInlineSnapshot(` + "This is an automated pull request that bumps the version from \\\`7.1.0-alpha.10\\\` to \\\`7.1.0-alpha.11\\\`. + Once this pull request is merged, it will trigger a new release of version \\\`7.1.0-alpha.11\\\`. + If you\\'re not a core maintainer with permissions to release you can ignore this pull request. + + ## To do + + Before merging the PR, there are a few QA steps to go through: + + - [ ] Add the \\"freeze\\" label to this PR, to ensure it doesn\\'t get automatically forced pushed by new changes. + + And for each change below: + + 1. Ensure the change is appropriate for the version bump. E.g. patch release should only contain patches, not new or de-stabilizing features. If a change is not appropriate, revert the PR. + 2. Ensure the PR is labeled correctly with \\"BREAKING CHANGE\\", \\"feature request\\", \\"maintainance\\", \\"bug\\", \\"build\\" or \\"documentation\\". + 3. Ensure the PR title is correct, and follows the format \\"[Area]: [Summary]\\", e.g. *\\"React: Fix hooks in CSF3 render functions\\"*. If it is not correct, change the title in the PR. + - Areas include: React, Vue, Core, Docs, Controls, etc. + - First word of summary indicates the type: β€œAdd”, β€œFix”, β€œUpgrade”, etc. + - The entire title should fit on a line + + This is a list of all the PRs merged and commits pushed directly to \\\`next\\\`, that will be part of this release: + + - **πŸ› Bug**: Some PR title for a bug [#42](https://github.com/storybookjs/storybook/pull/42) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + - **⚠️ Direct commit**: Some title for a \\\\"direct commit\\\\" [22bb11](https://github.com/storybookjs/storybook/commit/22bb11) + - [ ] The change is appropriate for the version bump + - **πŸ“ Documentation**: Another PR \\\\\`title\\\\\` for docs [#11](https://github.com/storybookjs/storybook/pull/11) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + - **✨ Feature Request**: Some PR title for a \\\\'new\\\\' feature [#48](https://github.com/storybookjs/storybook/pull/48) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + - **⚠️ Missing Label**: Some PR title with a missing label [#77](https://github.com/storybookjs/storybook/pull/77) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + + + + If you\\'ve made any changes doing the above QA (change PR titles, revert PRs), manually trigger a re-generation of this PR with [this workflow](https://github.com/storybookjs/monorepo-release-tooling-prototype/actions/workflows/prepare-prerelease.yml) and wait for it to finish. It will wipe your progress in this to do, which is expected. + + When everything above is done: + - [ ] Merge this PR + - [ ] [Follow the publish workflow run and see it finishes succesfully](https://github.com/storybookjs/monorepo-release-tooling-prototype/actions/workflows/publish.yml) + + --- + + # Generated changelog + + ## 7.1.0-alpha.11 + + - Some PR \\\`title\\\` for a bug [#42](https://github.com/storybookjs/storybook/pull/42), thanks [@ JReinhold](https://github.com/JReinhold) + - Some PR \\'title\\' for a feature request [#48](https://github.com/storybookjs/storybook/pull/48), thanks [@ JReinhold](https://github.com/JReinhold) + - Antoher PR \\"title\\" for maintainance [#49](https://github.com/storybookjs/storybook/pull/49), thanks [@ JReinhold](https://github.com/JReinhold)" + `); + }); + + it('should return a correct string for non-releases without cherry picks', () => { + expect(generateNonReleaseDescription(changeList)).toMatchInlineSnapshot(` + "This is an automated pull request. None of the changes requires a version bump, they are only internal or documentation related. Merging this PR will not trigger a new release, but documentation will be updated. + If you\\'re not a core maintainer with permissions to release you can ignore this pull request. + + This is a list of all the PRs merged and commits pushed directly to \\\`next\\\` since the last release: + + - **πŸ› Bug**: Some PR title for a bug [#42](https://github.com/storybookjs/storybook/pull/42) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + - **⚠️ Direct commit**: Some title for a \\\\"direct commit\\\\" [22bb11](https://github.com/storybookjs/storybook/commit/22bb11) + - [ ] The change is appropriate for the version bump + - **πŸ“ Documentation**: Another PR \\\\\`title\\\\\` for docs [#11](https://github.com/storybookjs/storybook/pull/11) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + - **✨ Feature Request**: Some PR title for a \\\\'new\\\\' feature [#48](https://github.com/storybookjs/storybook/pull/48) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + - **⚠️ Missing Label**: Some PR title with a missing label [#77](https://github.com/storybookjs/storybook/pull/77) + - [ ] The change is appropriate for the version bump + - [ ] The PR is labeled correctly + - [ ] The PR title is correct + + + + If you\\'ve made any changes (change PR titles, revert PRs), manually trigger a re-generation of this PR with [this workflow](https://github.com/storybookjs/monorepo-release-tooling-prototype/actions/workflows/prepare-prerelease.yml) and wait for it to finish. + + When everything above is done: + - [ ] Merge this PR + - [ ] [Approve the publish workflow run](https://github.com/storybookjs/monorepo-release-tooling-prototype/actions/workflows/publish.yml)" + `); + }); + }); +}); diff --git a/scripts/release/__tests__/is-pr-frozen.test.ts b/scripts/release/__tests__/is-pr-frozen.test.ts new file mode 100644 index 00000000000..a8b9f49f2a2 --- /dev/null +++ b/scripts/release/__tests__/is-pr-frozen.test.ts @@ -0,0 +1,55 @@ +import path from "path"; +import { run as isPrFrozen } from "../is-pr-frozen"; + +jest.mock('fs-extra', () => require('../../../code/__mocks__/fs-extra')); +jest.mock('../utils/get-github-info'); + +const fsExtra = require('fs-extra'); +const simpleGit = require('simple-git'); +const { getPullInfoFromCommit } = require('../utils/get-github-info'); + +const CODE_DIR_PATH = path.join(__dirname, '..', '..', '..', 'code'); +const CODE_PACKAGE_JSON_PATH = path.join(CODE_DIR_PATH, 'package.json'); + +fsExtra.__setMockFiles({ + [CODE_PACKAGE_JSON_PATH]: JSON.stringify({ version: '1.0.0' }), +}); + +describe('isPrFrozen', () => { + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return true when PR is frozen', async () => { + getPullInfoFromCommit.mockResolvedValue({ + labels: ['freeze'] + }); + await expect(isPrFrozen({patch: false})).resolves.toBe(true); + }); + + it('should return false when PR is not frozen', async () => { + getPullInfoFromCommit.mockResolvedValue({ + labels: [] + }); + await expect(isPrFrozen({patch: false})).resolves.toBe(false); + }); + + it('should look for patch PRs when patch is true', async () => { + getPullInfoFromCommit.mockResolvedValue({ + labels: [] + }); + await isPrFrozen({patch: true}); + + expect(simpleGit.__fetch).toHaveBeenCalledWith('origin', 'version-from-patch-1.0.0', {'--depth': 1}); + }); + + it('should look for prerelease PRs when patch is false', async () => { + getPullInfoFromCommit.mockResolvedValue({ + labels: [] + }); + await isPrFrozen({patch: false}); + + expect(simpleGit.__fetch).toHaveBeenCalledWith('origin', 'version-from-prerelease-1.0.0', {'--depth': 1}); + }); +}); diff --git a/scripts/release/__tests__/version.test.ts b/scripts/release/__tests__/version.test.ts new file mode 100644 index 00000000000..46db3115564 --- /dev/null +++ b/scripts/release/__tests__/version.test.ts @@ -0,0 +1,180 @@ +/* eslint-disable global-require */ +/* eslint-disable no-underscore-dangle */ +import path from 'path'; +import { run as version } from '../version'; + +// eslint-disable-next-line jest/no-mocks-import +jest.mock('fs-extra', () => require('../../../code/__mocks__/fs-extra')); +const fsExtra = require('fs-extra'); + +jest.mock('../../utils/exec'); +const { execaCommand } = require('../../utils/exec'); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('Version', () => { + const CODE_DIR_PATH = path.join(__dirname, '..', '..', '..', 'code'); + const CODE_PACKAGE_JSON_PATH = path.join(CODE_DIR_PATH, 'package.json'); + const MANAGER_API_VERSION_PATH = path.join( + CODE_DIR_PATH, + 'lib', + 'manager-api', + 'src', + 'version.ts' + ); + const VERSIONS_PATH = path.join(CODE_DIR_PATH, 'lib', 'cli', 'src', 'versions.ts'); + + it('should throw when release type is invalid', async () => { + fsExtra.__setMockFiles({ + [CODE_PACKAGE_JSON_PATH]: JSON.stringify({ version: '1.0.0' }), + [MANAGER_API_VERSION_PATH]: `export const version = "1.0.0";`, + [VERSIONS_PATH]: `export default { "@junk-temporary-prototypes/addon-a11y": "1.0.0" };`, + }); + + await expect(version({ releaseType: 'invalid' })).rejects.toThrowErrorMatchingInlineSnapshot(` + "[ + { + "received": "invalid", + "code": "invalid_enum_value", + "options": [ + "major", + "minor", + "patch", + "prerelease", + "premajor", + "preminor", + "prepatch" + ], + "path": [ + "releaseType" + ], + "message": "Invalid enum value. Expected 'major' | 'minor' | 'patch' | 'prerelease' | 'premajor' | 'preminor' | 'prepatch', received 'invalid'" + } + ]" + `); + }); + + it('should throw when prerelease identifier is combined with non-pre release type', async () => { + fsExtra.__setMockFiles({ + [CODE_PACKAGE_JSON_PATH]: JSON.stringify({ version: '1.0.0' }), + [MANAGER_API_VERSION_PATH]: `export const version = "1.0.0";`, + [VERSIONS_PATH]: `export default { "@junk-temporary-prototypes/addon-a11y": "1.0.0" };`, + }); + + await expect(version({ releaseType: 'major', preId: 'alpha' })).rejects + .toThrowErrorMatchingInlineSnapshot(` + "[ + { + "code": "custom", + "message": "Using prerelease identifier requires one of release types: premajor, preminor, prepatch, prerelease", + "path": [] + } + ]" + `); + }); + + it('should throw when exact is combined with release type', async () => { + fsExtra.__setMockFiles({ + [CODE_PACKAGE_JSON_PATH]: JSON.stringify({ version: '1.0.0' }), + [MANAGER_API_VERSION_PATH]: `export const version = "1.0.0";`, + [VERSIONS_PATH]: `export default { "@junk-temporary-prototypes/addon-a11y": "1.0.0" };`, + }); + + await expect(version({ releaseType: 'major', exact: '1.0.0' })).rejects + .toThrowErrorMatchingInlineSnapshot(` + "[ + { + "code": "custom", + "message": "Combining --exact with --release-type is invalid, but having one of them is required", + "path": [] + } + ]" + `); + }); + + it('should throw when exact is invalid semver', async () => { + fsExtra.__setMockFiles({ + [CODE_PACKAGE_JSON_PATH]: JSON.stringify({ version: '1.0.0' }), + [MANAGER_API_VERSION_PATH]: `export const version = "1.0.0";`, + [VERSIONS_PATH]: `export default { "@junk-temporary-prototypes/addon-a11y": "1.0.0" };`, + }); + + await expect(version({ exact: 'not-semver' })).rejects.toThrowErrorMatchingInlineSnapshot(` + "[ + { + "code": "custom", + "message": "--exact version has to be a valid semver string", + "path": [ + "exact" + ] + } + ]" + `); + }); + + it.each([ + // prettier-ignore + { releaseType: 'major', currentVersion: '1.1.1', expectedVersion: '2.0.0' }, + // prettier-ignore + { releaseType: 'minor', currentVersion: '1.1.1', expectedVersion: '1.2.0' }, + // prettier-ignore + { releaseType: 'patch', currentVersion: '1.1.1', expectedVersion: '1.1.2' }, + // prettier-ignore + { releaseType: 'premajor', preId: 'alpha', currentVersion: '1.1.1', expectedVersion: '2.0.0-alpha.0' }, + // prettier-ignore + { releaseType: 'preminor', preId: 'alpha', currentVersion: '1.1.1', expectedVersion: '1.2.0-alpha.0' }, + // prettier-ignore + { releaseType: 'prepatch', preId: 'alpha', currentVersion: '1.1.1', expectedVersion: '1.1.2-alpha.0' }, + // prettier-ignore + { releaseType: 'prerelease', currentVersion: '1.1.1-alpha.5', expectedVersion: '1.1.1-alpha.6' }, + // prettier-ignore + { releaseType: 'prerelease', preId: 'alpha', currentVersion: '1.1.1-alpha.5', expectedVersion: '1.1.1-alpha.6' }, + // prettier-ignore + { releaseType: 'prerelease', preId: 'beta', currentVersion: '1.1.1-alpha.10', expectedVersion: '1.1.1-beta.0' }, + // prettier-ignore + { releaseType: 'major', currentVersion: '1.1.1-rc.10', expectedVersion: '2.0.0' }, + // prettier-ignore + { releaseType: 'minor', currentVersion: '1.1.1-rc.10', expectedVersion: '1.2.0' }, + // prettier-ignore + { releaseType: 'patch', currentVersion: '1.1.1-rc.10', expectedVersion: '1.1.1' }, + // prettier-ignore + { exact: '4.2.0-canary.69', currentVersion: '1.1.1-rc.10', expectedVersion: '4.2.0-canary.69' }, + ])( + 'bump with type: "$releaseType", pre id "$preId" or exact "$exact", from: $currentVersion, to: $expectedVersion', + async ({ releaseType, preId, exact, currentVersion, expectedVersion }) => { + fsExtra.__setMockFiles({ + [CODE_PACKAGE_JSON_PATH]: JSON.stringify({ version: currentVersion }), + [MANAGER_API_VERSION_PATH]: `export const version = "${currentVersion}";`, + [VERSIONS_PATH]: `export default { "@junk-temporary-prototypes/addon-a11y": "${currentVersion}" };`, + }); + + await version({ releaseType, preId, exact }); + + expect(fsExtra.writeJson).toHaveBeenCalledWith( + CODE_PACKAGE_JSON_PATH, + { version: expectedVersion }, + { spaces: 2 } + ); + expect(fsExtra.writeFile).toHaveBeenCalledWith( + path.join( + CODE_DIR_PATH, + '.yarn', + 'versions', + 'generated-by-versions-script.yml' + ), + expect.stringContaining(expectedVersion) + ); + expect(execaCommand).toHaveBeenCalledWith('yarn version apply --all', { cwd: CODE_DIR_PATH }); + expect(fsExtra.writeFile).toHaveBeenCalledWith( + MANAGER_API_VERSION_PATH, + expect.stringContaining(expectedVersion) + ); + expect(fsExtra.writeFile).toHaveBeenCalledWith( + VERSIONS_PATH, + expect.stringContaining(expectedVersion) + ); + } + ); +}); diff --git a/scripts/release/generate-pr-description.ts b/scripts/release/generate-pr-description.ts new file mode 100644 index 00000000000..c3e3b6e6b4f --- /dev/null +++ b/scripts/release/generate-pr-description.ts @@ -0,0 +1,291 @@ +/* eslint-disable no-continue */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable no-console */ +import chalk from 'chalk'; +import program from 'commander'; +import { z } from 'zod'; +import dedent from 'ts-dedent'; +import { setOutput } from '@actions/core'; +import type { Change } from './utils/get-changes'; +import { getChanges } from './utils/get-changes'; +import { getCurrentVersion } from './get-current-version'; + +program + .name('generate-pr-description') + .description('generate a PR description for a release') + .option( + '-C, --current-version ', + 'Which version to generate changelog from, eg. "7.0.7". Defaults to the version at code/package.json' + ) + .option('-N, --next-version ', 'Which version to generate changelog to, eg. "7.0.8"') + .option('-P, --unpicked-patches', 'Set to only consider PRs labeled with "patch" label') + .option( + '-M, --manual-cherry-picks ', + 'A stringified JSON array of commit hashes, of patch PRs that needs to be cherry-picked manually' + ) + .option('-V, --verbose', 'Enable verbose logging', false); + +const optionsSchema = z.object({ + currentVersion: z.string().optional(), + nextVersion: z.string().optional(), + unpickedPatches: z.boolean().optional(), + manualCherryPicks: z + .string() + .default('[]') + .transform((val) => JSON.parse(val)) + .refine((val) => Array.isArray(val)), + verbose: z.boolean().optional(), +}); + +type Options = { + currentVersion?: string; + nextVersion?: string; + unpickedPatches?: boolean; + manualCherryPicks?: string[]; + verbose: boolean; +}; + +const LABELS_BY_IMPORTANCE = { + 'BREAKING CHANGE': '❗ Breaking Change', + 'feature request': '✨ Feature Request', + bug: 'πŸ› Bug', + maintenance: 'πŸ”§ Maintenance', + documentation: 'πŸ“ Documentation', + build: 'πŸ—οΈ Build', + unknown: '❔ Missing Label', +} as const; + +const CHANGE_TITLES_TO_IGNORE = [/^bump version on.*/i, /^merge branch.*/i]; + +export const mapToChangelist = ({ + changes, + isRelease, +}: { + changes: Change[]; + isRelease: boolean; +}): string => { + return changes + .filter((change) => { + // eslint-disable-next-line no-restricted-syntax + for (const titleToIgnore of CHANGE_TITLES_TO_IGNORE) { + if (change.title.match(titleToIgnore)) { + return false; + } + } + return true; + }) + .map((change) => { + const lines: string[] = []; + if (!change.pull) { + lines.push(`- **⚠️ Direct commit**: ${change.title} ${change.links.commit}`); + if (isRelease) { + lines.push('\t- [ ] The change is appropriate for the version bump'); + } + return lines.join('\n'); + } + + const label = (change.labels + ?.filter((l) => Object.keys(LABELS_BY_IMPORTANCE).includes(l)) + .sort( + (a, b) => + Object.keys(LABELS_BY_IMPORTANCE).indexOf(a) - + Object.keys(LABELS_BY_IMPORTANCE).indexOf(b) + )[0] || 'unknown') as keyof typeof LABELS_BY_IMPORTANCE; + + lines.push(`- **${LABELS_BY_IMPORTANCE[label]}**: ${change.title} ${change.links.pull}`); + + if (isRelease) { + lines.push('\t- [ ] The change is appropriate for the version bump'); + lines.push('\t- [ ] The PR is labeled correctly'); + lines.push('\t- [ ] The PR title is correct'); + } + return lines.join('\n'); + }) + .join('\n'); +}; + +export const mapCherryPicksToTodo = ({ + commits, + changes, + verbose, +}: { + commits: string[]; + changes: Change[]; + verbose?: boolean; +}): string => { + const list = commits + .map((commit) => { + const change = changes.find((change) => change.commit === commit.substring(0, 7)); + if (!change) { + throw new Error( + `Cherry pick commit "${commit}" not found in changes, this should not happen?!` + ); + } + return `- [ ] ${change.links.pull}: \`git cherry-pick -m1 -x ${commit}\``; + }) + .join('\n'); + + if (verbose) { + console.log(`πŸ’ Cherry pick list:\n${list}`); + } + return dedent`## πŸ’ Manual cherry picking needed! + + The following pull requests could not be cherry-picked automatically because it resulted in merge conflicts. + For each pull request below, you need to either manually cherry pick it, or discard it by removing the "patch" label from the PR and re-generate this PR. + + ${list}`; +}; + +export const generateReleaseDescription = ({ + currentVersion, + nextVersion, + changeList, + changelogText, + manualCherryPicks, +}: { + currentVersion: string; + nextVersion: string; + changeList: string; + changelogText: string; + manualCherryPicks?: string; +}): string => { + return ( + dedent`This is an automated pull request that bumps the version from \`${currentVersion}\` to \`${nextVersion}\`. + Once this pull request is merged, it will trigger a new release of version \`${nextVersion}\`. + If you're not a core maintainer with permissions to release you can ignore this pull request. + + ## To do + + Before merging the PR, there are a few QA steps to go through: + + - [ ] Add the "freeze" label to this PR, to ensure it doesn't get automatically forced pushed by new changes. + + And for each change below: + + 1. Ensure the change is appropriate for the version bump. E.g. patch release should only contain patches, not new or de-stabilizing features. If a change is not appropriate, revert the PR. + 2. Ensure the PR is labeled correctly with "BREAKING CHANGE", "feature request", "maintainance", "bug", "build" or "documentation". + 3. Ensure the PR title is correct, and follows the format "[Area]: [Summary]", e.g. *"React: Fix hooks in CSF3 render functions"*. If it is not correct, change the title in the PR. + - Areas include: React, Vue, Core, Docs, Controls, etc. + - First word of summary indicates the type: β€œAdd”, β€œFix”, β€œUpgrade”, etc. + - The entire title should fit on a line + + This is a list of all the PRs merged and commits pushed directly to \`next\`, that will be part of this release: + + ${changeList} + + ${manualCherryPicks ? manualCherryPicks : ''} + + If you've made any changes doing the above QA (change PR titles, revert PRs), manually trigger a re-generation of this PR with [this workflow](https://github.com/storybookjs/monorepo-release-tooling-prototype/actions/workflows/prepare-prerelease.yml) and wait for it to finish. It will wipe your progress in this to do, which is expected. + + When everything above is done: + - [ ] Merge this PR + - [ ] [Follow the publish workflow run and see it finishes succesfully](https://github.com/storybookjs/monorepo-release-tooling-prototype/actions/workflows/publish.yml) + + --- + + # Generated changelog + + ${changelogText}` + // don't mention contributors in the release PR, to avoid spamming them + .replaceAll('[@', '[@ ') + .replaceAll('"', '\\"') + .replaceAll('`', '\\`') + .replaceAll("'", "\\'") + ); +}; + +export const generateNonReleaseDescription = ( + changeList: string, + manualCherryPicks?: string +): string => { + return ( + dedent`This is an automated pull request. None of the changes requires a version bump, they are only internal or documentation related. Merging this PR will not trigger a new release, but documentation will be updated. + If you're not a core maintainer with permissions to release you can ignore this pull request. + + This is a list of all the PRs merged and commits pushed directly to \`next\` since the last release: + + ${changeList} + + ${manualCherryPicks ? manualCherryPicks : ''} + + If you've made any changes (change PR titles, revert PRs), manually trigger a re-generation of this PR with [this workflow](https://github.com/storybookjs/monorepo-release-tooling-prototype/actions/workflows/prepare-prerelease.yml) and wait for it to finish. + + When everything above is done: + - [ ] Merge this PR + - [ ] [Approve the publish workflow run](https://github.com/storybookjs/monorepo-release-tooling-prototype/actions/workflows/publish.yml)` + // don't mention contributors in the release PR, to avoid spamming them + .replaceAll('[@', '[@ ') + .replaceAll('"', '\\"') + .replaceAll('`', '\\`') + .replaceAll("'", "\\'") + ); +}; + +export const run = async (rawOptions: unknown) => { + const { nextVersion, unpickedPatches, verbose, manualCherryPicks, ...options } = + optionsSchema.parse(rawOptions) as Options; + + if (!nextVersion) { + console.log( + '🚨 --next-version option not specified, generating PR description assuming no release is needed' + ); + } + + const currentVersion = options.currentVersion || (await getCurrentVersion()); + + console.log( + `πŸ’¬ Generating PR description for ${chalk.blue(nextVersion)} between ${chalk.green( + currentVersion + )} and ${chalk.green('HEAD')}` + ); + + const { changes, changelogText } = await getChanges({ + version: nextVersion, + from: `v${currentVersion}`, + to: 'HEAD', + unpickedPatches, + verbose, + }); + + const hasCherryPicks = manualCherryPicks?.length > 0; + + const description = nextVersion + ? generateReleaseDescription({ + currentVersion, + nextVersion, + changeList: mapToChangelist({ changes, isRelease: true }), + changelogText, + ...(hasCherryPicks && { + manualCherryPicks: mapCherryPicksToTodo({ + commits: manualCherryPicks, + changes, + verbose, + }), + }), + }) + : generateNonReleaseDescription( + mapToChangelist({ changes, isRelease: false }), + hasCherryPicks ? mapCherryPicksToTodo({ + commits: manualCherryPicks, + changes, + verbose, + }) : undefined + ); + + if (process.env.GITHUB_ACTIONS === 'true') { + setOutput('description', description); + } + console.log(`βœ… Generated PR description for ${chalk.blue(nextVersion)}`); + if (verbose) { + console.log(description); + } +}; + +if (require.main === module) { + const parsed = program.parse(); + run(parsed.opts()).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/scripts/release/get-changelog-from-file.ts b/scripts/release/get-changelog-from-file.ts new file mode 100644 index 00000000000..27b154d8d74 --- /dev/null +++ b/scripts/release/get-changelog-from-file.ts @@ -0,0 +1,52 @@ +/* eslint-disable no-console */ +import { setOutput } from '@actions/core'; +import chalk from 'chalk'; +import { program } from 'commander'; +import { readFile } from 'fs-extra'; +import path from 'path'; +import semver from 'semver'; +import dedent from 'ts-dedent'; +import { getCurrentVersion } from './get-current-version'; + +program + .name('get-changelog-from-file') + .description( + 'get changelog entry for specific version. If not version argument specified it will use the current version in code/package.json' + ) + .arguments('[version]') + .option('-V, --verbose', 'Enable verbose logging', false); + +export const getChangelogFromFile = async (args: { version?: string; verbose?: boolean }) => { + const version = args.version || (await getCurrentVersion()); + const isPrerelease = semver.prerelease(version) !== null; + const changelogFilename = isPrerelease ? 'CHANGELOG.prerelease.md' : 'CHANGELOG.md'; + const changelogPath = path.join(__dirname, '..', '..', changelogFilename); + + console.log(`πŸ“ Getting changelog from ${chalk.blue(changelogPath)}`); + + const fullChangelog = await readFile(changelogPath, 'utf-8'); + const changelogForVersion = fullChangelog.split(/(^|\n)## /).find((v) => v.startsWith(version)); + if (!changelogForVersion) { + throw new Error( + `Could not find changelog entry for version ${chalk.blue(version)} in ${chalk.green( + changelogPath + )}` + ); + } + const result = `## ${changelogForVersion}`; + + console.log(dedent`πŸ“ Changelog entry found: + ${result}`); + if (process.env.GITHUB_ACTIONS === 'true') { + setOutput('changelog', result); + } + return result; +}; + +if (require.main === module) { + const parsed = program.parse(); + getChangelogFromFile({ version: parsed.args[0], verbose: parsed.opts().verbose }).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/scripts/release/get-current-version.ts b/scripts/release/get-current-version.ts new file mode 100644 index 00000000000..d6cfa0d9d80 --- /dev/null +++ b/scripts/release/get-current-version.ts @@ -0,0 +1,28 @@ +/* eslint-disable no-continue */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable no-console */ +import chalk from 'chalk'; +import { setOutput } from '@actions/core'; +import path from 'path'; +import { readJson } from 'fs-extra'; + +const CODE_DIR_PATH = path.join(__dirname, '..', '..', 'code'); +const CODE_PACKAGE_JSON_PATH = path.join(CODE_DIR_PATH, 'package.json'); + +export const getCurrentVersion = async () => { + console.log(`πŸ“ Reading current version of Storybook...`); + const { version } = (await readJson(CODE_PACKAGE_JSON_PATH)) as { version: string }; + if (process.env.GITHUB_ACTIONS === 'true') { + setOutput('current-version', version); + } + console.log(`πŸ“¦ Current version is ${chalk.green(version)}`); + return version; +}; + +if (require.main === module) { + getCurrentVersion().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/scripts/release/get-version-changelog.ts b/scripts/release/get-version-changelog.ts new file mode 100644 index 00000000000..3ece5480236 --- /dev/null +++ b/scripts/release/get-version-changelog.ts @@ -0,0 +1,35 @@ +/* eslint-disable no-console */ +import { setOutput } from '@actions/core'; +import chalk from 'chalk'; +import { program } from 'commander'; +import { getCurrentVersion } from './get-current-version'; +import { getChanges } from './utils/get-changes'; + +program + .name('get-version-changelog') + .description( + 'get changelog for specific version. If no version argument specified it will use the current version in code/package.json' + ) + .arguments('[version]') + .option('-V, --verbose', 'Enable verbose logging', false); + +export const getVersionChangelog = async (args: { version?: string; verbose?: boolean }) => { + const version = args.version || (await getCurrentVersion()); + + console.log(`πŸ“ Getting changelog for version ${chalk.blue(version)}`); + + const { changelogText } = await getChanges({ from: version, version, verbose: args.verbose }); + + if (process.env.GITHUB_ACTIONS === 'true') { + setOutput('changelog', changelogText); + } + return changelogText; +}; + +if (require.main === module) { + const parsed = program.parse(); + getVersionChangelog({ version: parsed.args[0], verbose: parsed.opts().verbose }).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/scripts/release/is-pr-frozen.ts b/scripts/release/is-pr-frozen.ts new file mode 100644 index 00000000000..a762882b504 --- /dev/null +++ b/scripts/release/is-pr-frozen.ts @@ -0,0 +1,104 @@ +/* eslint-disable no-continue */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable no-console */ +import chalk from 'chalk'; +import program from 'commander'; +import { simpleGit } from 'simple-git'; +import { setOutput } from '@actions/core'; +import path from 'path'; +import { readJson } from 'fs-extra'; +import { getPullInfoFromCommit } from './utils/get-github-info'; + +program + .name('is-pr-frozen') + .description( + 'returns true if the versioning pull request associated with the current branch has the "freeze" label' + ) + .option('-P, --patch', 'Look for patch PR instead of prerelease PR', false) + .option('-V, --verbose', 'Enable verbose logging', false); + +const git = simpleGit(); + +const CODE_DIR_PATH = path.join(__dirname, '..', '..', 'code'); +const CODE_PACKAGE_JSON_PATH = path.join(CODE_DIR_PATH, 'package.json'); + +const getCurrentVersion = async () => { + console.log(`πŸ“ Reading current version of Storybook...`); + const { version } = await readJson(CODE_PACKAGE_JSON_PATH); + return version; +}; + +const getRepo = async (verbose?: boolean): Promise => { + const remotes = await git.getRemotes(true); + const originRemote = remotes.find((remote) => remote.name === 'origin'); + if (!originRemote) { + console.error( + 'Could not determine repository URL because no remote named "origin" was found. Remotes found:' + ); + console.dir(remotes, { depth: null, colors: true }); + throw new Error('No remote named "origin" found'); + } + const pushUrl = originRemote.refs.push; + const repo = pushUrl.replace(/\.git$/, '').replace(/.*:(\/\/github\.com\/)*/, ''); + if (verbose) { + console.log(`πŸ“¦ Extracted repo: ${chalk.blue(repo)}`); + } + return repo; +}; + +export const run = async (options: unknown) => { + const { verbose, patch } = options as { verbose?: boolean; patch?: boolean }; + + const version = await getCurrentVersion(); + const branch = `version-from-${patch ? 'patch' : 'prerelease'}-${version}`; + + console.log(`πŸ’¬ Determining if pull request from branch '${chalk.blue(branch)}' is frozen`); + + console.log(`⬇️ Fetching remote 'origin/${branch}'...`); + try { + await git.fetch('origin', branch, { '--depth': 1 }); + } catch (error) { + console.warn( + `❗ Could not fetch remote 'origin/${branch}', it probably does not exist yet, which is okay` + ); + console.warn(error); + console.log(`πŸ’§ Pull request doesn't exist yet! 😎`); + if (process.env.GITHUB_ACTIONS === 'true') { + setOutput('frozen', false); + } + return false; + } + + const commit = await git.revparse(`origin/${branch}`); + console.log(`πŸ” Found commit: ${commit}`); + + const repo = await getRepo(verbose); + + const pullRequest = await getPullInfoFromCommit({ repo, commit }).catch((err) => { + console.error(`🚨 Could not get pull requests from commit: ${commit}`); + console.error(err); + throw err; + }); + console.log(`πŸ” Found pull request: + ${JSON.stringify(pullRequest, null, 2)}`); + + const isFrozen = pullRequest.labels?.includes('freeze'); + if (process.env.GITHUB_ACTIONS === 'true') { + setOutput('frozen', isFrozen); + } + if (isFrozen) { + console.log(`🧊 Pull request is frozen! πŸ₯Ά`); + } else { + console.log(`πŸ”₯ Pull request is on fire! πŸ₯΅`); + } + return isFrozen; +}; + +if (require.main === module) { + const parsed = program.parse(); + run(parsed.opts()).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/scripts/release/is-prerelease.ts b/scripts/release/is-prerelease.ts new file mode 100644 index 00000000000..6d6fcc5395f --- /dev/null +++ b/scripts/release/is-prerelease.ts @@ -0,0 +1,37 @@ +/* eslint-disable no-continue */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable no-console */ +import chalk from 'chalk'; +import program from 'commander'; +import { setOutput } from '@actions/core'; +import semver from 'semver'; +import { getCurrentVersion } from './get-current-version'; + +program + .name('is-prerelease') + .description('returns true if the current version is a prerelease') + .option('-V, --verbose', 'Enable verbose logging', false); + +export const isPrerelease = async (versionArg?: string) => { + const version = versionArg || (await getCurrentVersion()); + const result = semver.prerelease(version) !== null; + + if (process.env.GITHUB_ACTIONS === 'true') { + setOutput('prerelease', result); + } + console.log( + `πŸ“¦ Current version ${chalk.green(version)} ${ + result ? chalk.blue('IS') : chalk.red('IS NOT') + } a prerelease` + ); + + return result; +}; + +if (require.main === module) { + isPrerelease().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/scripts/release/is-version-published.ts b/scripts/release/is-version-published.ts new file mode 100644 index 00000000000..fa1e15bd6b8 --- /dev/null +++ b/scripts/release/is-version-published.ts @@ -0,0 +1,89 @@ +/* eslint-disable no-continue */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable no-console */ +import chalk from 'chalk'; +import program from 'commander'; +import { setOutput } from '@actions/core'; +import fetch from 'node-fetch'; +import { getCurrentVersion } from './get-current-version'; + +program + .name('is-prerelease [version]') + .description('returns true if the current version is a prerelease') + .arguments('[version]'); + +const isVersionPublished = async ({ + packageName, + version, + verbose, +}: { + packageName: string; + version: string; + verbose?: boolean; +}) => { + const prettyPackage = `${chalk.blue(packageName)}@${chalk.green(version)}`; + console.log(`β›… Checking if ${prettyPackage} is published...`); + + if (verbose) { + console.log(`Fetching from npm:`); + console.log(`https://registry.npmjs.org/${chalk.blue(packageName)}/${chalk.green(version)}`); + } + const response = await fetch(`https://registry.npmjs.org/${packageName}/${version}`); + if (response.status === 404) { + console.log(`🌀️ ${prettyPackage} is not published`); + return false; + } + if (response.status !== 200) { + console.error( + `Unexpected status code when checking the current version on npm: ${response.status}` + ); + console.error(await response.text()); + throw new Error( + `Unexpected status code when checking the current version on npm: ${response.status}` + ); + } + const data = await response.json(); + if (verbose) { + console.log(`Response from npm:`); + console.log(data); + } + if (data.version !== version) { + // this should never happen + console.error( + `Unexpected version received when checking the current version on npm: ${data.version}` + ); + console.error(JSON.stringify(data, null, 2)); + throw new Error( + `Unexpected version received when checking the current version on npm: ${data.version}` + ); + } + + console.log(`β›ˆοΈ ${prettyPackage} is published`); + return true; +}; + +export const run = async (args: unknown[], options: unknown) => { + const { verbose } = options as { verbose?: boolean }; + + const version = (args[0] as string) || (await getCurrentVersion()); + + const isAlreadyPublished = await isVersionPublished({ + version, + packageName: '@junk-temporary-prototypes/manager-api', + verbose, + }); + + if (process.env.GITHUB_ACTIONS === 'true') { + setOutput('published', isAlreadyPublished); + } + return isAlreadyPublished; +}; + +if (require.main === module) { + const parsed = program.parse(); + run(parsed.args, parsed.opts()).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/scripts/release/pick-patches.ts b/scripts/release/pick-patches.ts new file mode 100644 index 00000000000..50dc034ff1e --- /dev/null +++ b/scripts/release/pick-patches.ts @@ -0,0 +1,152 @@ +/* eslint-disable no-await-in-loop */ +import program from 'commander'; +import chalk from 'chalk'; +import { v4 as uuidv4 } from 'uuid'; +import type { GraphQlQueryResponseData } from '@octokit/graphql'; +import ora from 'ora'; +import { simpleGit } from 'simple-git'; +import { getUnpickedPRs } from './utils/get-unpicked-prs'; +import { setOutput } from '@actions/core'; +import { githubGraphQlClient } from './utils/github-client'; + +program.name('pick-patches').description('Cherry pick patch PRs back to main'); + +const logger = console; + +const OWNER = 'storybookjs'; +const REPO = 'monorepo-release-tooling-prototype'; +const SOURCE_BRANCH = 'next-v2'; + +const git = simpleGit(); + +interface PR { + number: number; + id: string; + branch: string; + title: string; + mergeCommit: string; +} + +const LABEL = { + PATCH: 'patch', + PICKED: 'picked', + DOCUMENTATION: 'documentation', +} as const; + +function formatPR(pr: PR): string { + return `https://github.com/${OWNER}/${REPO}/pull/${pr.number} "${pr.title}" ${chalk.yellow( + pr.mergeCommit + )}`; +} + +// @ts-expect-error not used atm +async function getLabelIds(labelNames: string[]) { + const query = labelNames.join('+'); + const result = await githubGraphQlClient( + ` + query ($owner: String!, $repo: String!, $q: String!) { + repository(owner: $owner, name: $repo) { + labels(query: $q, first: 10) { + nodes { + id + name + description + } + } + } + } + `, + { + owner: OWNER, + repo: REPO, + q: query, + } + ); + + const { labels } = result.repository; + const labelToId = {} as Record; + labels.nodes.forEach((label: { name: string; id: string }) => { + labelToId[label.name] = label.id; + }); + return labelToId; +} + +// @ts-expect-error not used atm +async function labelPR(id: string, labelToId: Record) { + await githubGraphQlClient( + ` + mutation ($input: AddLabelsToLabelableInput!) { + addLabelsToLabelable(input: $input) { + clientMutationId + } + } + `, + { + input: { + labelIds: [labelToId[LABEL.PICKED]], + labelableId: id, + clientMutationId: uuidv4(), + }, + } + ); +} + +export const run = async (_: unknown) => { + if (!process.env.GH_TOKEN) { + logger.error('GH_TOKEN environment variable must be set, exiting.'); + process.exit(1); + } + + const sourceBranch = SOURCE_BRANCH; + + const spinner = ora('Searching for patch PRs to cherry-pick').start(); + + // const labelToId = await getLabelIds(Object.values(LABEL)); + const patchPRs = await getUnpickedPRs(sourceBranch); + + if (patchPRs.length > 0) { + spinner.succeed(`Found ${patchPRs.length} PRs to cherry-pick to main.`); + } else { + spinner.warn('No PRs found.'); + } + + const failedCherryPicks: string[] = []; + + for (const pr of patchPRs) { + const spinner = ora(`Cherry picking #${pr.number}`).start(); + + try { + await git.raw(['cherry-pick', '-m', '1', '-x', pr.mergeCommit]); + spinner.succeed(`Picked: ${formatPR(pr)}`); + } catch (pickError) { + spinner.fail(`Failed to automatically pick: ${formatPR(pr)}`); + logger.error(pickError.message); + const abort = ora(`Aborting cherry pick for merge commit: ${pr.mergeCommit}`).start(); + try { + await git.raw(['cherry-pick', '--abort']); + abort.stop(); + } catch (abortError) { + abort.warn(`Failed to abort cherry pick (${pr.mergeCommit})`); + logger.error(pickError.message); + } + failedCherryPicks.push(pr.mergeCommit); + spinner.info( + `This PR can be picked manually with: ${chalk.grey( + `git cherry-pick -m1 -x ${pr.mergeCommit}` + )}` + ); + } + } + + if (process.env.GITHUB_ACTIONS === 'true') { + setOutput('failed-cherry-picks', JSON.stringify(failedCherryPicks)); + } +}; + +if (require.main === module) { + const options = program.parse(process.argv); + run(options).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/scripts/release/publish.ts b/scripts/release/publish.ts new file mode 100644 index 00000000000..43e422eae10 --- /dev/null +++ b/scripts/release/publish.ts @@ -0,0 +1,209 @@ +/* eslint-disable no-continue */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable no-console */ +import chalk from 'chalk'; +import path from 'path'; +import program from 'commander'; +import semver from 'semver'; +import { z } from 'zod'; +import { readJson } from 'fs-extra'; +import fetch from 'node-fetch'; +import dedent from 'ts-dedent'; +import { execaCommand } from '../utils/exec'; + +program + .name('publish') + .description('publish all packages') + .requiredOption( + '-T, --tag ', + 'Specify which distribution tag to set for the version being published. Required, since leaving it undefined would publish with the "latest" tag' + ) + .option('-D, --dry-run', 'Do not publish, only output to shell', false) + .option('-V, --verbose', 'Enable verbose logging', false); + +const optionsSchema = z + .object({ + tag: z.string(), + verbose: z.boolean().optional(), + dryRun: z.boolean().optional(), + }) + .refine((schema) => (schema.tag ? !semver.valid(schema.tag) : true), { + message: + 'The tag can not be a valid semver version, it must be a plain string like "next" or "latest"', + }); + +type Options = { + tag: string; + verbose: boolean; + dryRun?: boolean; +}; + +const CODE_DIR_PATH = path.join(__dirname, '..', '..', 'code'); +const CODE_PACKAGE_JSON_PATH = path.join(CODE_DIR_PATH, 'package.json'); + +const validateOptions = (options: { [key: string]: any }): options is Options => { + optionsSchema.parse(options); + return true; +}; + +const getCurrentVersion = async (verbose?: boolean) => { + if (verbose) { + console.log(`πŸ“ Reading current version of Storybook...`); + } + const { version } = await readJson(CODE_PACKAGE_JSON_PATH); + console.log(`πŸ“ Current version of Storybook is ${chalk.green(version)}`); + return version; +}; + +const isCurrentVersionPublished = async ({ + packageName, + currentVersion, + verbose, +}: { + packageName: string; + currentVersion: string; + verbose?: boolean; +}) => { + const prettyPackage = `${chalk.blue(packageName)}@${chalk.green(currentVersion)}`; + console.log(`β›… Checking if ${prettyPackage} is published...`); + + if (verbose) { + console.log(`Fetching from npm:`); + console.log( + `https://registry.npmjs.org/${chalk.blue(packageName)}/${chalk.green(currentVersion)}` + ); + } + const response = await fetch(`https://registry.npmjs.org/${packageName}/${currentVersion}`); + if (response.status === 404) { + console.log(`🌀️ ${prettyPackage} is not published`); + return false; + } + if (response.status !== 200) { + console.error( + `Unexpected status code when checking the current version on npm: ${response.status}` + ); + console.error(await response.text()); + throw new Error( + `Unexpected status code when checking the current version on npm: ${response.status}` + ); + } + const data = await response.json(); + if (verbose) { + console.log(`Response from npm:`); + console.log(data); + } + if (data.version !== currentVersion) { + // this should never happen + console.error( + `Unexpected version received when checking the current version on npm: ${data.version}` + ); + console.error(JSON.stringify(data, null, 2)); + throw new Error( + `Unexpected version received when checking the current version on npm: ${data.version}` + ); + } + + console.log(`β›ˆοΈ ${prettyPackage} is published`); + return true; +}; + +const buildAllPackages = async () => { + console.log(`πŸ—οΈ Building all packages...`); + await execaCommand('yarn task --task=compile --start-from=compile --no-link', { + stdio: 'inherit', + cwd: CODE_DIR_PATH, + }); + console.log(`πŸ—οΈ Packages successfully built`); +}; + +const publishAllPackages = async ({ + tag, + verbose, + dryRun, +}: { + tag: string; + verbose?: boolean; + dryRun?: boolean; +}) => { + console.log(`πŸ“¦ Publishing all packages...`); + const command = `yarn workspaces foreach --parallel --no-private --verbose npm publish --tolerate-republish --tag ${tag}`; + if (verbose) { + console.log(`πŸ“¦ Executing: ${command}`); + } + if (dryRun) { + console.log(`πŸ“¦ Dry run, skipping publish. Would have executed: + ${chalk.blue(command)}`); + return; + } + + // Note this is to fool `ts-node` into not turning the `import()` into a `require()`. + // See: https://github.com/TypeStrong/ts-node/discussions/1290 + // prettier-ignore + const pRetry = ( + // eslint-disable-next-line @typescript-eslint/no-implied-eval + (await new Function('specifier', 'return import(specifier)')( + 'p-retry' + )) as typeof import('p-retry') + ).default; + /** + * 'yarn npm publish' will fail if just one package fails to publish. + * But it will continue through with all the other packages, and --tolerate-republish makes it okay to publish the same version again. + * So we can safely retry the whole publishing process if it fails. + * It's not uncommon for the registry to fail often, which Yarn catches by checking the registry after a package has been published. + */ + await pRetry( + () => + execaCommand(command, { + stdio: 'inherit', + cwd: CODE_DIR_PATH, + }), + { + retries: 4, + onFailedAttempt: (error) => + console.log( + chalk.yellow( + dedent`❗One or more packages failed to publish, retrying... + This was attempt number ${error.attemptNumber}, there are ${error.retriesLeft} retries left. 🀞` + ) + ), + } + ); + console.log(`πŸ“¦ Packages successfully published`); +}; + +export const run = async (options: unknown) => { + if (!validateOptions(options)) { + return; + } + const { tag, dryRun, verbose } = options; + + // Get the current version from code/package.json + const currentVersion = await getCurrentVersion(verbose); + const isAlreadyPublished = await isCurrentVersionPublished({ + currentVersion, + packageName: '@junk-temporary-prototypes/manager-api', + verbose, + }); + if (isAlreadyPublished) { + throw new Error( + `β›” Current version (${chalk.green(currentVersion)}) is already published, aborting.` + ); + } + await buildAllPackages(); + await publishAllPackages({ tag, verbose, dryRun }); + + console.log( + `βœ… Published all packages with version ${chalk.green(currentVersion)}${ + tag ? ` at tag ${chalk.blue(tag)}` : '' + }` + ); +}; + +if (require.main === module) { + const parsed = program.parse(); + run(parsed.opts()).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/scripts/release/unreleased-changes-exists.ts b/scripts/release/unreleased-changes-exists.ts new file mode 100644 index 00000000000..8b168e35c62 --- /dev/null +++ b/scripts/release/unreleased-changes-exists.ts @@ -0,0 +1,92 @@ +/* eslint-disable no-continue */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable no-console */ +import chalk from 'chalk'; +import program from 'commander'; +import { z } from 'zod'; +import { setOutput } from '@actions/core'; +import { intersection } from 'lodash'; +import type { Change } from './utils/get-changes'; +import { getChanges } from './utils/get-changes'; +import { getCurrentVersion } from './get-current-version'; + +program + .name('are-changes-unreleased') + .description('check if any changes since a release should be released') + .option( + '-F, --from ', + 'Which version/tag/commit to go back and check changes from. Defaults to latest release tag' + ) + .option('-P, --unpicked-patches', 'Set to only consider PRs labeled with "patch" label') + .option('-V, --verbose', 'Enable verbose logging', false); + +const optionsSchema = z.object({ + from: z.string().optional(), + unpickedPatches: z.boolean().optional(), + verbose: z.boolean().optional(), +}); + +type Options = { + from?: string; + unpickedPatches?: boolean; + verbose: boolean; +}; + +const validateOptions = (options: { [key: string]: any }): options is Options => { + optionsSchema.parse(options); + return true; +}; + +const LABELS_TO_RELEASE = ['BREAKING CHANGE', 'feature request', 'bug', 'maintenance'] as const; + +export const run = async ( + options: unknown +): Promise<{ changesToRelease: Change[]; hasChangesToRelease: boolean }> => { + if (!validateOptions(options)) { + // this will never return because the validator throws + return { changesToRelease: [], hasChangesToRelease: false }; + } + const { from, unpickedPatches, verbose } = options; + + const currentVersion = await getCurrentVersion(); + + console.log( + `πŸ“ Checking if there are any unreleased changes...` + ); + + const { changes } = await getChanges({ + version: currentVersion, + from: from || currentVersion, + to: 'HEAD', + unpickedPatches, + verbose, + }); + + const changesToRelease = changes + .filter(({ labels }) => intersection(LABELS_TO_RELEASE, labels).length > 0); + + const hasChangesToRelease = changesToRelease.length > 0; + + if (process.env.GITHUB_ACTIONS === 'true') { + setOutput('has-changes-to-release', hasChangesToRelease); + } + if (hasChangesToRelease) { + console.log( + `${chalk.green('πŸ¦‹ The following changes are releasable')}: +${chalk.blue(changesToRelease.map(({ title, pull }) => ` #${pull}: ${title}`).join('\n'))}` + ); + } else { + console.log(chalk.red('πŸ«™ No changes to release!')); + } + + return { changesToRelease, hasChangesToRelease }; +}; + +if (require.main === module) { + const parsed = program.parse(); + run(parsed.opts()).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/scripts/release/utils/__mocks__/get-github-info.js b/scripts/release/utils/__mocks__/get-github-info.js new file mode 100644 index 00000000000..6612d35a51d --- /dev/null +++ b/scripts/release/utils/__mocks__/get-github-info.js @@ -0,0 +1,4 @@ +module.exports = { + getPullInfoFromCommit: jest.fn(), + getPullInfoFromPullRequest: jest.fn(), +} diff --git a/scripts/release/utils/get-changes.ts b/scripts/release/utils/get-changes.ts new file mode 100644 index 00000000000..d3e2a006249 --- /dev/null +++ b/scripts/release/utils/get-changes.ts @@ -0,0 +1,250 @@ +/* eslint-disable no-console */ +import chalk from 'chalk'; +import semver from 'semver'; +import simpleGit from 'simple-git'; +import type { PullRequestInfo } from './get-github-info'; +import { getPullInfoFromCommit } from './get-github-info'; +import { getUnpickedPRs } from './get-unpicked-prs'; + +const LABELS_FOR_CHANGELOG = ['BREAKING CHANGE', 'feature request', 'bug', 'maintenance']; + +const git = simpleGit(); + +const getCommitAt = async (id: string, verbose?: boolean) => { + if (!semver.valid(id)) { + console.log(`πŸ” ${chalk.red(id)} is not a valid semver string, assuming it is a commit hash`); + return id; + } + const version = id.startsWith('v') ? id : `v${id}`; + const commitSha = (await git.raw(['rev-list', '-n', '1', version])).split('\n')[0]; + if (verbose) { + console.log(`πŸ” Commit at tag ${chalk.green(version)}: ${chalk.blue(commitSha)}`); + } + return commitSha; +}; + +export const getFromCommit = async (from?: string | undefined, verbose?: boolean) => { + let actualFrom = from; + if (!from) { + console.log(`πŸ” No 'from' specified, finding latest version tag, fetching all of them...`); + // await git.fetch('origin', ['--all', '--tags']); + const { latest } = await git.tags(['v*', '--sort=-v:refname', '--merged']); + if (!latest) { + throw new Error( + 'Could not automatically detect which commit to generate from, because no version tag was found in the history. Have you fetch tags?' + ); + } + actualFrom = latest; + if (verbose) { + console.log(`πŸ” No 'from' specified, found latest tag: ${chalk.blue(latest)}`); + } + } + const commit = await getCommitAt(actualFrom, verbose); + if (verbose) { + console.log(`πŸ” Found 'from' commit: ${chalk.blue(commit)}`); + } + return commit; +}; + +export const getToCommit = async (to?: string | undefined, verbose?: boolean) => { + if (!to) { + const head = await git.revparse('HEAD'); + if (verbose) { + console.log(`πŸ” No 'to' specified, HEAD is at commit: ${chalk.blue(head)}`); + } + return head; + } + + const commit = await getCommitAt(to, verbose); + if (verbose) { + console.log(`πŸ” Found 'to' commit: ${chalk.blue(commit)}`); + } + return commit; +}; + +export const getAllCommitsBetween = async ({ + from, + to, + verbose, +}: { + from: string; + to?: string; + verbose?: boolean; +}) => { + const logResult = await git.log({ from, to }); + if (verbose) { + console.log( + `πŸ” Found ${chalk.blue(logResult.total)} commits between ${chalk.green( + `${from}` + )} and ${chalk.green(`${to}`)}:` + ); + console.dir(logResult.all, { depth: null, colors: true }); + } + return logResult.all; +}; + +export const getRepo = async (verbose?: boolean): Promise => { + const remotes = await git.getRemotes(true); + const originRemote = remotes.find((remote) => remote.name === 'origin'); + if (!originRemote) { + console.error( + 'Could not determine repository URL because no remote named "origin" was found. Remotes found:' + ); + console.dir(remotes, { depth: null, colors: true }); + throw new Error('No remote named "origin" found'); + } + const pushUrl = originRemote.refs.push; + const repo = pushUrl.replace(/\.git$/, '').replace(/.*:(\/\/github\.com\/)*/, ''); + if (verbose) { + console.log(`πŸ“¦ Extracted repo: ${chalk.blue(repo)}`); + } + return repo; +}; + +export const getPullInfoFromCommits = async ({ + repo, + commits, + verbose, +}: { + repo: string; + commits: readonly { hash: string }[]; + verbose?: boolean; +}): Promise => { + const pullRequests = await Promise.all( + commits.map((commit) => + getPullInfoFromCommit({ + repo, + commit: commit.hash, + }) + ) + ); + if (verbose) { + console.log(`πŸ” Found pull requests:`); + console.dir(pullRequests, { depth: null, colors: true }); + } + return pullRequests; +}; + +export type Change = PullRequestInfo; + +export const mapToChanges = ({ + commits, + pullRequests, + unpickedPatches, + verbose, +}: { + commits: readonly { hash: string; message?: string }[]; + pullRequests: PullRequestInfo[]; + unpickedPatches?: boolean; + verbose?: boolean; +}): Change[] => { + if (pullRequests.length !== commits.length) { + // not all commits are associated with a pull request, but the pullRequests array should still contain those commits + console.error('Pull requests and commits are not the same length, this should not happen'); + console.error(`Pull Requests: ${pullRequests.length}`); + console.dir(pullRequests, { depth: null, colors: true }); + console.error(`Commits: ${commits.length}`); + console.dir(commits, { depth: null, colors: true }); + throw new Error('Pull requests and commits are not the same length, this should not happen'); + } + const allEntries = pullRequests.map((pr, index) => { + return { + ...pr, + title: pr.title || commits[index].message, + }; + }); + + const changes: Change[] = []; + allEntries.forEach((entry) => { + // filter out any duplicate entries, eg. when multiple commits are associated with the same pull request + if (entry.pull && changes.findIndex((existing) => entry.pull === existing.pull) !== -1) { + return; + } + // filter out any entries that are not patches if unpickedPatches is set. this will also filter out direct commits + if (unpickedPatches && !entry.labels?.includes('patch')) { + return; + } + changes.push(entry); + }); + + if (verbose) { + console.log(`πŸ“ Generated changelog entries:`); + console.dir(changes, { depth: null, colors: true }); + } + return changes; +}; + +export const getChangelogText = ({ + changes, + version, +}: { + changes: Change[]; + version: string; +}): string => { + const heading = `## ${version}`; + const formattedEntries = changes + .filter((entry) => { + // don't include direct commits that are not from pull requests + if (!entry.pull) { + return false; + } + // only include PRs that with labels listed in LABELS_FOR_CHANGELOG + return entry.labels?.some((label) => LABELS_FOR_CHANGELOG.includes(label)); + }) + .map((entry) => { + const { title, links } = entry; + const { pull, commit, user } = links; + return pull + ? `- ${title} - ${pull}, thanks ${user}!` + : `- ⚠️ _Direct commit_ ${title} - ${commit} by ${user}`; + }); + const text = [heading, '', ...formattedEntries].join('\n'); + + console.log(`βœ… Generated Changelog:`); + console.log(text); + + return text; +}; + +export const getChanges = async ({ + version, + from, + to, + unpickedPatches, + verbose, +}: { + version: string; + from?: string; + to?: string; + unpickedPatches?: boolean; + verbose?: boolean; +}) => { + console.log(`πŸ’¬ Getting changes for ${chalk.blue(version)}`); + + let commits; + if (unpickedPatches) { + commits = (await getUnpickedPRs('next-v2', verbose)).map((it) => ({ hash: it.mergeCommit })); + } else { + commits = await getAllCommitsBetween({ + from: await getFromCommit(from, verbose), + to: await getToCommit(to, verbose), + verbose, + }); + } + + const repo = await getRepo(verbose); + const pullRequests = await getPullInfoFromCommits({ repo, commits, verbose }).catch((err) => { + console.error( + `🚨 Could not get pull requests from commits, this is usually because you have unpushed commits, or you haven't set the GH_TOKEN environment variable` + ); + console.error(err); + throw err; + }); + const changes = mapToChanges({ commits, pullRequests, unpickedPatches, verbose }); + const changelogText = getChangelogText({ + changes, + version, + }); + + return { changes, changelogText }; +}; diff --git a/scripts/release/utils/get-github-info.ts b/scripts/release/utils/get-github-info.ts new file mode 100644 index 00000000000..313814dc745 --- /dev/null +++ b/scripts/release/utils/get-github-info.ts @@ -0,0 +1,289 @@ +/** + * This file is soft-forked from @changesets/get-github-info + * https://github.com/changesets/changesets/tree/main/packages/get-github-info + * + * The only modification is that it also returns the PR title and labels + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import DataLoader from 'dataloader'; +import fetch from 'node-fetch'; + +const validRepoNameRegex = /^[\w.-]+\/[\w.-]+$/; + +type RequestData = + | { kind: 'commit'; repo: string; commit: string } + | { kind: 'pull'; repo: string; pull: number }; + +type ReposWithCommitsAndPRsToFetch = Record< + string, + ({ kind: 'commit'; commit: string } | { kind: 'pull'; pull: number })[] +>; + +function makeQuery(repos: ReposWithCommitsAndPRsToFetch) { + return ` + query { + ${Object.keys(repos) + .map( + (repo, i) => + `a${i}: repository( + owner: ${JSON.stringify(repo.split('/')[0])} + name: ${JSON.stringify(repo.split('/')[1])} + ) { + ${repos[repo] + .map((data) => + data.kind === 'commit' + ? `a${data.commit}: object(expression: ${JSON.stringify(data.commit)}) { + ... on Commit { + commitUrl + associatedPullRequests(first: 50) { + nodes { + number + title + url + mergedAt + labels(first: 50) { + nodes { + name + } + } + author { + login + url + } + } + } + author { + user { + login + url + } + } + }}` + : `pr__${data.pull}: pullRequest(number: ${data.pull}) { + url + title + author { + login + url + } + labels(first: 50) { + nodes { + name + } + } + mergeCommit { + commitUrl + abbreviatedOid + } + }` + ) + .join('\n')} + }` + ) + .join('\n')} + } + `; +} + +// why are we using dataloader? +// it provides use with two things +// 1. caching +// since getInfo will be called inside of changeset's getReleaseLine +// and there could be a lot of release lines for a single commit +// caching is important so we don't do a bunch of requests for the same commit +// 2. batching +// getReleaseLine will be called a large number of times but it'll be called at the same time +// so instead of doing a bunch of network requests, we can do a single one. +const GHDataLoader = new DataLoader(async (requests: RequestData[]) => { + if (!process.env.GH_TOKEN) { + throw new Error( + 'Please create a GitHub personal access token at https://github.com/settings/tokens/new with `read:user` and `repo:status` permissions and add it as the GH_TOKEN environment variable' + ); + } + const repos: ReposWithCommitsAndPRsToFetch = {}; + requests.forEach(({ repo, ...data }) => { + if (repos[repo] === undefined) { + repos[repo] = []; + } + repos[repo].push(data); + }); + + const data = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { + Authorization: `Token ${process.env.GH_TOKEN}`, + }, + body: JSON.stringify({ query: makeQuery(repos) }), + }).then((x: any) => x.json()); + + if (data.errors) { + throw new Error( + `An error occurred when fetching data from GitHub\n${JSON.stringify(data.errors, null, 2)}` + ); + } + + // this is mainly for the case where there's an authentication problem + if (!data.data) { + throw new Error(`An error occurred when fetching data from GitHub\n${JSON.stringify(data)}`); + } + + const cleanedData: Record; pull: Record }> = + {}; + Object.keys(repos).forEach((repo, index) => { + const output: { commit: Record; pull: Record } = { + commit: {}, + pull: {}, + }; + cleanedData[repo] = output; + Object.entries(data.data[`a${index}`]).forEach(([field, value]) => { + // this is "a" because that's how it was when it was first written, "a" means it's a commit not a pr + // we could change it to commit__ but then we have to get new GraphQL results from the GH API to put in the tests + if (field[0] === 'a') { + output.commit[field.substring(1)] = value; + } else { + output.pull[field.replace('pr__', '')] = value; + } + }); + }); + + return requests.map( + ({ repo, ...rest }) => + cleanedData[repo][rest.kind][rest.kind === 'pull' ? rest.pull : rest.commit] + ); +}); + +export type PullRequestInfo = { + user: string | null; + title: string | null; + commit: string | null; + pull: string | null; + labels: string[] | null; + links: { + commit: string; + pull: string | null; + user: string | null; + }; +}; + +export async function getPullInfoFromCommit(request: { + commit: string; + repo: string; +}): Promise { + if (!request.commit) { + throw new Error('Please pass a commit SHA to getInfo'); + } + + if (!request.repo) { + throw new Error('Please pass a GitHub repository in the form of userOrOrg/repoName to getInfo'); + } + + if (!validRepoNameRegex.test(request.repo)) { + throw new Error( + `Please pass a valid GitHub repository in the form of userOrOrg/repoName to getInfo (it has to match the "${validRepoNameRegex.source}" pattern)` + ); + } + + const data = await GHDataLoader.load({ kind: 'commit', ...request }); + if (!data) { + return { + user: null, + pull: null, + commit: request.commit, + title: null, + labels: null, + links: { + commit: request.commit, + pull: null, + user: null, + }, + }; + } + let user = null; + if (data.author && data.author.user) { + user = data.author.user; + } + + const associatedPullRequest = + data.associatedPullRequests && + data.associatedPullRequests.nodes && + data.associatedPullRequests.nodes.length + ? (data.associatedPullRequests.nodes as any[]).sort((a, b) => { + if (a.mergedAt === null && b.mergedAt === null) { + return 0; + } + if (a.mergedAt === null) { + return 1; + } + if (b.mergedAt === null) { + return -1; + } + const aDate = new Date(a.mergedAt); + const bDate = new Date(b.mergedAt); + if (aDate > bDate) { + return 1; + } + if (aDate < bDate) { + return -1; + } + return 0; + })[0] + : null; + if (associatedPullRequest) { + user = associatedPullRequest.author; + } + + return { + user: user ? user.login : null, + pull: associatedPullRequest ? associatedPullRequest.number : null, + commit: request.commit, + title: associatedPullRequest ? associatedPullRequest.title : null, + labels: associatedPullRequest + ? (associatedPullRequest.labels.nodes || []).map((label: { name: string }) => label.name) + : null, + links: { + commit: `[\`${request.commit}\`](${data.commitUrl})`, + pull: associatedPullRequest + ? `[#${associatedPullRequest.number}](${associatedPullRequest.url})` + : null, + user: user ? `[@${user.login}](${user.url})` : null, + }, + }; +} + +export async function getPullInfoFromPullRequest(request: { + pull: number; + repo: string; +}): Promise { + if (request.pull === undefined) { + throw new Error('Please pass a pull request number'); + } + + if (!request.repo) { + throw new Error('Please pass a GitHub repository in the form of userOrOrg/repoName to getInfo'); + } + + if (!validRepoNameRegex.test(request.repo)) { + throw new Error( + `Please pass a valid GitHub repository in the form of userOrOrg/repoName to getInfo (it has to match the "${validRepoNameRegex.source}" pattern)` + ); + } + + const data = await GHDataLoader.load({ kind: 'pull', ...request }); + const user = data?.author; + const title = data?.title; + const commit = data?.mergeCommit; + + return { + user: user ? user.login : null, + pull: request.pull.toString(), + commit: commit ? commit.abbreviatedOid : null, + title: title || null, + labels: data ? (data.labels.nodes || []).map((label: { name: string }) => label.name) : null, + links: { + commit: commit ? `[\`${commit.abbreviatedOid}\`](${commit.commitUrl})` : null, + pull: `[#${request.pull}](https://github.com/${request.repo}/pull/${request.pull})`, + user: user ? `[@${user.login}](${user.url})` : null, + }, + }; +} diff --git a/scripts/release/utils/get-unpicked-prs.ts b/scripts/release/utils/get-unpicked-prs.ts new file mode 100644 index 00000000000..b1b27940a03 --- /dev/null +++ b/scripts/release/utils/get-unpicked-prs.ts @@ -0,0 +1,67 @@ +import type { GraphQlQueryResponseData } from "@octokit/graphql"; +import { githubGraphQlClient } from "./github-client"; + +export interface PR { + number: number; + id: string; + branch: string; + title: string; + mergeCommit: string; +} + +export async function getUnpickedPRs(baseBranch: string, verbose?: boolean): Promise> { + console.log(`πŸ’¬ Getting unpicked patch pull requests...`); + const result = await githubGraphQlClient( + ` + query ($owner: String!, $repo: String!, $state: PullRequestState!, $order: IssueOrder!) { + repository(owner: $owner, name: $repo) { + pullRequests(states: [$state], labels: ["patch"], orderBy: $order, first: 50) { + nodes { + id + number + title + baseRefName + mergeCommit { + abbreviatedOid + } + labels(first: 20) { + nodes { + name + } + } + } + } + } + } + `, + { + owner: 'storybookjs', + repo: 'monorepo-release-tooling-prototype', + order: { + field: 'UPDATED_AT', + direction: 'ASC', + }, + state: 'MERGED', + } + ); + + const { + pullRequests: { nodes }, + } = result.repository; + + const prs = nodes.map((node: any) => ({ + number: node.number, + id: node.id, + branch: node.baseRefName, + title: node.title, + mergeCommit: node.mergeCommit.abbreviatedOid, + labels: node.labels.nodes.map((l: any) => l.name), + })); + + const unpickedPRs = prs.filter((pr: any) => !pr.labels.includes('picked')).filter((pr: any) => pr.branch === baseBranch); + if(verbose){ + console.log(`πŸ” Found unpicked patch pull requests: + ${JSON.stringify(unpickedPRs, null, 2)}`); + } + return unpickedPRs; +} diff --git a/scripts/release/utils/github-client.ts b/scripts/release/utils/github-client.ts new file mode 100644 index 00000000000..8830beac160 --- /dev/null +++ b/scripts/release/utils/github-client.ts @@ -0,0 +1,5 @@ +import { graphql } from '@octokit/graphql'; + +export const githubGraphQlClient = graphql.defaults({ + headers: { authorization: `token ${process.env.GH_TOKEN}` }, +}); diff --git a/scripts/release/version.ts b/scripts/release/version.ts new file mode 100644 index 00000000000..1d40223bd97 --- /dev/null +++ b/scripts/release/version.ts @@ -0,0 +1,220 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable no-console */ +import { setOutput } from '@actions/core'; +import { ensureDir, readFile, readJson, writeFile, writeJson } from 'fs-extra'; +import chalk from 'chalk'; +import path from 'path'; +import program from 'commander'; +import semver from 'semver'; +import { z } from 'zod'; +import dedent from 'ts-dedent'; +import { execaCommand } from '../utils/exec'; +import { listOfPackages } from '../utils/list-packages'; +import packageVersionMap from '../../code/lib/cli/src/versions'; + +program + .name('version') + .description('version all packages') + .option( + '-R, --release-type ', + 'Which release type to use to bump the version' + ) + .option('-P, --pre-id ', 'Which prerelease identifer to change to, eg. "alpha", "beta", "rc"') + .option( + '-E, --exact ', + 'Use exact version instead of calculating from current version, eg. "7.2.0-canary.123". Can not be combined with --release-type or --pre-id' + ) + .option('-V, --verbose', 'Enable verbose logging', false); + +const optionsSchema = z + .object({ + releaseType: z + .enum(['major', 'minor', 'patch', 'prerelease', 'premajor', 'preminor', 'prepatch']) + .optional(), + preId: z.string().optional(), + exact: z + .string() + .optional() + .refine((version) => (version ? semver.valid(version) !== null : true), { + message: '--exact version has to be a valid semver string', + }), + verbose: z.boolean().optional(), + }) + .superRefine((schema, ctx) => { + // manual union validation because zod + commander is not great in this case + const hasExact = 'exact' in schema && schema.exact; + const hasReleaseType = 'releaseType' in schema && schema.releaseType; + if ((hasExact && hasReleaseType) || (!hasExact && !hasReleaseType)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'Combining --exact with --release-type is invalid, but having one of them is required', + }); + } + if (schema.preId && !schema.releaseType.startsWith('pre')) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'Using prerelease identifier requires one of release types: premajor, preminor, prepatch, prerelease', + }); + } + return z.NEVER; + }); + +type BaseOptions = { verbose: boolean }; +type BumpOptions = BaseOptions & { + releaseType: semver.ReleaseType; + preId?: string; +}; +type ExactOptions = BaseOptions & { + exact: semver.ReleaseType; +}; +type Options = BumpOptions | ExactOptions; + +const CODE_DIR_PATH = path.join(__dirname, '..', '..', 'code'); +const CODE_PACKAGE_JSON_PATH = path.join(CODE_DIR_PATH, 'package.json'); + +const validateOptions = (options: { [key: string]: any }): options is Options => { + optionsSchema.parse(options); + return true; +}; + +const getCurrentVersion = async () => { + console.log(`πŸ“ Reading current version of Storybook...`); + const { version } = await readJson(CODE_PACKAGE_JSON_PATH); + return version; +}; + +const bumpCodeVersion = async (nextVersion: string) => { + console.log(`🀜 Bumping version of ${chalk.cyan('code')}'s package.json...`); + + const codePkgJson = await readJson(CODE_PACKAGE_JSON_PATH); + + codePkgJson.version = nextVersion; + await writeJson(CODE_PACKAGE_JSON_PATH, codePkgJson, { spaces: 2 }); + + console.log(`βœ… Bumped version of ${chalk.cyan('code')}'s package.json`); +}; + +const bumpAllPackageVersions = async (nextVersion: string, verbose?: boolean) => { + console.log(`🀜 Bumping version of ${chalk.cyan('all packages')}...`); + + /** + * This uses the release workflow outlined by Yarn documentation here: + * https://yarnpkg.com/features/release-workflow + * + * However we build the release YAML file manually instead of using the `yarn version --deferred` command + * This is super hacky, but it's also way faster than invoking `yarn version` for each package, which is 1s each + * + * A simpler alternative is to use Lerna with: + * await execaCommand(`yarn lerna version ${nextVersion} --no-git-tag-version --exact`, { + * cwd: CODE_DIR_PATH, + * stdio: verbose ? 'inherit' : undefined, + * }); + * However that doesn't update peer deps. Trade offs + */ + const yarnVersionsPath = path.join(__dirname, '..', '..', 'code', '.yarn', 'versions'); + let yarnDefferedVersionFileContents = dedent`# this file is auto-generated by scripts/release/version.ts + releases: + + `; + Object.keys(packageVersionMap).forEach((packageName) => { + yarnDefferedVersionFileContents += ` '${packageName}': ${nextVersion}\n`; + }); + await ensureDir(yarnVersionsPath); + await writeFile( + path.join(yarnVersionsPath, 'generated-by-versions-script.yml'), + yarnDefferedVersionFileContents + ); + + await execaCommand('yarn version apply --all', { + cwd: CODE_DIR_PATH, + stdio: verbose ? 'inherit' : undefined, + }); + + console.log(`βœ… Bumped version of ${chalk.cyan('all packages')}`); +}; + +const bumpVersionSources = async (currentVersion: string, nextVersion: string) => { + const filesToUpdate = [ + path.join(CODE_DIR_PATH, 'lib', 'manager-api', 'src', 'version.ts'), + path.join(CODE_DIR_PATH, 'lib', 'cli', 'src', 'versions.ts'), + ]; + console.log(`🀜 Bumping versions in...:\n ${chalk.cyan(filesToUpdate.join('\n '))}`); + + await Promise.all( + filesToUpdate.map(async (filename) => { + const currentContent = await readFile(filename, { encoding: 'utf-8' }); + const nextContent = currentContent.replaceAll(currentVersion, nextVersion); + return writeFile(filename, nextContent); + }) + ); + + console.log(`βœ… Bumped versions in:\n ${chalk.cyan(filesToUpdate.join('\n '))}`); +}; + +export const run = async (options: unknown) => { + if (!validateOptions(options)) { + return; + } + const { verbose } = options; + + console.log(`πŸš› Finding Storybook packages...`); + + const [packages, currentVersion] = await Promise.all([listOfPackages(), getCurrentVersion()]); + + console.log( + `πŸ“¦ found ${packages.length} storybook packages at version ${chalk.red(currentVersion)}` + ); + if (verbose) { + const formattedPackages = packages.map( + (pkg) => + `${chalk.green(pkg.name.padEnd(60))}${chalk.red(pkg.version)}: ${chalk.cyan(pkg.location)}` + ); + console.log(`πŸ“¦ Packages: + ${formattedPackages.join('\n ')}`); + } + + let nextVersion: string; + + if ('exact' in options && options.exact) { + console.log(`πŸ“ˆ Exact version selected: ${chalk.green(options.exact)}`); + nextVersion = options.exact; + } else { + const { releaseType, preId } = options as BumpOptions; + console.log(`πŸ“ˆ Release type selected: ${chalk.green(releaseType)}`); + if (preId) { + console.log(`πŸ†” Version prerelease identifier selected: ${chalk.yellow(preId)}`); + } + + nextVersion = semver.inc(currentVersion, releaseType, preId); + + console.log( + `⏭ Bumping version ${chalk.blue(currentVersion)} with release type ${chalk.green( + releaseType + )}${ + preId ? ` and ${chalk.yellow(preId)}` : '' + } results in version: ${chalk.bgGreenBright.bold(nextVersion)}` + ); + } + + console.log(`⏭ Bumping all packages to ${chalk.blue(nextVersion)}...`); + + await bumpCodeVersion(nextVersion); + await bumpAllPackageVersions(nextVersion, verbose); + await bumpVersionSources(currentVersion, nextVersion); + + if (process.env.GITHUB_ACTIONS === 'true') { + setOutput('current-version', currentVersion); + setOutput('next-version', nextVersion); + } +}; + +if (require.main === module) { + const options = program.parse().opts(); + run(options).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/scripts/release/write-changelog.ts b/scripts/release/write-changelog.ts new file mode 100644 index 00000000000..3d8c042d088 --- /dev/null +++ b/scripts/release/write-changelog.ts @@ -0,0 +1,114 @@ +/* eslint-disable no-continue */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable no-underscore-dangle */ +/* eslint-disable no-console */ +import chalk from 'chalk'; +import path from 'path'; +import program from 'commander'; +import semver from 'semver'; +import { z } from 'zod'; +import { readFile, writeFile } from 'fs-extra'; +import { getChanges } from './utils/get-changes'; + +program + .name('write-changelog') + .description( + 'write changelog based on merged PRs and commits. the argument describes the changelog entry heading, but NOT which commits/PRs to include, must be a semver string' + ) + .arguments('') + .option('-P, --unpicked-patches', 'Set to only consider PRs labeled with "patch" label') + .option( + '-F, --from ', + 'Which tag or commit to generate changelog from, eg. "7.0.7". Leave unspecified to select latest released tag in git history' + ) + .option( + '-T, --to ', + 'Which tag or commit to generate changelog to, eg. "7.1.0-beta.8". Leave unspecified to select HEAD commit' + ) + .option('-D, --dry-run', 'Do not write file, only output to shell', false) + .option('-V, --verbose', 'Enable verbose logging', false); + +const optionsSchema = z.object({ + unpickedPatches: z.boolean().optional(), + from: z.string().optional(), + to: z.string().optional(), + verbose: z.boolean().optional(), + dryRun: z.boolean().optional(), +}); + +type Options = { + unpickedPatches?: boolean; + from?: string; + to?: string; + verbose: boolean; + dryRun?: boolean; +}; + +const validateOptions = (args: unknown[], options: { [key: string]: any }): options is Options => { + optionsSchema.parse(options); + if (args.length !== 1 || !semver.valid(args[0] as string)) { + console.error( + `🚨 Invalid arguments, expected a single argument with the version to generate changelog for, eg. ${chalk.green( + '7.1.0-beta.8' + )}` + ); + return false; + } + return true; +}; + +const writeToFile = async ({ + changelogText, + version, + verbose, +}: { + changelogText: string; + version: string; + verbose?: boolean; +}) => { + const isPrerelease = semver.prerelease(version) !== null; + const changelogFilename = isPrerelease ? 'CHANGELOG.prerelease.md' : 'CHANGELOG.md'; + const changelogPath = path.join(__dirname, '..', '..', changelogFilename); + + if (verbose) { + console.log(`πŸ“ Writing changelog to ${chalk.blue(changelogPath)}`); + } + + const currentChangelog = await readFile(changelogPath, 'utf-8'); + const nextChangelog = [changelogText, currentChangelog].join('\n\n'); + + await writeFile(changelogPath, nextChangelog); +}; + +export const run = async (args: unknown[], options: unknown) => { + if (!validateOptions(args, options)) { + return; + } + const { from, to, unpickedPatches, dryRun, verbose } = options; + const version = args[0] as string; + + console.log( + `πŸ’¬ Generating changelog for ${chalk.blue(version)} between ${chalk.green( + from || 'latest' + )} and ${chalk.green(to || 'HEAD')}` + ); + + const { changelogText } = await getChanges({ version, from, to, unpickedPatches, verbose }); + + if (dryRun) { + console.log(`πŸ“ Dry run, not writing file`); + return; + } + + await writeToFile({ changelogText, version, verbose }); + + console.log(`βœ… Wrote Changelog to file`); +}; + +if (require.main === module) { + const parsed = program.parse(); + run(parsed.args, parsed.opts()).catch((err) => { + console.error(err); + process.exit(1); + }); +}