diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index be37b132b1fc..df82ca33c9a5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -37,20 +37,9 @@ jobs: gh run watch ${{ github.run_id }} - name: Cancel all release preparation runs - run: | - # Get a list of all running or pending release preparation runs - # combining both the prepare-patch-release.yml and prepare-non-patch-release.yml workflows - RUNNING_RELEASE_PREPARATIONS=$( - { - gh run list --limit 50 --workflow=prepare-patch-release.yml --json databaseId,status - gh run list --limit 50 --workflow=prepare-non-patch-release.yml --json databaseId,status - } | jq -rc '.[] | select(.status | contains("in_progress", "pending", "queued", "requested", "waiting")) | .databaseId' - ) - - # Loop through each run and pass it to the "gh run cancel" command - while IFS= read -r databaseId; do - gh run cancel "$databaseId" - done <<< "$RUNNING_RELEASE_PREPARATIONS" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: yarn release:cancel-preparation-runs - name: Checkout ${{ github.ref_name }} uses: actions/checkout@v3 diff --git a/scripts/package.json b/scripts/package.json index 4cd557a0736e..9514d8fe5865 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -13,6 +13,7 @@ "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", @@ -74,6 +75,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/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/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 ee273eaf2d45..690e145e27ea 100644 --- a/scripts/yarn.lock +++ b/scripts/yarn.lock @@ -2536,6 +2536,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" @@ -2572,6 +2583,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" @@ -2636,6 +2654,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" @@ -2664,6 +2693,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" @@ -2688,6 +2730,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" @@ -2906,6 +2957,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