add release scripts

This commit is contained in:
Jeppe Reinhold 2023-06-07 13:18:45 +02:00
parent 32d2fafa8d
commit 529afae088
21 changed files with 2676 additions and 0 deletions

View File

@ -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",

View File

@ -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)"
`);
});
});
});

View File

@ -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});
});
});

View File

@ -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)
);
}
);
});

View File

@ -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 <version>',
'Which version to generate changelog from, eg. "7.0.7". Defaults to the version at code/package.json'
)
.option('-N, --next-version <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 <commits>',
'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);
});
}

View File

@ -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);
});
}

View File

@ -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);
});
}

View File

@ -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);
});
}

View File

@ -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<string> => {
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);
});
}

View File

@ -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);
});
}

View File

@ -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);
});
}

View File

@ -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<GraphQlQueryResponseData>(
`
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<string, string>;
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<string, string>) {
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);
});
}

209
scripts/release/publish.ts Normal file
View File

@ -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 <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);
});
}

View File

@ -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 <version>',
'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);
});
}

View File

@ -0,0 +1,4 @@
module.exports = {
getPullInfoFromCommit: jest.fn(),
getPullInfoFromPullRequest: jest.fn(),
}

View File

@ -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<string> => {
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<PullRequestInfo[]> => {
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 };
};

View File

@ -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<string, { commit: Record<string, any>; pull: Record<string, any> }> =
{};
Object.keys(repos).forEach((repo, index) => {
const output: { commit: Record<string, any>; pull: Record<string, any> } = {
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<PullRequestInfo> {
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<PullRequestInfo> {
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,
},
};
}

View File

@ -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<Array<PR>> {
console.log(`💬 Getting unpicked patch pull requests...`);
const result = await githubGraphQlClient<GraphQlQueryResponseData>(
`
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;
}

View File

@ -0,0 +1,5 @@
import { graphql } from '@octokit/graphql';
export const githubGraphQlClient = graphql.defaults({
headers: { authorization: `token ${process.env.GH_TOKEN}` },
});

220
scripts/release/version.ts Normal file
View File

@ -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 <major|minor|patch|prerelease>',
'Which release type to use to bump the version'
)
.option('-P, --pre-id <id>', 'Which prerelease identifer to change to, eg. "alpha", "beta", "rc"')
.option(
'-E, --exact <version>',
'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);
});
}

View File

@ -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 <version> argument describes the changelog entry heading, but NOT which commits/PRs to include, must be a semver string'
)
.arguments('<version>')
.option('-P, --unpicked-patches', 'Set to only consider PRs labeled with "patch" label')
.option(
'-F, --from <tag>',
'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 <tag>',
'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);
});
}