From 7df0125b6d0306afa3bc5d6159fdd094445ceba0 Mon Sep 17 00:00:00 2001 From: Matsu Date: Thu, 5 Mar 2026 10:23:18 +0200 Subject: [PATCH] ci: Allow manual execution of backport workflow (#26576) --- .github/scripts/compute-backport-targets.mjs | 39 ++++++++++++++- .../scripts/compute-backport-targets.test.mjs | 48 +++++++++++++++++-- .../scripts/fixtures/mock-github-event.json | 5 ++ .github/scripts/github-helpers.mjs | 48 ++++++++++++++++++- .github/workflows/backport.yml | 14 +++++- 5 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 .github/scripts/fixtures/mock-github-event.json diff --git a/.github/scripts/compute-backport-targets.mjs b/.github/scripts/compute-backport-targets.mjs index 077abe98b89..36dbf5d3e7f 100644 --- a/.github/scripts/compute-backport-targets.mjs +++ b/.github/scripts/compute-backport-targets.mjs @@ -1,6 +1,11 @@ // Creates backport PR's according to labels on merged PR -import { readPrLabels, resolveRcBranchForTrack, writeGithubOutput } from './github-helpers.mjs'; +import { + getPullRequestById, + readPrLabels, + resolveRcBranchForTrack, + writeGithubOutput, +} from './github-helpers.mjs'; /** @type { Record } */ const BACKPORT_BY_TAG_MAP = { @@ -50,8 +55,38 @@ export function labelsToReleaseCandidateBranches(labels) { return targets; } +/** + * This script is called in 2 cases: + * + * 1. When a PR is merged, in which case functions like `readPrLabels` reads PR info from GITHUB_EVENT_PATH + * 2. Manually via Workflow Dispatch, where a Pull Request ID is passed as an env parameter + * + * @returns { Promise } Pull request object, if ID was provided in env params + */ +async function fetchPossiblePullRequestFromEnv() { + const pullRequestEnv = process.env.PULL_REQUEST_ID; + if (!pullRequestEnv) { + // No ID provided, will proceed to read data from GITHUB_EVENT_PATH + return undefined; + } + + const pullRequestNumber = parseInt(pullRequestEnv); + if (isNaN(pullRequestNumber)) { + throw new Error( + "PULL_REQUEST_ID must be a number. It shouldn't contain any other symbols (#, PR, etc.)", + ); + } + + return await getPullRequestById(pullRequestNumber); +} + +export async function getLabels() { + const pullRequest = await fetchPossiblePullRequestFromEnv(); + return new Set(readPrLabels(pullRequest)); +} + async function main() { - const labels = new Set(readPrLabels()); + const labels = await getLabels(); if (!labels || labels.size === 0) { console.log('No labels on PR. Exiting...'); return; diff --git a/.github/scripts/compute-backport-targets.test.mjs b/.github/scripts/compute-backport-targets.test.mjs index 5fea8d63f31..ddeb1a2e47f 100644 --- a/.github/scripts/compute-backport-targets.test.mjs +++ b/.github/scripts/compute-backport-targets.test.mjs @@ -1,5 +1,6 @@ import { describe, it, mock, before } from 'node:test'; import assert from 'node:assert/strict'; +import { readPrLabels } from './github-helpers.mjs'; /** * Run these tests by running @@ -12,9 +13,14 @@ import assert from 'node:assert/strict'; mock.module('./github-helpers.mjs', { namedExports: { ensureEnvVar: () => {}, // no-op - readPrLabels: () => {}, // no-op + readPrLabels: readPrLabels, resolveRcBranchForTrack: mockResolveRcBranchForTrack, writeGithubOutput: () => {}, //no-op + getPullRequestById: () => { + return { + labels: ['n8n team', 'Backport to Beta'], + }; + }, }, }); @@ -28,9 +34,11 @@ function mockResolveRcBranchForTrack(track) { return undefined; } -let labelsToReleaseCandidateBranches; +let labelsToReleaseCandidateBranches, getLabels; before(async () => { - ({ labelsToReleaseCandidateBranches } = await import('./compute-backport-targets.mjs')); + ({ labelsToReleaseCandidateBranches, getLabels } = await import( + './compute-backport-targets.mjs' + )); }); describe('Compute backport targets', () => { @@ -59,4 +67,38 @@ describe('Compute backport targets', () => { assert.equal(result.size, 0); }); + + it('Should parse labels properly in Pull request context', async () => { + process.env.GITHUB_EVENT_PATH = './fixtures/mock-github-event.json'; + /** @type { Set } */ + const labels = await getLabels(); + + assert.equal(labels.size, 2); + assert.ok(labels.has('release')); + assert.ok(labels.has('Backport to Stable')); + }); + it('Should parse labels properly in manual workflow context', async () => { + process.env.PULL_REQUEST_ID = '123'; + /** @type { Set } */ + const labels = await getLabels(); + + assert.equal(labels.size, 2); + assert.ok(labels.has('n8n team')); + assert.ok(labels.has('Backport to Beta')); + }); + + it('Should throw when passed pull request id with #', async () => { + process.env.PULL_REQUEST_ID = '#123'; + await assert.rejects(getLabels); + }); + + it('Should not throw when passed pull request id with just a number', async () => { + process.env.PULL_REQUEST_ID = '123'; + await assert.doesNotReject(getLabels); + }); + + it('Should throw when passed pull request id with other than numbers included', async () => { + process.env.PULL_REQUEST_ID = 'abc-123'; + await assert.rejects(getLabels); + }); }); diff --git a/.github/scripts/fixtures/mock-github-event.json b/.github/scripts/fixtures/mock-github-event.json new file mode 100644 index 00000000000..0a13f47e504 --- /dev/null +++ b/.github/scripts/fixtures/mock-github-event.json @@ -0,0 +1,5 @@ +{ + "pull_request": { + "labels": ["release", "Backport to Stable"] + } +} diff --git a/.github/scripts/github-helpers.mjs b/.github/scripts/github-helpers.mjs index b16f02ab75f..9efb1009d1a 100644 --- a/.github/scripts/github-helpers.mjs +++ b/.github/scripts/github-helpers.mjs @@ -1,5 +1,7 @@ +import { getOctokit } from '@actions/github'; import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; +import path from 'node:path'; import semver from 'semver'; export const RELEASE_TRACKS = /** @type { const } */ ([ @@ -117,6 +119,14 @@ export function stripReleasePrefixes(tag) { ); } +export function getEventFromGithubEventPath() { + let eventPath = ensureEnvVar('GITHUB_EVENT_PATH'); + if (!path.isAbsolute(eventPath)) { + eventPath = import.meta.dirname + '/' + eventPath; + } + return JSON.parse(fs.readFileSync(eventPath, 'utf8')); +} + /** * @param {any} [pullRequest] Optional pull request object. If not provided, reads from GITHUB_EVENT_PATH * @@ -124,8 +134,7 @@ export function stripReleasePrefixes(tag) { */ export function readPrLabels(pullRequest) { if (!pullRequest) { - const eventPath = ensureEnvVar('GITHUB_EVENT_PATH'); - const event = JSON.parse(fs.readFileSync(eventPath, 'utf8')); + const event = getEventFromGithubEventPath(); pullRequest = event.pull_request; } /** @type { string[] | { name: string }[] } */ @@ -247,3 +256,38 @@ export function localRefExists(ref) { const res = trySh('git', ['show-ref', '--verify', '--quiet', ref]); return res.ok; } + +/** + * Initializes octokit with GITHUB_TOKEN from env vars. + * + * Also ensures the existence of useful environment variables. + * */ +export function initGithub() { + const token = ensureEnvVar('GITHUB_TOKEN'); + const repoFullName = ensureEnvVar('GITHUB_REPOSITORY'); + + const [owner, repo] = repoFullName.split('/'); + + const octokit = getOctokit(token); + + return { + octokit, + owner, + repo, + }; +} + +/** + * @param {number} pullRequestId + */ +export async function getPullRequestById(pullRequestId) { + const { octokit, owner, repo } = initGithub(); + + const pullRequest = await octokit.rest.pulls.get({ + owner, + repo, + pull_number: pullRequestId, + }); + + return pullRequest.data; +} diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index 9bf114831ce..74d133a8cfb 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -1,6 +1,12 @@ on: pull_request: types: [closed] + workflow_dispatch: + inputs: + pull-request-id: + description: 'The ID number of the pull request (e.g. 3342). No #, no extra letters.' + required: true + type: string permissions: contents: write @@ -8,7 +14,9 @@ permissions: jobs: backport: - if: github.event.pull_request.merged == true + if: | + github.event.pull_request.merged == true || + github.event_name == 'workflow_dispatch' runs-on: ubuntu-slim steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 @@ -23,6 +31,8 @@ jobs: - name: Compute backport targets id: targets + env: + PULL_REQUEST_ID: ${{ inputs.pull-request-id }} run: node .github/scripts/compute-backport-targets.mjs - name: Backport @@ -30,7 +40,7 @@ jobs: uses: korthout/backport-action@c656f5d5851037b2b38fb5db2691a03fa229e3b2 # v4.0.1 with: github_token: ${{ secrets.GITHUB_TOKEN }} - source_pr_number: ${{ github.event.pull_request.number }} + source_pr_number: ${{ github.event.pull_request.number || inputs.pull-request-id }} target_branches: ${{ steps.targets.outputs.target_branches }} pull_description: |- # Description