From 43825e0b12f1786a95d446fbd2610bdb69d7496b Mon Sep 17 00:00:00 2001 From: Jeppe Reinhold Date: Thu, 5 Oct 2023 11:33:55 +0200 Subject: [PATCH] Merge pull request #24106 from storybookjs/release-stable-to-latest Release tooling: Release stable releases from `latest-release` (cherry picked from commit 9139e906483d62219afd850591234959cec75474) --- ...ease.yml => prepare-non-patch-release.yml} | 29 +++- .github/workflows/prepare-patch-release.yml | 11 +- .github/workflows/publish.yml | 33 ++-- CONTRIBUTING/RELEASING.md | 164 +++++++++--------- scripts/package.json | 3 + .../__tests__/cancel-preparation-runs.test.ts | 107 ++++++++++++ .../__tests__/ensure-next-ahead.test.ts | 85 +++++++++ .../__tests__/generate-pr-description.test.ts | 10 +- .../release/__tests__/is-pr-frozen.test.ts | 12 +- .../release/__tests__/label-patches.test.ts | 1 + scripts/release/cancel-preparation-runs.ts | 107 ++++++++++++ scripts/release/ensure-next-ahead.ts | 101 +++++++++++ scripts/release/generate-pr-description.ts | 11 +- scripts/release/is-pr-frozen.ts | 12 +- scripts/release/pick-patches.ts | 1 + scripts/release/utils/get-changes.ts | 2 +- scripts/release/utils/get-github-info.ts | 13 +- scripts/release/utils/github-client.ts | 9 + scripts/yarn.lock | 52 ++++++ 19 files changed, 648 insertions(+), 115 deletions(-) rename .github/workflows/{prepare-prerelease.yml => prepare-non-patch-release.yml} (81%) create mode 100644 scripts/release/__tests__/cancel-preparation-runs.test.ts create mode 100644 scripts/release/__tests__/ensure-next-ahead.test.ts create mode 100644 scripts/release/cancel-preparation-runs.ts create mode 100644 scripts/release/ensure-next-ahead.ts diff --git a/.github/workflows/prepare-prerelease.yml b/.github/workflows/prepare-non-patch-release.yml similarity index 81% rename from .github/workflows/prepare-prerelease.yml rename to .github/workflows/prepare-non-patch-release.yml index e68a7e1ef63a..ea72d924d918 100644 --- a/.github/workflows/prepare-prerelease.yml +++ b/.github/workflows/prepare-non-patch-release.yml @@ -34,7 +34,7 @@ concurrency: cancel-in-progress: true jobs: - prepare-prerelease-pull-request: + prepare-non-patch-pull-request: name: Prepare prerelease pull request runs-on: ubuntu-latest environment: release @@ -112,21 +112,35 @@ jobs: run: | yarn release:version --deferred --release-type ${{ inputs.release-type || 'prerelease' }} ${{ inputs.pre-id && format('{0} {1}', '--pre-id', inputs.pre-id) || '' }} --verbose + - name: Check release vs prerelease + id: is-prerelease + run: yarn release:is-prerelease ${{ steps.bump-version.outputs.next-version }} + - name: Write changelog env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | yarn release:write-changelog ${{ steps.bump-version.outputs.next-version }} --verbose - - name: 'Commit changes to branch: version-prerelease-from-${{ steps.bump-version.outputs.current-version }}' + - name: 'Commit changes to branch: version-non-patch-from-${{ steps.bump-version.outputs.current-version }}' working-directory: . run: | git config --global user.name 'storybook-bot' git config --global user.email '32066757+storybook-bot@users.noreply.github.com' - git checkout -b version-prerelease-from-${{ steps.bump-version.outputs.current-version }} + git checkout -b version-non-patch-from-${{ steps.bump-version.outputs.current-version }} + git add . + git commit -m "Write changelog for ${{ steps.bump-version.outputs.next-version }} [skip ci]" || true + git push --force origin version-non-patch-from-${{ steps.bump-version.outputs.current-version }} + + - name: Resolve merge-conflicts with base branch + if: steps.is-prerelease.outputs.prerelease == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git pull origin latest-release + git checkout --ours . git add . - git commit -m "Write changelog for ${{ steps.bump-version.outputs.next-version }}" || true - git push --force origin version-prerelease-from-${{ steps.bump-version.outputs.current-version }} + git commit -m "Merge latest-release into version-non-patch-from-${{ steps.bump-version.outputs.current-version }} with conflicts resolved to ours [skip ci]" - name: Generate PR description id: description @@ -144,14 +158,15 @@ jobs: gh pr edit \ --repo "${{github.repository }}" \ --title "Release: $CAPITALIZED_RELEASE_TYPE ${{ inputs.pre-id && format('{0} ', inputs.pre-id) }}${{ steps.bump-version.outputs.next-version }}" \ + --base ${{ steps.is-prerelease.outputs.prerelease == 'true' && 'next-release' || 'latest-release' }} \ --body "${{ steps.description.outputs.description }}" else gh pr create \ --repo "${{github.repository }}"\ --title "Release: $CAPITALIZED_RELEASE_TYPE ${{ inputs.pre-id && format('{0} ', inputs.pre-id) }}${{ steps.bump-version.outputs.next-version }}" \ --label "release" \ - --base next-release \ - --head version-prerelease-from-${{ steps.bump-version.outputs.current-version }} \ + --base ${{ steps.is-prerelease.outputs.prerelease == 'true' && 'next-release' || 'latest-release' }} \ + --head version-non-patch-from-${{ steps.bump-version.outputs.current-version }} \ --body "${{ steps.description.outputs.description }}" fi diff --git a/.github/workflows/prepare-patch-release.yml b/.github/workflows/prepare-patch-release.yml index e4f8e38df502..5cdc1ba44fcf 100644 --- a/.github/workflows/prepare-patch-release.yml +++ b/.github/workflows/prepare-patch-release.yml @@ -88,6 +88,15 @@ jobs: git config --global user.email '32066757+storybook-bot@users.noreply.github.com' yarn release:pick-patches + - name: Cancel when no patches to pick + if: steps.pick-patches.outputs.pr-count == '0' && steps.pick-patches.outputs.pr-count != null + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # From https://stackoverflow.com/a/75809743 + run: | + gh run cancel ${{ github.run_id }} + gh run watch ${{ github.run_id }} + - name: Bump version deferred id: bump-version if: steps.unreleased-changes.outputs.has-changes-to-release == 'true' @@ -121,7 +130,7 @@ jobs: git config --global user.email '32066757+storybook-bot@users.noreply.github.com' git checkout -b version-patch-from-${{ steps.versions.outputs.current }} git add . - git commit -m "Write changelog for ${{ steps.versions.outputs.next }}" || true + git commit -m "Write changelog for ${{ steps.versions.outputs.next }} [skip ci]" || true git push --force origin version-patch-from-${{ steps.versions.outputs.current }} - name: Generate PR description diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 863b4e9ae7e2..df82ca33c9a5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,7 +5,7 @@ on: push: branches: - latest-release - - next-release + - non-patch-release env: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 @@ -36,6 +36,11 @@ jobs: gh run cancel ${{ github.run_id }} gh run watch ${{ github.run_id }} + - name: Cancel all release preparation runs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: yarn release:cancel-preparation-runs + - name: Checkout ${{ github.ref_name }} uses: actions/checkout@v3 with: @@ -63,7 +68,6 @@ jobs: yarn install - name: Apply deferred version bump and commit - id: version-bump working-directory: . run: | CURRENT_VERSION=$(cat ./code/package.json | jq '.version') @@ -122,12 +126,11 @@ jobs: 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' || (steps.publish-needed.outputs.published == 'false' && steps.target.outputs.target == 'next' && !steps.is-prerelease.outputs.prerelease) + if: github.ref_name == 'latest-release' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: yarn release:label-patches ${{ steps.target.outputs.target == 'next' && '--all' || '' }} + run: yarn release:label-patches - name: Create GitHub Release if: steps.publish-needed.outputs.published == 'false' @@ -151,8 +154,20 @@ jobs: git merge ${{ github.ref_name }} git push origin ${{ steps.target.outputs.target }} + - name: Ensure `next` is a minor version ahead of `main` + if: steps.target.outputs.target == 'main' + run: | + git checkout next + git pull + + yarn release:ensure-next-ahead --main-version "${{ steps.version.outputs.current-version }}" + + git add .. + git commit -m "Bump next to be one minor ahead of main [skip ci]" + git push origin next + - name: Sync CHANGELOG.md from `main` to `next` - if: github.ref_name == 'latest-release' + if: steps.target.outputs.target == 'main' working-directory: . run: | git fetch origin next @@ -160,7 +175,7 @@ jobs: git pull git checkout origin/main ./CHANGELOG.md git add ./CHANGELOG.md - git commit -m "Update CHANGELOG.md for v${{ steps.version.outputs.current-version }} [skip ci]" + git commit -m "Update CHANGELOG.md for v${{ steps.version.outputs.current-version }} [skip ci]" || true git push origin next - name: Sync version JSONs from `next-release` to `main` @@ -176,10 +191,6 @@ jobs: git commit -m "Update $VERSION_FILE for v${{ steps.version.outputs.current-version }}" git push origin main - - name: Overwrite main with next - if: steps.target.outputs.target == 'next' && steps.is-prerelease.outputs.prerelease == 'false' - run: git push --force origin next:main - - name: Report job failure to Discord if: failure() env: diff --git a/CONTRIBUTING/RELEASING.md b/CONTRIBUTING/RELEASING.md index 0997b757b6ea..e92fa8fa36a3 100644 --- a/CONTRIBUTING/RELEASING.md +++ b/CONTRIBUTING/RELEASING.md @@ -8,8 +8,8 @@ - [Introduction](#introduction) - [Branches](#branches) - [Release Pull Requests](#release-pull-requests) - - [Prereleases](#prereleases) - [Patch Releases](#patch-releases) + - [Non-patch Releases](#non-patch-releases) - [Publishing](#publishing) - [👉 How to Release](#-how-to-release) - [1. Find the Prepared Pull Request](#1-find-the-prepared-pull-request) @@ -21,6 +21,8 @@ - [7. See the "Publish" Workflow Finish](#7-see-the-publish-workflow-finish) - [Releasing Locally in an Emergency 🚨](#releasing-locally-in-an-emergency-) - [Canary Releases](#canary-releases) + - [With GitHub UI](#with-github-ui) + - [With the CLI](#with-the-cli) - [Versioning Scenarios](#versioning-scenarios) - [Prereleases - `7.1.0-alpha.12` -\> `7.1.0-alpha.13`](#prereleases---710-alpha12---710-alpha13) - [Prerelease promotions - `7.1.0-alpha.13` -\> `7.1.0-beta.0`](#prerelease-promotions---710-alpha13---710-beta0) @@ -31,7 +33,7 @@ - [Prerelease of upcoming patch release - `7.0.20` -\> `7.0.21-alpha.0`](#prerelease-of-upcoming-patch-release---7020---7021-alpha0) - [Merges to `main` without versioning](#merges-to-main-without-versioning) - [FAQ](#faq) - - [When should I use the "patch" label?](#when-should-i-use-the-patch-label) + - [When should I use the "patch:yes" label?](#when-should-i-use-the-patchyes-label) - [How do I make changes to the release tooling/process?](#how-do-i-make-changes-to-the-release-toolingprocess) - [Why do I need to re-trigger workflows to update the changelog?](#why-do-i-need-to-re-trigger-workflows-to-update-the-changelog) - [Which combination of inputs creates the version bump I need?](#which-combination-of-inputs-creates-the-version-bump-i-need) @@ -43,19 +45,19 @@ This document explains the release process for the Storybook monorepo. There are two types: -1. Prereleases and major/minor releases - releasing any content that is on the `next` branch +1. Non-patch releases - releasing any content that is on the `next` branch, either prereleases or stable releases 2. Patch releases - picking any content from `next` to `main`, that needs to be patched back to the current stable minor release The release process is based on automatically created "Release Pull Requests", that when merged will trigger a new version to be released. A designated Releaser -- which may rotate between core team members -- will go through the release process in the current Release PR. This process is implemented with NodeJS scripts in [`scripts/release`](../scripts/release/) and three GitHub Actions workflows: -- [Prepare Prerelease PR](../.github/workflows/prepare-prerelease.yml) -- [Prepare Patch PR](../.github/workflows/prepare-patch-release.yml) +- [Prepare `next` PR](../.github/workflows/prepare-non-patch-release.yml) +- [Prepare patch PR](../.github/workflows/prepare-patch-release.yml) - [Publish](../.github/workflows/publish.yml) > **Note** -> This document distinguishes between **patch** releases and **prereleases**. This is a simplification; stable major and minor releases work the same way as prereleases. The distinction reflects the difference between patching an existing minor version on `main` or releasing a new minor/major/prerelease from `next`. +> This document distinguishes between **patch** and **non-patch** releases. The distinction reflects the difference between patching an existing minor version on `main` or releasing a new minor/major/prerelease from `next`. ### Branches @@ -101,7 +103,7 @@ Two GitHub Actions workflows automatically create release pull requests, one for The high-level flow is: 1. When a PR is merged to `next` (or a commit is pushed), both release pull requests are (re)generated. -2. They create a new branch - `version-(patch|prerelease)-from-`. +2. They create a new branch - `version-(patch|non-patch)-from-`. 3. They calculate which version to bump to according to the version strategy. 4. They update `CHANGELOG(.prerelease).md` with all changes detected. 5. They commit everything. @@ -115,62 +117,20 @@ A few key points to note in this flow: - The changelogs are committed during the preparation, but the packages are not version bumped and not published until later. - The release pull requests don't target their working branches (`next` and `main`), but rather `next-release` and `latest-release`. -### Prereleases - -> **Note** -> Workflow: [`prepare-prerelease.yml`](../.github/workflows/prepare-prerelease.yml) - -Prereleases are prepared with all content from the `next` branch. The changelog is generated by examining the git history, and looking up all the commits and pull requests between the current prerelease (on `next-release`) and `HEAD` of `next`. - -The default versioning strategy is to increase the current prerelease number, as described in [Prereleases - `7.1.0-alpha.12` -> `7.1.0-alpha.13`](#prereleases---710-alpha12---710-alpha13). If there is no prerelease number (i.e., we just released a new stable minor/major version), it will add one to a patch bump, so it would go from `7.2.0` to `7.2.1-0` by default. - -Prerelease PRs are only created if there are actual changes to release. Content labeled with "build" or "documentation" is [not considered "releasable"](#which-changes-are-considered-releasable-and-what-does-it-mean) and is not user-facing, so it doesn't make sense to create a release. This is explained in more detail in [Why are no release PRs being prepared?](#why-are-no-release-prs-being-prepared). - -The preparation workflow will create a new branch from `next`, called `version-prerelease-from-`, and open a pull request targeting `next-release`. When the Releaser merges it, the [publish workflow](#publishing) will merge `next-release` into `next`. - -Here's an example of a workflow where a feature and a bugfix have been created and then released to a new `7.1.0-alpha.29` version. All the commits highlighted with square dots are the ones that will be considered when generating the changelog. - -```mermaid -%%{init: { 'gitGraph': { 'mainBranchName': 'next' } } }%% -gitGraph - commit - branch next-release - commit tag: "7.1.0-alpha.28" - checkout next - merge next-release - commit type: HIGHLIGHT id: "direct commit" - branch new-feature - commit - commit - checkout next - merge new-feature type: HIGHLIGHT - branch some-bugfix - commit - checkout next - merge some-bugfix type: HIGHLIGHT - branch version-prerelease-from-7.1.0-alpha.28 - commit id: "write changelog" - checkout next-release - merge version-prerelease-from-7.1.0-alpha.28 - commit id: "bump versions" tag: "7.1.0-alpha.29" - checkout next - merge next-release -``` - ### Patch Releases > **Note** > Workflow: [`prepare-patch-release.yml`](../.github/workflows/prepare-patch-release.yml) -Patch releases are created by [cherry-picking](https://www.atlassian.com/git/tutorials/cherry-pick) any merged, unreleased pull requests that have the "**patch**" label applied to the `next` branch. The merge commit of said pull requests are cherry-picked. +Patch releases are created by [cherry-picking](https://www.atlassian.com/git/tutorials/cherry-pick) any merged, unreleased pull requests that have the "**patch:yes**" label applied to the `next` branch. The merge commit of said pull requests are cherry-picked. -Sometimes it is desired to pick pull requests back to `main` even if they are not considered "releasable". Unlike prerelease preparation, patch releases will not be canceled if the content is not releasable. It might not make sense to create a new patch release if the changes are only for documentation and/or internal build systems. However, getting the changes back to `main` is the only way to deploy the documentation to the production docs site. You may also want to cherry-pick changes to internal CI to fix issues. These are valid scenarios where you want to cherry-pick the changes without being blocked on "releasable" content. In these cases, where all cherry picks are non-releasable, the preparation workflow creates a "merging" pull request instead of a "releasing" pull request. This pull request does not bump versions or update changelogs; it just cherry-picks the changes and allows you to merge them into `latest-release` -> `main`. +Sometimes it is desired to pick pull requests back to `main` even if they are not considered "releasable". Unlike non-patch-release preparation, patch releases will not be canceled if the content is not releasable. It might not make sense to create a new patch release if the changes are only for documentation and/or internal build systems. However, getting the changes back to `main` is the only way to deploy the documentation to the production docs site. You may also want to cherry-pick changes to internal CI to fix issues. These are valid scenarios where you want to cherry-pick the changes without being blocked on "releasable" content. In these cases, where all cherry picks are non-releasable, the preparation workflow creates a "merging" pull request instead of a "releasing" pull request. This pull request does not bump versions or update changelogs; it just cherry-picks the changes and allows you to merge them into `latest-release` -> `main`. The preparation workflow sequentially cherry-picks each patch pull request to its branch. If this cherry-picking fails due to conflicts or other reasons, it is ignored and the next pull request is processed. All failing cherry-picks are listed in the release pull request's description, for the Releaser to manually cherry-pick during the release process. This problem occurs more often when `main` and `next` diverge, i.e. the longer it has been since a stable major/minor release. -Similar to the prerelease flow, the preparation workflow for patches will create a new branch from `main` called `version-patch-from-`, and open a pull request that targets `latest-release`. When the pull request is merged by the Releaser, the [publish workflow](#publishing) will eventually merge `latest-release` into `main`. +Similar to the non-patch-release flow, the preparation workflow for patches will create a new branch from `main` called `version-patch-from-`, and open a pull request that targets `latest-release`. When the pull request is merged by the Releaser, the [publish workflow](#publishing) will eventually merge `latest-release` into `main`. -Here is an example of a workflow where a feature and two bug fixes have been merged to `next`. Only the bug fixes have the "**patch**" label, so only those two go into the new `7.0.19` release. Note that it is the merge commits to `next` that are cherry-picked, not the commits on the bugfix branches. +Here is an example of a workflow where a feature and two bug fixes have been merged to `next`. Only the bug fixes have the "**patch:yes**" label, so only those two go into the new `7.0.19` release. Note that it is the merge commits to `next` that are cherry-picked, not the commits on the bugfix branches. ```mermaid gitGraph @@ -208,21 +168,62 @@ gitGraph merge latest-release ``` +### Non-patch Releases + +> **Note** +> Workflow: [`prepare-non-patch-release.yml`](../.github/workflows/prepare-non-patch-release.yml) + +Non-patch-releases are prepared with all content from the `next` branch. The changelog is generated by examining the git history, and looking up all the commits and pull requests between the current prerelease (on `next-release`) and `HEAD` of `next`. + +The default versioning strategy is to increase the current prerelease number, as described in [Prereleases - `7.1.0-alpha.12` -> `7.1.0-alpha.13`](#prereleases---710-alpha12---710-alpha13). If there is no prerelease number (i.e., we just released a new stable minor/major version), it will add one to a patch bump, so it would go from `7.2.0` to `7.2.1-0` by default. + +`next`-PRs are only created if there are actual changes to release. Content labeled with "build" or "documentation" is [not considered "releasable"](#which-changes-are-considered-releasable-and-what-does-it-mean) and is not user-facing, so it doesn't make sense to create a release. This is explained in more detail in [Why are no release PRs being prepared?](#why-are-no-release-prs-being-prepared). + +The preparation workflow will create a new branch from `next`, called `version-non-patch-from-`, and open a pull request targeting `next-release`. When the Releaser merges it, the [publish workflow](#publishing) will merge `next-release` into `next`. + +Here's an example of a workflow where a feature and a bugfix have been created and then released to a new `7.1.0-alpha.29` version. All the commits highlighted with square dots are the ones that will be considered when generating the changelog. + +```mermaid +%%{init: { 'gitGraph': { 'mainBranchName': 'next' } } }%% +gitGraph + commit + branch next-release + commit tag: "7.1.0-alpha.28" + checkout next + merge next-release + commit type: HIGHLIGHT id: "direct commit" + branch new-feature + commit + commit + checkout next + merge new-feature type: HIGHLIGHT + branch some-bugfix + commit + checkout next + merge some-bugfix type: HIGHLIGHT + branch version-non-patch-from-7.1.0-alpha.28 + commit id: "write changelog" + checkout next-release + merge version-non-patch-from-7.1.0-alpha.28 + commit id: "bump versions" tag: "7.1.0-alpha.29" + checkout next + merge next-release +``` + ### Publishing > **Note** > Workflow: [`publish.yml`](../.github/workflows/publish.yml) -When either a prerelease or a patch release branch is merged into `main` or `next-release`, the publishing workflow is triggered. This workflow performs the following tasks: +When either a non-patch-release or a patch release branch is merged into `latest-release` or `next-release`, the publishing workflow is triggered. This workflow performs the following tasks: 1. Bump versions of all packages according to the plan from the prepared PRs 2. Install dependencies and build all packages. 3. Publish packages to npm. -4. (If this is a patch release, add the "**picked**" label to all relevant pull requests.) +4. (If this is a patch release, add the "**patch:done**" label to all relevant pull requests.) 5. Create a new GitHub Release, including a version tag in the release branch (`latest-release` or `next-release`). 6. Merge the release branch into the core branch (`main` or `next`). 7. (If this is a patch release, copy the `CHANGELOG.md` changes from `main` to `next`.) -8. (If this is [a promotion from a prerelease to a stable release](#minormajor-releases---710-rc2---710-or-800-rc3---800), force push `next` to `main`.) The publish workflow runs in the "release" GitHub environment, which has the npm token required to publish packages to the `@storybook` npm organization. For security reasons, this environment can only be accessed from the four "core" branches: `main`, `next`, `latest-release` and `next-release`. @@ -244,7 +245,7 @@ The high-level workflow for a Releaser is: Look for the release pull request that has been prepared for the type of release you're about to release: -- "Release: Prerelease ``" for prereleases +- "Release: Prerelease|Minor|Major ``" for releases from `next` - "Release: Patch ``" for patch releases - "Release: Merge patches to `main` (without version bump)" for patches without releases @@ -266,7 +267,7 @@ It is important to verify that the release includes the right content. Key eleme For example, check if it's a breaking change that isn't allowed in a minor prerelease, or if it's a new feature in a patch release. If it's not suitable, revert the pull request and notify the author. -Sometimes when doing a patch release, a pull request can have the "patch" label but you don't want that change to be part of this release. Maybe you're not confident in the change, or you require more input from maintainers before releasing it. In those situations you should remove the "patch" label from the pull request and follow through with the release (make sure to re-trigger the workflow). When the release is done, add the patch label back again, so it will be part of the next release. +Sometimes when doing a patch release, a pull request can have the "patch:yes" label but you don't want that change to be part of this release. Maybe you're not confident in the change, or you require more input from maintainers before releasing it. In those situations you should remove the "patch:yes" label from the pull request and follow through with the release (make sure to re-trigger the workflow). When the release is done, add the "patch:yes" label back again, so it will be part of the next release. 2. Is the pull request title correct? @@ -300,12 +301,12 @@ When triggering the workflows, always choose the `next` branch as the base, unle The workflows can be triggered here: -- [Prepare prerelease PR](https://github.com/storybookjs/storybook/actions/workflows/prepare-prerelease.yml) +- [Prepare next PR](https://github.com/storybookjs/storybook/actions/workflows/prepare-non-patch-release.yml) - [Prepare patch PR](https://github.com/storybookjs/storybook/actions/workflows/prepare-patch-release.yml) -Crucially for prereleases, this is also where you change the versioning strategy if you need something else than the default as described in [Preparing - Prereleases](#prereleases). When triggering the prerelease workflow manually, you can optionally add inputs: +Crucially for prereleases, this is also where you change the versioning strategy if you need something else than the default as described in [Preparing - Non-patch Releases](#non-patch-releases). When triggering the prerelease workflow manually, you can optionally add inputs: -![Screenshot of triggering the prerelease workflow in GitHub Actions, with a form that shows a release type selector and a prerelease identifier text field](prerelease-workflow-inputs.png) +![Screenshot of triggering the non-patch-release workflow in GitHub Actions, with a form that shows a release type selector and a prerelease identifier text field](prerelease-workflow-inputs.png) See [Versioning Scenarios](#versioning-scenarios) for a description of each version bump scenario, how to activate it and what it does, and [Which combination of inputs creates the version bump I need?](#which-combination-of-inputs-creates-the-version-bump-i-need) for a detailed description of the workflow inputs. @@ -339,7 +340,7 @@ You can inspect the workflows to see what they are running and copy that, but he Before you start you should make sure that your working tree is clean and the repository is in a clean state by running `git clean -xdf`. -1. Create a new branch from either `next` (prereleases) or `main` (patches) +1. Create a new branch from either `next` or `main` (patches) 2. Get all tags: `git fetch --tags origin` 3. Install dependencies: `yarn task --task=install --start-from=install` 4. `cd scripts` @@ -375,7 +376,7 @@ Before you start you should make sure that your working tree is clean and the re 4. `git add ./CHANGELOG.md` 5. `git commit -m "Update CHANGELOG.md for v"` 6. `git push origin` -19. (If prerelease) Sync `versions/next.json` from `next` to `main` +19. (If non-patch-release) Sync `versions/next.json` from `next` to `main` 1. `git checkout main` 2. `git pull` 3. `git checkout origin/next ./docs/versions/next.json` @@ -434,7 +435,7 @@ There are multiple types of releases that use the same principles, but are done ### Prereleases - `7.1.0-alpha.12` -> `7.1.0-alpha.13` -This is the default strategy for prereleases, there's nothing special needed to trigger this scenario. +This is the default strategy for Non-patch releases, there's nothing special needed to trigger this scenario. ### Prerelease promotions - `7.1.0-alpha.13` -> `7.1.0-beta.0` @@ -445,14 +446,12 @@ To promote a prerelease to a new prerelease ID, during the [Re-trigger the Workf ### Minor/major releases - `7.1.0-rc.2` -> `7.1.0` or `8.0.0-rc.3` -> `8.0.0` -To promote a prerelease to a new prerelease ID, during the [Re-trigger the Workflow](#4-re-trigger-the-workflow) step, choose: +To promote a prerelease to a stable reelase, during the [Re-trigger the Workflow](#4-re-trigger-the-workflow) step, choose: -- Release type: Patch +- Release type: Patch, Minor or Major - Prerelease ID: Leave empty -The "Patch" release type ensures the current prerelease version gets promoted to a stable version without any major/minor/patch bumps. - -This scenario is special as it turns the `next` branch into a stable branch (until the next prerelease). Therefore, this will also force push `next` to `main`, to ensure that `main` contains the latest stable release. Consequently, the history for `main` is lost. +This scenario is special as it will target `latest-release` instead of `next-release`, and thus merge into `main` when done, and not `next`. So it goes `next` -> `version-non-patch-from-` -> `latest-release` -> `main`. ### First prerelease of new major/minor - `7.1.0` -> `7.2.0-alpha.0` or `8.0.0-alpha.0` @@ -481,13 +480,13 @@ As described in more details in [the Patch Releases section](#patch-releases), t ## FAQ -### When should I use the "patch" label? +### When should I use the "patch:yes" label? -Not all pull requests need to be patched back to the stable release, which is why only those with the **"patch"** label gets that treatment. But how do you decide whether or not a give pull requests should have that label? +Not all pull requests need to be patched back to the stable release, which is why only those with the **"patch:yes"** label gets that treatment. But how do you decide whether or not a give pull requests should have that label? -First of all, patches are only for fixes and minor improvements, and not completely new features. A pull request that introduces a new feature shouldn't be patched back to the stable release. +First of all, patches are only for important and time-sensitive fixes, and not minor improvements or completely new features. A pull request that introduces a new feature shouldn't be patched back to the stable release. -Second, any destabilizing changes shouldn't be patched back either. Breaking changes are reserved for major releases, but changes can be destabilizing without being strictly breaking, and those shouldn't be patched back either. An example is moving the settings panel in the manager to a completely different place, but with the same functionality. Many wouldn't consider this breaking because no usage will stop working because of this, but it can be considered a destabilizing change because user behavior have to change as a result of this. +Second, PRs that changes the code in a big architectural way should ideally not be patched back either, because that makes merge conflicts more likely in the future. When in doubt ask the core team for their input. @@ -497,12 +496,15 @@ The whole process is based on [GitHub Action workflows](../.github/workflows/) a The short answer to "how", is to make changes as a regular pull request that is also patched back to `main`. -There's a longer answer too, but it's pretty confusing: +
+ There's a longer answer too, but it's pretty confusing The scripts run from either `main` or `next`, so if you're changing a release script, you must patch it back to `main` for it to have an effect on patch releases. If you need the change to take effect immediately, you must manually cherry pick it to `main`. For workflow file changes, they usually run from `next`, but patching them back is recommended for consistency. The "publish" workflow runs from `latest-release` and `next-release`, so you should always patch changes back for _that_. 🙃 +
+ ### Why do I need to re-trigger workflows to update the changelog? Changes to pull requests' titles, labels or even reverts won't be reflected in the release pull request. This is because the workflow only triggers on pushes to `next`, not when pull request meta data is changed. @@ -536,7 +538,7 @@ If a pull request does not have any of the above labels at the time of release, This is most likely because `next` only contains [unreleasable changes](#which-changes-are-considered-releasable-and-what-does-it-mean), which causes the preparation workflow to cancel itself. That's because it doesn't make sense to prepare a new release if all the changes are unreleasable, as that wouldn't bump the version nor write a new changelog entry, so "releasing" it would just merge it back to `next` without any differences. -You can always see the workflows and if they have been cancelled [here for prereleases](https://github.com/storybookjs/storybook/actions/workflows/prepare-prerelease.yml) and [here for patch releases](https://github.com/storybookjs/storybook/actions/workflows/prepare-patch-release.yml). +You can always see the workflows and if they have been cancelled [here for non-patch releases](https://github.com/storybookjs/storybook/actions/workflows/prepare-non-patch-release.yml) and [here for patch releases](https://github.com/storybookjs/storybook/actions/workflows/prepare-patch-release.yml). ### Why do we need separate release branches? @@ -558,11 +560,11 @@ gitGraph branch some-simultaneous-bugfix commit checkout next - branch version-prerelease-from-7.1.0-alpha.28 + branch version-non-patch-from-7.1.0-alpha.28 commit id checkout next merge some-simultaneous-bugfix type: HIGHLIGHT id: "whoops!" - merge version-prerelease-from-7.1.0-alpha.28 tag: "v7.1.0-alpha.29" + merge version-non-patch-from-7.1.0-alpha.28 tag: "v7.1.0-alpha.29" ``` When publishing at the last commit with tag `v7.1.0-alpha.29`, it will publish whatever the content is at that point (all the square dots), which includes the "whoops!" commit from merging the bugfix. But the bugfix was never part of the release pull request because it got prepared before the bugfix was merged in. @@ -582,19 +584,19 @@ gitGraph branch some-simultanous-bugfix commit checkout next - branch version-prerelease-from-7.1.0-alpha.28 + branch version-non-patch-from-7.1.0-alpha.28 commit id: "write changelog" checkout next merge some-simultanous-bugfix id: "whoops!" checkout next-release - merge version-prerelease-from-7.1.0-alpha.28 + merge version-non-patch-from-7.1.0-alpha.28 commit id: "bump versions" tag: "v7.1.0-alpha.29" checkout next merge next-release - branch version-prerelease-from-7.1.0-alpha.29 + branch version-non-patch-from-7.1.0-alpha.29 commit id: "write changelog again" checkout next-release - merge version-prerelease-from-7.1.0-alpha.29 + merge version-non-patch-from-7.1.0-alpha.29 commit id: "bump versions again" tag: "v7.1.0-alpha.30" checkout next merge next-release diff --git a/scripts/package.json b/scripts/package.json index eff165cd7f76..c9ba74055d2f 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -13,6 +13,8 @@ "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:cancel-preparation-runs": "ts-node --swc ./release/cancel-preparation-runs.ts", + "release:ensure-next-ahead": "ts-node --swc ./release/ensure-next-ahead.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", @@ -72,6 +74,7 @@ "@jest/globals": "^29.3.1", "@nx/workspace": "16.2.1", "@octokit/graphql": "^5.0.5", + "@octokit/request": "^8.1.2", "@storybook/eslint-config-storybook": "^3.1.2", "@storybook/jest": "next", "@storybook/linter-config": "^3.1.2", diff --git a/scripts/release/__tests__/cancel-preparation-runs.test.ts b/scripts/release/__tests__/cancel-preparation-runs.test.ts new file mode 100644 index 000000000000..aaf8cdbec718 --- /dev/null +++ b/scripts/release/__tests__/cancel-preparation-runs.test.ts @@ -0,0 +1,107 @@ +/* eslint-disable global-require */ +/* eslint-disable no-underscore-dangle */ +import { + PREPARE_NON_PATCH_WORKFLOW_PATH, + PREPARE_PATCH_WORKFLOW_PATH, + run as cancelPreparationWorkflows, +} from '../cancel-preparation-runs'; +import * as github_ from '../utils/github-client'; + +jest.mock('../utils/github-client'); + +const github = jest.mocked(github_); + +jest.spyOn(console, 'log').mockImplementation(() => {}); +jest.spyOn(console, 'warn').mockImplementation(() => {}); +jest.spyOn(console, 'error').mockImplementation(() => {}); + +describe('Cancel preparation runs', () => { + beforeEach(() => { + jest.clearAllMocks(); + github.githubRestClient.mockImplementation(((route: string, options: any) => { + switch (route) { + case 'GET /repos/{owner}/{repo}/actions/workflows': + return { + data: { + workflows: [ + { + id: 1, + path: PREPARE_PATCH_WORKFLOW_PATH, + }, + { + id: 2, + path: PREPARE_NON_PATCH_WORKFLOW_PATH, + }, + ], + }, + }; + case 'GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs': + return { + data: { + workflow_runs: [ + { + id: options.workflow_id === 1 ? 100 : 200, + status: 'in_progress', + }, + { + id: options.workflow_id === 1 ? 150 : 250, + status: 'completed', + }, + ], + }, + }; + case 'POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel': + return undefined; // success + default: + throw new Error(`Unexpected route: ${route}`); + } + }) as any); + }); + + it('should fail early when no GH_TOKEN is set', async () => { + delete process.env.GH_TOKEN; + await expect(cancelPreparationWorkflows()).rejects.toThrowErrorMatchingInlineSnapshot( + `"GH_TOKEN environment variable must be set, exiting."` + ); + }); + + it('should cancel all running preparation workflows in GitHub', async () => { + process.env.GH_TOKEN = 'MY_SECRET'; + + await expect(cancelPreparationWorkflows()).resolves.toBeUndefined(); + + expect(github.githubRestClient).toHaveBeenCalledTimes(5); + expect(github.githubRestClient).toHaveBeenCalledWith( + 'POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel', + { + owner: 'storybookjs', + repo: 'storybook', + run_id: 100, + } + ); + expect(github.githubRestClient).toHaveBeenCalledWith( + 'POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel', + { + owner: 'storybookjs', + repo: 'storybook', + run_id: 200, + } + ); + expect(github.githubRestClient).not.toHaveBeenCalledWith( + 'POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel', + { + owner: 'storybookjs', + repo: 'storybook', + run_id: 150, + } + ); + expect(github.githubRestClient).not.toHaveBeenCalledWith( + 'POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel', + { + owner: 'storybookjs', + repo: 'storybook', + run_id: 250, + } + ); + }); +}); diff --git a/scripts/release/__tests__/ensure-next-ahead.test.ts b/scripts/release/__tests__/ensure-next-ahead.test.ts new file mode 100644 index 000000000000..0b192d39b106 --- /dev/null +++ b/scripts/release/__tests__/ensure-next-ahead.test.ts @@ -0,0 +1,85 @@ +/* eslint-disable global-require */ +/* eslint-disable no-underscore-dangle */ +import path from 'path'; +import { run as ensureNextAhead } from '../ensure-next-ahead'; +import * as gitClient_ from '../utils/git-client'; +import * as bumpVersion_ from '../version'; + +jest.mock('../utils/git-client', () => jest.requireActual('jest-mock-extended').mockDeep()); +const gitClient = jest.mocked(gitClient_); + +// eslint-disable-next-line jest/no-mocks-import +jest.mock('fs-extra', () => require('../../../code/__mocks__/fs-extra')); +const fsExtra = require('fs-extra'); + +jest.mock('../version', () => jest.requireActual('jest-mock-extended').mockDeep()); +const bumpVersion = jest.mocked(bumpVersion_); + +jest.spyOn(console, 'log').mockImplementation(() => {}); +jest.spyOn(console, 'warn').mockImplementation(() => {}); +jest.spyOn(console, 'error').mockImplementation(() => {}); + +const CODE_PACKAGE_JSON_PATH = path.join(__dirname, '..', '..', '..', 'code', 'package.json'); + +describe('Ensure next ahead', () => { + beforeEach(() => { + jest.clearAllMocks(); + gitClient.git.status.mockResolvedValue({ current: 'next' } as any); + fsExtra.__setMockFiles({ + [CODE_PACKAGE_JSON_PATH]: JSON.stringify({ version: '2.0.0' }), + }); + }); + + it('should throw when main-version is missing', async () => { + await expect(ensureNextAhead({})).rejects.toThrowErrorMatchingInlineSnapshot(` + "[ + { + "code": "invalid_type", + "expected": "string", + "received": "undefined", + "path": [ + "mainVersion" + ], + "message": "Required" + } + ]" + `); + }); + + it('should throw when main-version is not a semver string', async () => { + await expect(ensureNextAhead({ mainVersion: '200' })).rejects + .toThrowErrorMatchingInlineSnapshot(` + "[ + { + "code": "custom", + "message": "main-version must be a valid semver version string like '7.4.2'.", + "path": [] + } + ]" + `); + }); + + it('should not bump version when next is already ahead of main', async () => { + await expect(ensureNextAhead({ mainVersion: '1.0.0' })).resolves.toBeUndefined(); + expect(bumpVersion.run).not.toHaveBeenCalled(); + }); + + it('should bump version to 3.1.0-alpha.0 when main is 3.0.0 and next is 2.0.0', async () => { + await expect(ensureNextAhead({ mainVersion: '3.0.0' })).resolves.toBeUndefined(); + expect(bumpVersion.run).toHaveBeenCalledWith({ exact: '3.1.0-alpha.0' }); + }); + + it('should bump version to 2.1.0-alpha.0 when main and next are both 2.0.0', async () => { + await expect(ensureNextAhead({ mainVersion: '2.0.0' })).resolves.toBeUndefined(); + expect(bumpVersion.run).toHaveBeenCalledWith({ exact: '2.1.0-alpha.0' }); + }); + + it('should bump version to 2.1.0-alpha.0 when main is 2.0.0 and and next is 2.0.0-rc.10', async () => { + fsExtra.__setMockFiles({ + [CODE_PACKAGE_JSON_PATH]: JSON.stringify({ version: '2.0.0-rc.10' }), + }); + + await expect(ensureNextAhead({ mainVersion: '2.0.0' })).resolves.toBeUndefined(); + expect(bumpVersion.run).toHaveBeenCalledWith({ exact: '2.1.0-alpha.0' }); + }); +}); diff --git a/scripts/release/__tests__/generate-pr-description.test.ts b/scripts/release/__tests__/generate-pr-description.test.ts index b0f1bbe89db5..50aa5de8e019 100644 --- a/scripts/release/__tests__/generate-pr-description.test.ts +++ b/scripts/release/__tests__/generate-pr-description.test.ts @@ -15,6 +15,7 @@ describe('Generate PR Description', () => { labels: ['bug', 'build', 'other label', 'patch:yes'], commit: 'abc123', pull: 42, + state: 'MERGED', links: { commit: '[abc123](https://github.com/storybookjs/storybook/commit/abc123)', pull: '[#42](https://github.com/storybookjs/storybook/pull/42)', @@ -26,6 +27,7 @@ describe('Generate PR Description', () => { id: null, user: 'storybook-bot', pull: null, + state: null, commit: '012b58140c3606efeacbe99c0c410624b0a1ed1f', title: 'Bump version on `next`: preminor (alpha) from 7.2.0 to 7.3.0-alpha.0', labels: null, @@ -41,6 +43,7 @@ describe('Generate PR Description', () => { user: 'shilman', title: 'Some title for a "direct commit"', labels: null, + state: null, commit: '22bb11', pull: null, links: { @@ -55,6 +58,7 @@ describe('Generate PR Description', () => { title: 'Another PR `title` for docs', labels: ['another label', 'documentation', 'patch:yes'], commit: 'ddd222', + state: 'MERGED', pull: 11, links: { commit: '[ddd222](https://github.com/storybookjs/storybook/commit/ddd222)', @@ -69,6 +73,7 @@ describe('Generate PR Description', () => { labels: ['feature request', 'other label'], commit: 'wow1337', pull: 48, + state: 'MERGED', links: { commit: '[wow1337](https://github.com/storybookjs/storybook/commit/wow1337)', pull: '[#48](https://github.com/storybookjs/storybook/pull/48)', @@ -81,6 +86,7 @@ describe('Generate PR Description', () => { title: 'Some PR title with a missing label', labels: ['incorrect label', 'other label'], commit: 'bad999', + state: 'MERGED', pull: 77, links: { commit: '[bad999](https://github.com/storybookjs/storybook/commit/bad999)', @@ -213,7 +219,7 @@ For each pull request below, you need to either manually cherry pick it, or disc - [ ] [#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/storybook/actions/workflows/prepare-prerelease.yml) and wait for it to finish. It will wipe your progress in this to do, which is expected. + 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-non-patch-release.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. @@ -340,7 +346,7 @@ 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. + 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-non-patch-release.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. diff --git a/scripts/release/__tests__/is-pr-frozen.test.ts b/scripts/release/__tests__/is-pr-frozen.test.ts index 63747a863ddf..00331e0555cd 100644 --- a/scripts/release/__tests__/is-pr-frozen.test.ts +++ b/scripts/release/__tests__/is-pr-frozen.test.ts @@ -26,6 +26,7 @@ describe('isPrFrozen', () => { it('should return true when PR is frozen', async () => { getPullInfoFromCommit.mockResolvedValue({ labels: ['freeze'], + state: 'OPEN', }); await expect(isPrFrozen({ patch: false })).resolves.toBe(true); }); @@ -33,6 +34,15 @@ describe('isPrFrozen', () => { it('should return false when PR is not frozen', async () => { getPullInfoFromCommit.mockResolvedValue({ labels: [], + state: 'OPEN', + }); + await expect(isPrFrozen({ patch: false })).resolves.toBe(false); + }); + + it('should return false when PR is closed', async () => { + getPullInfoFromCommit.mockResolvedValue({ + labels: ['freeze'], + state: 'CLOSED', }); await expect(isPrFrozen({ patch: false })).resolves.toBe(false); }); @@ -54,7 +64,7 @@ describe('isPrFrozen', () => { }); await isPrFrozen({ patch: false }); - expect(simpleGit.__fetch).toHaveBeenCalledWith('origin', 'version-prerelease-from-1.0.0', { + expect(simpleGit.__fetch).toHaveBeenCalledWith('origin', 'version-non-patch-from-1.0.0', { '--depth': 1, }); }); diff --git a/scripts/release/__tests__/label-patches.test.ts b/scripts/release/__tests__/label-patches.test.ts index d98abc7eb763..d43290a1828c 100644 --- a/scripts/release/__tests__/label-patches.test.ts +++ b/scripts/release/__tests__/label-patches.test.ts @@ -58,6 +58,7 @@ const pullInfoMock = { commit: '930b47f011f750c44a1782267d698ccdd3c04da3', title: 'Legal: Fix license', labels: ['documentation', 'patch:yes', 'patch:done'], + state: 'MERGED', links: { commit: '[`930b47f011f750c44a1782267d698ccdd3c04da3`](https://github.com/storybookjs/storybook/commit/930b47f011f750c44a1782267d698ccdd3c04da3)', diff --git a/scripts/release/cancel-preparation-runs.ts b/scripts/release/cancel-preparation-runs.ts new file mode 100644 index 000000000000..630bfb4847b3 --- /dev/null +++ b/scripts/release/cancel-preparation-runs.ts @@ -0,0 +1,107 @@ +/** + * This script cancels all running preparation workflows in GitHub. + * It will fetch all active runs for the preparation workflows, and cancel them. + */ +/* eslint-disable no-console */ +import chalk from 'chalk'; +import program from 'commander'; +import dedent from 'ts-dedent'; +import { githubRestClient } from './utils/github-client'; + +program + .name('cancel-preparation-workflows') + .description('cancel all running preparation workflows in GitHub'); + +export const PREPARE_PATCH_WORKFLOW_PATH = '.github/workflows/prepare-patch-release.yml'; +export const PREPARE_NON_PATCH_WORKFLOW_PATH = '.github/workflows/prepare-non-patch-release.yml'; + +export const run = async () => { + if (!process.env.GH_TOKEN) { + throw new Error('GH_TOKEN environment variable must be set, exiting.'); + } + + console.log(`🔎 Looking for workflows to cancel...`); + const allWorkflows = await githubRestClient('GET /repos/{owner}/{repo}/actions/workflows', { + owner: 'storybookjs', + repo: 'storybook', + }); + + const preparePatchWorkflowId = allWorkflows.data.workflows.find( + ({ path }) => path === PREPARE_PATCH_WORKFLOW_PATH + )?.id; + const prepareNonPatchWorkflowId = allWorkflows.data.workflows.find( + ({ path }) => path === PREPARE_NON_PATCH_WORKFLOW_PATH + )?.id; + + console.log(`Found workflow IDs for the preparation workflows: + ${chalk.blue(PREPARE_PATCH_WORKFLOW_PATH)}: ${chalk.green(preparePatchWorkflowId)} + ${chalk.blue(PREPARE_NON_PATCH_WORKFLOW_PATH)}: ${chalk.green(prepareNonPatchWorkflowId)}`); + + if (!preparePatchWorkflowId || !prepareNonPatchWorkflowId) { + throw new Error(dedent`🚨 Could not find workflow IDs for the preparation workflows + - Looked for paths: "${chalk.blue(PREPARE_PATCH_WORKFLOW_PATH)}" and "${chalk.blue( + PREPARE_NON_PATCH_WORKFLOW_PATH + )}", are they still correct? + - Found workflows: + ${JSON.stringify(allWorkflows.data.workflows, null, 2)}`); + } + + console.log('🔍 Fetching patch and non-patch runs for preparation workflows...'); + const [patchRuns, nonPatchRuns] = await Promise.all([ + githubRestClient('GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs', { + owner: 'storybookjs', + repo: 'storybook', + workflow_id: preparePatchWorkflowId, + }), + githubRestClient('GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs', { + owner: 'storybookjs', + repo: 'storybook', + workflow_id: prepareNonPatchWorkflowId, + }), + ]); + console.log('✅ Successfully fetched patch and non-patch runs for preparation workflows.'); + + const runsToCancel = patchRuns.data.workflow_runs + .concat(nonPatchRuns.data.workflow_runs) + .filter(({ status }) => + ['in_progress', 'pending', 'queued', 'requested', 'waiting'].includes(status) + ); + + if (runsToCancel.length === 0) { + console.log('👍 No runs to cancel.'); + return; + } + + console.log(`🔍 Found ${runsToCancel.length} runs to cancel. Cancelling them now: + ${runsToCancel + .map((r) => `${chalk.green(r.path)} - ${chalk.green(r.id)}: ${chalk.blue(r.status)}`) + .join('\n ')}`); + + const result = await Promise.allSettled( + runsToCancel.map((r) => + githubRestClient('POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel', { + owner: 'storybookjs', + repo: 'storybook', + run_id: r.id, + }) + ) + ); + + if (result.some((r) => r.status === 'rejected')) { + console.warn('⚠️ Some runs could not be cancelled:'); + result.forEach((r, index) => { + if (r.status === 'rejected') { + console.warn(`Run ID: ${runsToCancel[index].id} - Reason: ${r.reason}`); + } + }); + } else { + console.log('✅ Successfully cancelled all preparation runs.'); + } +}; + +if (require.main === module) { + run().catch((err) => { + console.error(err); + // this is non-critical work, so we don't want to fail the CI build if this fails + }); +} diff --git a/scripts/release/ensure-next-ahead.ts b/scripts/release/ensure-next-ahead.ts new file mode 100644 index 000000000000..1aa32bfd932c --- /dev/null +++ b/scripts/release/ensure-next-ahead.ts @@ -0,0 +1,101 @@ +/** + * This script ensures that next is always one minor ahead of main. + * This is needed when releasing a stable from next. + * Next will be at eg. 7.4.0-alpha.4, and main will be at 7.3.0. + * Then we release 7.4.0 by merging next to latest-release to main. + * We then ensure here that next is bumped to 7.5.0-alpha.0 - without releasing it. + * If this is a patch release bumping main to 7.3.1, next will not be touched because it's already ahead. + */ + +/* 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 { run as bumpVersion } from './version'; +import { git } from './utils/git-client'; + +program + .name('ensure-next-ahead') + .description('ensure the "next" branch is always a minor version ahead of "main"') + .requiredOption('-M, --main-version ', 'The version currently on the "main" branch'); + +const optionsSchema = z + .object({ + mainVersion: z.string(), + }) + .refine((schema) => semver.valid(schema.mainVersion), { + message: "main-version must be a valid semver version string like '7.4.2'.", + }); + +type Options = { + mainVersion: string; +}; + +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 () => { + const { version } = await readJson(CODE_PACKAGE_JSON_PATH); + console.log(`📐 Current version of Storybook is ${chalk.green(version)}`); + return version; +}; + +export const run = async (options: unknown) => { + if (!validateOptions(options)) { + return; + } + const { mainVersion } = options; + + const { current: currentGitBranch } = await git.status(); + + if (currentGitBranch !== 'next') { + console.warn( + `🚧 The current branch is not "next" but "${currentGitBranch}", this only really makes sense to run on the "next" branch.` + ); + } + + // Get the current version from code/package.json + const currentNextVersion = await getCurrentVersion(); + if (semver.gt(currentNextVersion, mainVersion)) { + console.log( + `✅ The version on next (${chalk.green( + currentNextVersion + )}) is already ahead of the version on main (${chalk.green(mainVersion)}), no action needed.` + ); + return; + } + + const nextNextVersion = `${semver.inc(mainVersion, 'minor')}-alpha.0`; + + console.log( + `🤜 The version on next (${chalk.green( + currentNextVersion + )}) is behind the version on main (${chalk.green(mainVersion)}), bumping to ${chalk.blue( + nextNextVersion + )}...` + ); + + await bumpVersion({ exact: nextNextVersion }); + + console.log( + `✅ bumped all versions to ${chalk.green( + nextNextVersion + )}, remember to commit and push to next.` + ); +}; + +if (require.main === module) { + const parsed = program.parse(); + run(parsed.opts()).catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/scripts/release/generate-pr-description.ts b/scripts/release/generate-pr-description.ts index 16a6928e994f..d437e8dc87bc 100644 --- a/scripts/release/generate-pr-description.ts +++ b/scripts/release/generate-pr-description.ts @@ -18,7 +18,7 @@ program 'Which version to generate changelog from, eg. "7.0.7". Defaults to the version at code/package.json' ) .option('-N, --next-version ', 'Which version to generate changelog to, eg. "7.0.8"') - .option('-P, --unpicked-patches', 'Set to only consider PRs labeled with "patch" label') + .option('-P, --unpicked-patches', 'Set to only consider PRs labeled with "patch:yes" label') .option( '-M, --manual-cherry-picks ', 'A stringified JSON array of commit hashes, of patch PRs that needs to be cherry-picked manually' @@ -52,6 +52,7 @@ const CHANGE_TITLES_TO_IGNORE = [ /\[ci skip\]/i, /^Update CHANGELOG\.md for.*/i, /^Release: (Pre)?(Patch|Minor|Major|Release).*\d+$/i, + /^Update \.\/docs\/versions/, ]; export const mapToChangelist = ({ @@ -65,7 +66,7 @@ export const mapToChangelist = ({ .filter((change) => { // eslint-disable-next-line no-restricted-syntax for (const titleToIgnore of CHANGE_TITLES_TO_IGNORE) { - if (change.title.match(titleToIgnore)) { + if (change.title?.match(titleToIgnore)) { return false; } } @@ -90,7 +91,7 @@ export const mapToChangelist = ({ )[0] || 'unknown') as keyof typeof LABELS_BY_IMPORTANCE; return `- [ ] **${LABELS_BY_IMPORTANCE[label]}**: ${change.title} ${change.links.pull}${ - !unpickedPatches && change.labels.includes('patch:yes') ? ' (will also be patched)' : '' + !unpickedPatches && change.labels?.includes('patch:yes') ? ' (will also be patched)' : '' }`; }) .join('\n'); @@ -141,7 +142,9 @@ export const generateReleaseDescription = ({ changelogText: string; manualCherryPicks?: string; }): string => { - const workflow = semver.prerelease(nextVersion) ? 'prepare-prerelease' : 'prepare-patch-release'; + const workflow = semver.prerelease(nextVersion) + ? 'prepare-non-patch-release' + : 'prepare-patch-release'; const workflowUrl = `https://github.com/storybookjs/storybook/actions/workflows/${workflow}.yml`; return ( diff --git a/scripts/release/is-pr-frozen.ts b/scripts/release/is-pr-frozen.ts index 70289b5369d8..e81610a451b9 100644 --- a/scripts/release/is-pr-frozen.ts +++ b/scripts/release/is-pr-frozen.ts @@ -12,7 +12,7 @@ program .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('-H, --patch', 'Look for patch PR instead of next PR', false) .option('-V, --verbose', 'Enable verbose logging', false); const CODE_DIR_PATH = path.join(__dirname, '..', '..', 'code'); @@ -46,7 +46,7 @@ export const run = async (options: unknown) => { const { verbose, patch } = options as { verbose?: boolean; patch?: boolean }; const version = await getCurrentVersion(); - const branch = `version-${patch ? 'patch' : 'prerelease'}-from-${version}`; + const branch = `version-${patch ? 'patch' : 'non-patch'}-from-${version}`; console.log(`💬 Determining if pull request from branch '${chalk.blue(branch)}' is frozen`); @@ -78,6 +78,14 @@ export const run = async (options: unknown) => { console.log(`🔍 Found pull request: ${JSON.stringify(pullRequest, null, 2)}`); + if (pullRequest.state !== 'OPEN') { + console.log('❌ The pull request is already closed, ignoring it'); + if (process.env.GITHUB_ACTIONS === 'true') { + setOutput('frozen', false); + } + return false; + } + const isFrozen = pullRequest.labels?.includes('freeze'); if (process.env.GITHUB_ACTIONS === 'true') { setOutput('frozen', isFrozen); diff --git a/scripts/release/pick-patches.ts b/scripts/release/pick-patches.ts index 82f1fe2b4a1a..ddee7999792e 100644 --- a/scripts/release/pick-patches.ts +++ b/scripts/release/pick-patches.ts @@ -80,6 +80,7 @@ export const run = async (_: unknown) => { } if (process.env.GITHUB_ACTIONS === 'true') { + setOutput('pr-count', JSON.stringify(patchPRs.length)); setOutput('failed-cherry-picks', JSON.stringify(failedCherryPicks)); } }; diff --git a/scripts/release/utils/get-changes.ts b/scripts/release/utils/get-changes.ts index 1ad2a0759198..416ea624fb50 100644 --- a/scripts/release/utils/get-changes.ts +++ b/scripts/release/utils/get-changes.ts @@ -53,7 +53,7 @@ export const getFromCommit = async (from?: string | undefined, verbose?: boolean console.log(`🔍 No 'from' specified, found latest tag: ${chalk.blue(latest)}`); } } - const commit = await getCommitAt(actualFrom, verbose); + const commit = await getCommitAt(actualFrom!, verbose); if (verbose) { console.log(`🔍 Found 'from' commit: ${chalk.blue(commit)}`); } diff --git a/scripts/release/utils/get-github-info.ts b/scripts/release/utils/get-github-info.ts index 6bd7126aec04..bdfe995a9001 100644 --- a/scripts/release/utils/get-github-info.ts +++ b/scripts/release/utils/get-github-info.ts @@ -40,6 +40,7 @@ function makeQuery(repos: ReposWithCommitsAndPRsToFetch) { number id title + state url mergedAt labels(first: 50) { @@ -63,6 +64,7 @@ function makeQuery(repos: ReposWithCommitsAndPRsToFetch) { : `pr__${data.pull}: pullRequest(number: ${data.pull}) { url title + state author { login url @@ -161,11 +163,12 @@ export type PullRequestInfo = { user: string | null; id: string | null; title: string | null; + state: string | null; commit: string | null; pull: number | null; labels: string[] | null; links: { - commit: string; + commit: string | null; pull: string | null; user: string | null; }; @@ -197,6 +200,7 @@ export async function getPullInfoFromCommit(request: { pull: null, commit: request.commit, title: null, + state: null, labels: null, links: { commit: request.commit, @@ -205,10 +209,7 @@ export async function getPullInfoFromCommit(request: { }, }; } - let user = null; - if (data.author && data.author.user) { - user = data.author.user; - } + let user = data?.author?.user || null; const associatedPullRequest = data.associatedPullRequests && @@ -245,6 +246,7 @@ export async function getPullInfoFromCommit(request: { pull: associatedPullRequest ? associatedPullRequest.number : null, commit: request.commit, title: associatedPullRequest ? associatedPullRequest.title : null, + state: associatedPullRequest ? associatedPullRequest.state : null, labels: associatedPullRequest ? (associatedPullRequest.labels.nodes || []).map((label: { name: string }) => label.name) : null, @@ -287,6 +289,7 @@ export async function getPullInfoFromPullRequest(request: { pull: request.pull, commit: commit ? commit.oid : null, title: title || null, + state: data?.state || null, labels: data ? (data.labels.nodes || []).map((label: { name: string }) => label.name) : null, links: { commit: commit ? `[\`${commit.oid}\`](${commit.commitUrl})` : null, diff --git a/scripts/release/utils/github-client.ts b/scripts/release/utils/github-client.ts index 646ba1003986..e81991414bf9 100644 --- a/scripts/release/utils/github-client.ts +++ b/scripts/release/utils/github-client.ts @@ -1,6 +1,8 @@ /* eslint-disable no-console */ import type { GraphQlQueryResponseData } from '@octokit/graphql'; import { graphql } from '@octokit/graphql'; +import { request } from '@octokit/request'; +import fetch from 'node-fetch'; export interface PullRequest { number: number; @@ -14,6 +16,13 @@ export const githubGraphQlClient = graphql.defaults({ headers: { authorization: `token ${process.env.GH_TOKEN}` }, }); +export const githubRestClient = request.defaults({ + request: { + fetch, + }, + headers: { authorization: `token ${process.env.GH_TOKEN}`, 'X-GitHub-Api-Version': '2022-11-28' }, +}); + export async function getUnpickedPRs( baseBranch: string, verbose?: boolean diff --git a/scripts/yarn.lock b/scripts/yarn.lock index be6fe9587929..75d42776f049 100644 --- a/scripts/yarn.lock +++ b/scripts/yarn.lock @@ -2565,6 +2565,17 @@ __metadata: languageName: node linkType: hard +"@octokit/endpoint@npm:^9.0.0": + version: 9.0.1 + resolution: "@octokit/endpoint@npm:9.0.1" + dependencies: + "@octokit/types": ^12.0.0 + is-plain-object: ^5.0.0 + universal-user-agent: ^6.0.0 + checksum: 757505b1cd634bcd7b71a18c8fe07dfda47790598ddd0d9d13f47d68713070f49953a672ac40ec39787defc2a7e07d08dca97756def7b907118f8f8d4c653f5c + languageName: node + linkType: hard + "@octokit/graphql@npm:^4.3.1, @octokit/graphql@npm:^4.5.8": version: 4.8.0 resolution: "@octokit/graphql@npm:4.8.0" @@ -2601,6 +2612,13 @@ __metadata: languageName: node linkType: hard +"@octokit/openapi-types@npm:^19.0.0": + version: 19.0.0 + resolution: "@octokit/openapi-types@npm:19.0.0" + checksum: 8270e0a224bbef6d1c82396cda873a3528111cb25a772184b39e1fbada4e6433b41c5f4634ecf204e8a2816a802048197e0132b7615b579fab217f7c1e29545b + languageName: node + linkType: hard + "@octokit/plugin-paginate-rest@npm:^2.16.8, @octokit/plugin-paginate-rest@npm:^2.2.0": version: 2.21.3 resolution: "@octokit/plugin-paginate-rest@npm:2.21.3" @@ -2665,6 +2683,17 @@ __metadata: languageName: node linkType: hard +"@octokit/request-error@npm:^5.0.0": + version: 5.0.1 + resolution: "@octokit/request-error@npm:5.0.1" + dependencies: + "@octokit/types": ^12.0.0 + deprecation: ^2.0.0 + once: ^1.4.0 + checksum: e72a4627120de345b54876a1f007664095e5be9d624fce2e14fccf7668cd8f5e4929d444d8fc085d48e1fb5cd548538453974aab129a669101110d6679dce6c6 + languageName: node + linkType: hard + "@octokit/request@npm:^5.4.0, @octokit/request@npm:^5.6.0, @octokit/request@npm:^5.6.3": version: 5.6.3 resolution: "@octokit/request@npm:5.6.3" @@ -2693,6 +2722,19 @@ __metadata: languageName: node linkType: hard +"@octokit/request@npm:^8.1.2": + version: 8.1.2 + resolution: "@octokit/request@npm:8.1.2" + dependencies: + "@octokit/endpoint": ^9.0.0 + "@octokit/request-error": ^5.0.0 + "@octokit/types": ^12.0.0 + is-plain-object: ^5.0.0 + universal-user-agent: ^6.0.0 + checksum: 49219eb71b856acecc8268f05a7a7d074488f9eaeb59439f5c8872e5b4555a4e6c0cf0ebcadf0983466f88957e74c27765f582e473b0dd89b453274501f0dc37 + languageName: node + linkType: hard + "@octokit/rest@npm:^16.43.0 || ^17.11.0 || ^18.12.0, @octokit/rest@npm:^18.12.0": version: 18.12.0 resolution: "@octokit/rest@npm:18.12.0" @@ -2717,6 +2759,15 @@ __metadata: languageName: node linkType: hard +"@octokit/types@npm:^12.0.0": + version: 12.0.0 + resolution: "@octokit/types@npm:12.0.0" + dependencies: + "@octokit/openapi-types": ^19.0.0 + checksum: 6e5b68f8855775638db53244348d2ca07896d36a15aad41d3cb652fafaa1b307c3b6222319174dd5716accd9076e101da838b82f988a7c380a8e9d188e3aadf1 + languageName: node + linkType: hard + "@octokit/types@npm:^4.1.6": version: 4.1.10 resolution: "@octokit/types@npm:4.1.10" @@ -2928,6 +2979,7 @@ __metadata: "@jest/globals": ^29.3.1 "@nx/workspace": 16.2.1 "@octokit/graphql": ^5.0.5 + "@octokit/request": ^8.1.2 "@storybook/eslint-config-storybook": ^3.1.2 "@storybook/jest": next "@storybook/linter-config": ^3.1.2