From f3a1d8c1d51d4125249a1336b228ec226a81fbeb Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Mon, 24 Jul 2023 10:13:27 +0200 Subject: [PATCH] Merge pull request #23491 from storybookjs/fix-release-promotions Release tooling: Fixes from stable promotion (cherry picked from commit 6850a8f64095072daa6decbd639b9eadedd82d29) --- .github/workflows/publish.yml | 40 ++++++-- CONTRIBUTING/RELEASING.md | 4 + .../__tests__/generate-pr-description.test.ts | 8 ++ .../release/__tests__/label-patches.test.ts | 93 ++++++++++++++++++- scripts/release/generate-pr-description.ts | 4 + scripts/release/label-patches.ts | 47 +++++++--- scripts/release/pick-patches.ts | 64 +------------ scripts/release/utils/get-changes.ts | 2 +- scripts/release/utils/get-unpicked-prs.ts | 72 -------------- scripts/release/utils/github-client.ts | 73 +++++++++++++++ 10 files changed, 246 insertions(+), 161 deletions(-) delete mode 100644 scripts/release/utils/get-unpicked-prs.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d319a540c376..3a6eeebd8248 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -121,11 +121,13 @@ jobs: if: github.ref_name == 'latest-release' run: git fetch --tags origin + # when this is a patch release from main, label any patch PRs included in the release + # when this is a stable release from next, label ALL patch PRs found, as they will per definition be "patched" now - name: Label patch PRs as picked - if: github.ref_name == 'latest-release' + if: github.ref_name == 'latest-release' || (steps.publish-needed.outputs.published == 'false' && steps.target.outputs.target == 'next' && !steps.is-prerelease.outputs.prerelease) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: yarn release:label-patches + run: yarn release:label-patches ${{ steps.target.outputs.target == 'next' && '--all' || '' }} - name: Create GitHub Release if: steps.publish-needed.outputs.published == 'false' @@ -161,25 +163,43 @@ jobs: git commit -m "Update CHANGELOG.md for v${{ steps.version.outputs.current-version }} [skip ci]" git push origin next - - name: Sync versions/next.json from `next` to `main` + - name: Sync version JSONs from `next-release` to `main` if: github.ref_name == 'next-release' working-directory: . run: | + VERSION_FILE="./docs/versions/${{ steps.is-prerelease.outputs.prerelease == 'true' && 'next' || 'latest' }}.json" git fetch origin main git checkout main git pull - git checkout origin/next ./docs/versions/next.json - git add ./docs/versions/next.json - git commit -m "Update versions/next.json for v${{ steps.version.outputs.current-version }}" + git checkout origin/next-release $VERSION_FILE + git add $VERSION_FILE + git commit -m "Update $VERSION_FILE for v${{ steps.version.outputs.current-version }}" git push origin main - # Force push from next to main if it is not a prerelease, and this release is from next-release + # TODO: this is currently disabled, because we may have a better strategy that we want to try out manually first before comitting to it: + # - create a branch "release-" from HEAD of main + # - git push --force origin ${{ steps.target.outputs.target }}:main + # - ... this will keep the "main" history in the new release branch, and then overwrite main's history with next's + + # Sync next-release to main if it is not a prerelease, and this release is from next-release # This happens when eg. next has been tracking 7.1.0-alpha.X, and now we want to release 7.1.0 - # This will keep release-next, next and main all tracking v7.1.0 - # - name: Force push ${{ steps.target.outputs.target }} to main + # This will keep next-release, next and main all tracking v7.1.0 + # See "Alternative merge strategies" in https://stackoverflow.com/a/36321787 + # - name: Sync next-release to main # if: steps.publish-needed.outputs.published == 'false' && steps.target.outputs.target == 'next' && !steps.is-prerelease.outputs.prerelease + # working-directory: . # run: | - # git push --force origin ${{ steps.target.outputs.target }}:main + # git fetch origin next-release + # git checkout next-release + # git pull + # git fetch origin main + # git checkout main + # git pull + # git merge --no-commit -s ours next-release + # git rm -rf . + # git checkout next-release -- . + # git commit -m "Sync next-release to main" + # git push origin main - name: Report job failure to Discord if: failure() diff --git a/CONTRIBUTING/RELEASING.md b/CONTRIBUTING/RELEASING.md index 19822e849d74..b9eb8f3ee786 100644 --- a/CONTRIBUTING/RELEASING.md +++ b/CONTRIBUTING/RELEASING.md @@ -315,6 +315,10 @@ It's possible and valid to push manual changes directly on the release branch wh It's recommended to use the automated process as much as possible to ensure that the information in GitHub is the single source of truth, and that pull requests and changelogs are in sync. +> **Warning** +> If you make manual changes to the changelog, you also need to make those changes in either [`./docs/versions/latest.json`](../docs/versions/latest.json) or [`./docs/versions/next.json`](../docs/versions/next.json). The `"plain"` property should match the changelog entry, **without the heading** and with all new lines replaces with `\n`. +> This is common for custom release notes when releasing majors and minors. + ### 6. Merge When the pull request was frozen, a CI run was triggered on the branch. If it's green, it's time to merge the pull request. If CI is failing for some reason, consult with the rest of the core team. These release pull requests are almost exact copies of `next|main` so CI should only fail if they fail too. diff --git a/scripts/release/__tests__/generate-pr-description.test.ts b/scripts/release/__tests__/generate-pr-description.test.ts index 4a9dc04bcd4c..b0f1bbe89db5 100644 --- a/scripts/release/__tests__/generate-pr-description.test.ts +++ b/scripts/release/__tests__/generate-pr-description.test.ts @@ -215,6 +215,8 @@ For each pull request below, you need to either manually cherry pick it, or disc 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/storybook/actions/workflows/prepare-prerelease.yml) and wait for it to finish. It will wipe your progress in this to do, which is expected. + Feel free to manually commit any changes necessary to this branch **after** you\\'ve done the last re-generation, following the [Make Manual Changes](https://github.com/storybookjs/storybook/blob/next/CONTRIBUTING/RELEASING.md#5-make-manual-changes) section in the docs, *especially* if you\\'re making changes to the changelog. + When everything above is done: - Merge this PR - [Follow the run of the publish action](https://github.com/storybookjs/storybook/actions/workflows/publish.yml) @@ -273,6 +275,8 @@ For each pull request below, you need to either manually cherry pick it, or disc 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/storybook/actions/workflows/prepare-patch-release.yml) and wait for it to finish. + Feel free to manually commit any changes necessary to this branch **after** you\\'ve done the last re-generation, following the [Make Manual Changes](https://github.com/storybookjs/storybook/blob/next/CONTRIBUTING/RELEASING.md#5-make-manual-changes) section in the docs. + When everything above is done: - Merge this PR - [Follow the run of the publish action](https://github.com/storybookjs/storybook/actions/workflows/publish.yml)" @@ -338,6 +342,8 @@ For each pull request below, you need to either manually cherry pick it, or disc 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/storybook/actions/workflows/prepare-prerelease.yml) and wait for it to finish. It will wipe your progress in this to do, which is expected. + Feel free to manually commit any changes necessary to this branch **after** you\\'ve done the last re-generation, following the [Make Manual Changes](https://github.com/storybookjs/storybook/blob/next/CONTRIBUTING/RELEASING.md#5-make-manual-changes) section in the docs, *especially* if you\\'re making changes to the changelog. + When everything above is done: - Merge this PR - [Follow the run of the publish action](https://github.com/storybookjs/storybook/actions/workflows/publish.yml) @@ -391,6 +397,8 @@ For each pull request below, you need to either manually cherry pick it, or disc 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/storybook/actions/workflows/prepare-patch-release.yml) and wait for it to finish. + Feel free to manually commit any changes necessary to this branch **after** you\\'ve done the last re-generation, following the [Make Manual Changes](https://github.com/storybookjs/storybook/blob/next/CONTRIBUTING/RELEASING.md#5-make-manual-changes) section in the docs. + When everything above is done: - Merge this PR - [Follow the run of the publish action](https://github.com/storybookjs/storybook/actions/workflows/publish.yml)" diff --git a/scripts/release/__tests__/label-patches.test.ts b/scripts/release/__tests__/label-patches.test.ts index ddfc0be15c50..d98abc7eb763 100644 --- a/scripts/release/__tests__/label-patches.test.ts +++ b/scripts/release/__tests__/label-patches.test.ts @@ -74,6 +74,22 @@ beforeEach(() => { gitClient.git.getRemotes.mockResolvedValue(remoteMock); githubInfo.getPullInfoFromCommit.mockResolvedValue(pullInfoMock); github.getLabelIds.mockResolvedValue({ 'patch:done': 'pick-id' }); + github.getUnpickedPRs.mockResolvedValue([ + { + number: 42, + id: 'some-id', + branch: 'some-patching-branch', + title: 'Fix: Patch this PR', + mergeCommit: 'abcd1234', + }, + { + number: 44, + id: 'other-id', + branch: 'other-patching-branch', + title: 'Fix: Also patch this PR', + mergeCommit: 'abcd1234', + }, + ]); }); test('it should fail early when no GH_TOKEN is set', async () => { @@ -130,8 +146,83 @@ test('it should label the PR associated with cheery picks in the current branch' "Found latest tag: v7.2.1", "Looking at cherry pick commits since v7.2.1", "Found the following picks : Commit: 930b47f011f750c44a1782267d698ccdd3c04da3 PR: [#55](https://github.com/storybookjs/storybook/pull/55)", - "Labeling the PRs with the patch:done label...", + "Labeling 1 PRs with the patch:done label...", + "Successfully labeled all PRs with the patch:done label.", + ] + `); +}); + +test('it should label all PRs when the --all flag is passed', async () => { + process.env.GH_TOKEN = 'MY_SECRET'; + + // clear the git log, it shouldn't depend on it in --all mode + gitClient.git.log.mockResolvedValue({ + all: [], + latest: null!, + total: 0, + }); + + const writeStderr = jest.spyOn(process.stderr, 'write').mockImplementation(); + + await run({ all: true }); + expect(github.githubGraphQlClient.mock.calls).toMatchInlineSnapshot(` + [ + [ + " + mutation ($input: AddLabelsToLabelableInput!) { + addLabelsToLabelable(input: $input) { + clientMutationId + } + } + ", + { + "input": { + "clientMutationId": "39cffd21-7933-56e4-9d9c-1afeda9d5906", + "labelIds": [ + "pick-id", + ], + "labelableId": "some-id", + }, + }, + ], + [ + " + mutation ($input: AddLabelsToLabelableInput!) { + addLabelsToLabelable(input: $input) { + clientMutationId + } + } + ", + { + "input": { + "clientMutationId": "cc31033b-5da7-5c9e-adf2-80a2963e19a8", + "labelIds": [ + "pick-id", + ], + "labelableId": "other-id", + }, + }, + ], + ] + `); + + const stderrCalls = writeStderr.mock.calls + .map(([text]) => + typeof text === 'string' + ? text + .replace(ansiRegex(), '') + .replace(/[^\x20-\x7E]/g, '') + .replaceAll('-', '') + .trim() + : text + ) + .filter((it) => it !== ''); + + expect(stderrCalls).toMatchInlineSnapshot(` + [ + "Labeling 2 PRs with the patch:done label...", "Successfully labeled all PRs with the patch:done label.", ] `); + expect(github.getUnpickedPRs).toHaveBeenCalledTimes(1); }); diff --git a/scripts/release/generate-pr-description.ts b/scripts/release/generate-pr-description.ts index 1a10ca89197f..16a6928e994f 100644 --- a/scripts/release/generate-pr-description.ts +++ b/scripts/release/generate-pr-description.ts @@ -175,6 +175,8 @@ export const generateReleaseDescription = ({ 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](${workflowUrl}) and wait for it to finish. It will wipe your progress in this to do, which is expected. + Feel free to manually commit any changes necessary to this branch **after** you've done the last re-generation, following the [Make Manual Changes](https://github.com/storybookjs/storybook/blob/next/CONTRIBUTING/RELEASING.md#5-make-manual-changes) section in the docs, *especially* if you're making changes to the changelog. + When everything above is done: - Merge this PR - [Follow the run of the publish action](https://github.com/storybookjs/storybook/actions/workflows/publish.yml) @@ -215,6 +217,8 @@ export const generateNonReleaseDescription = ( 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/storybook/actions/workflows/prepare-patch-release.yml) and wait for it to finish. + Feel free to manually commit any changes necessary to this branch **after** you've done the last re-generation, following the [Make Manual Changes](https://github.com/storybookjs/storybook/blob/next/CONTRIBUTING/RELEASING.md#5-make-manual-changes) section in the docs. + When everything above is done: - Merge this PR - [Follow the run of the publish action](https://github.com/storybookjs/storybook/actions/workflows/publish.yml)` diff --git a/scripts/release/label-patches.ts b/scripts/release/label-patches.ts index dea8e62e469e..1e9305b9806c 100644 --- a/scripts/release/label-patches.ts +++ b/scripts/release/label-patches.ts @@ -1,13 +1,18 @@ import program from 'commander'; import { v4 as uuidv4 } from 'uuid'; import ora from 'ora'; -import { getLabelIds, githubGraphQlClient } from './utils/github-client'; +import { getLabelIds, githubGraphQlClient, getUnpickedPRs } from './utils/github-client'; import { getPullInfoFromCommits, getRepo } from './utils/get-changes'; import { getLatestTag, git } from './utils/git-client'; program .name('label-patches') - .description('Label all patches applied in current branch up to the latest release tag.'); + .description('Label all patches applied in current branch up to the latest release tag.') + .option( + '-A, --all', + 'Label all pull requests pending patches, iregardless if they are in the git log or not', + false + ); async function labelPR(id: string, labelId: string) { await githubGraphQlClient( @@ -22,11 +27,7 @@ async function labelPR(id: string, labelId: string) { ); } -export const run = async (_: unknown) => { - if (!process.env.GH_TOKEN) { - throw new Error('GH_TOKEN environment variable must be set, exiting.'); - } - +async function getPullRequestsFromLog({ repo }: { repo: string }) { const spinner = ora('Looking for latest tag').start(); const latestTag = await getLatestTag(); spinner.succeed(`Found latest tag: ${latestTag}`); @@ -41,10 +42,8 @@ export const run = async (_: unknown) => { if (cherryPicked.length === 0) { spinner2.fail('No cherry pick commits found to label.'); - return; + return []; } - - const repo = await getRepo(); const pullRequests = ( await getPullInfoFromCommits({ repo, @@ -56,17 +55,37 @@ export const run = async (_: unknown) => { spinner2.fail( `Found picks: ${cherryPicked.join(', ')}, but no associated pull request found to label.` ); - return; + return pullRequests; } const commitWithPr = pullRequests.map((pr) => `Commit: ${pr.commit}\n PR: ${pr.links.pull}`); spinner2.succeed(`Found the following picks 🍒:\n ${commitWithPr.join('\n')}`); - const spinner3 = ora(`Labeling the PRs with the patch:done label...`).start(); + return pullRequests; +} + +export const run = async (options: unknown) => { + if (!process.env.GH_TOKEN) { + throw new Error('GH_TOKEN environment variable must be set, exiting.'); + } + + const repo = await getRepo(); + const labelAll = typeof options === 'object' && 'all' in options && Boolean(options.all); + + const pullRequestsToLabel = labelAll + ? await getUnpickedPRs('next') + : await getPullRequestsFromLog({ repo }); + if (pullRequestsToLabel.length === 0) { + return; + } + + const spinner3 = ora( + `Labeling ${pullRequestsToLabel.length} PRs with the patch:done label...` + ).start(); try { const labelToId = await getLabelIds({ repo, labelNames: ['patch:done'] }); - await Promise.all(pullRequests.map((pr) => labelPR(pr.id, labelToId['patch:done']))); + await Promise.all(pullRequestsToLabel.map((pr) => labelPR(pr.id, labelToId['patch:done']))); spinner3.succeed(`Successfully labeled all PRs with the patch:done label.`); } catch (e) { spinner3.fail(`Something went wrong when labelling the PRs.`); @@ -75,7 +94,7 @@ export const run = async (_: unknown) => { }; if (require.main === module) { - const options = program.parse(process.argv); + const options = program.parse().opts(); run(options).catch((err) => { console.error(err); process.exit(1); diff --git a/scripts/release/pick-patches.ts b/scripts/release/pick-patches.ts index 8182ee3fad88..3740b1c98edf 100644 --- a/scripts/release/pick-patches.ts +++ b/scripts/release/pick-patches.ts @@ -2,13 +2,10 @@ /* 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 { setOutput } from '@actions/core'; -import { getUnpickedPRs } from './utils/get-unpicked-prs'; -import { githubGraphQlClient } from './utils/github-client'; +import { getUnpickedPRs } from './utils/github-client'; program.name('pick-patches').description('Cherry pick patch PRs back to main'); @@ -28,70 +25,12 @@ interface PR { mergeCommit: string; } -const LABEL = { - PATCH: 'patch:yes', - PICKED: 'patch:done', - 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.'); @@ -102,7 +41,6 @@ export const run = async (_: unknown) => { 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) { diff --git a/scripts/release/utils/get-changes.ts b/scripts/release/utils/get-changes.ts index 34586dfee2d0..e3e414e766e3 100644 --- a/scripts/release/utils/get-changes.ts +++ b/scripts/release/utils/get-changes.ts @@ -3,8 +3,8 @@ import chalk from 'chalk'; import semver from 'semver'; import type { PullRequestInfo } from './get-github-info'; import { getPullInfoFromCommit } from './get-github-info'; -import { getUnpickedPRs } from './get-unpicked-prs'; import { git } from './git-client'; +import { getUnpickedPRs } from './github-client'; export const RELEASED_LABELS = { 'BREAKING CHANGE': '❗ Breaking Change', diff --git a/scripts/release/utils/get-unpicked-prs.ts b/scripts/release/utils/get-unpicked-prs.ts deleted file mode 100644 index ade20ff68af3..000000000000 --- a/scripts/release/utils/get-unpicked-prs.ts +++ /dev/null @@ -1,72 +0,0 @@ -/* eslint-disable no-console */ -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:yes"], orderBy: $order, first: 50, baseRefName: "next") { - nodes { - id - number - title - baseRefName - mergeCommit { - oid - } - labels(first: 20) { - nodes { - name - } - } - } - } - } - } - `, - { - owner: 'storybookjs', - repo: 'storybook', - order: { - field: 'UPDATED_AT', - direction: 'DESC', - }, - 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.oid, - labels: node.labels.nodes.map((l: any) => l.name), - })); - - const unpickedPRs = prs - .filter((pr: any) => !pr.labels.includes('patch:done')) - .filter((pr: any) => pr.branch === baseBranch) - .reverse(); - - 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 index 3c6a2355e0dc..646ba1003986 100644 --- a/scripts/release/utils/github-client.ts +++ b/scripts/release/utils/github-client.ts @@ -1,10 +1,83 @@ +/* eslint-disable no-console */ import type { GraphQlQueryResponseData } from '@octokit/graphql'; import { graphql } from '@octokit/graphql'; +export interface PullRequest { + number: number; + id: string; + branch: string; + title: string; + mergeCommit: string; +} + export const githubGraphQlClient = graphql.defaults({ headers: { authorization: `token ${process.env.GH_TOKEN}` }, }); +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:yes"], orderBy: $order, first: 50, baseRefName: "next") { + nodes { + id + number + title + baseRefName + mergeCommit { + oid + } + labels(first: 20) { + nodes { + name + } + } + } + } + } + } + `, + { + owner: 'storybookjs', + repo: 'storybook', + order: { + field: 'UPDATED_AT', + direction: 'DESC', + }, + 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.oid, + labels: node.labels.nodes.map((l: any) => l.name), + })); + + const unpickedPRs = prs + .filter((pr: any) => !pr.labels.includes('patch:done')) + .filter((pr: any) => pr.branch === baseBranch) + .reverse(); + + if (verbose) { + console.log(`🔍 Found unpicked patch pull requests: + ${JSON.stringify(unpickedPRs, null, 2)}`); + } + return unpickedPRs; +} + export async function getLabelIds({ repo: fullRepo, labelNames,