From c7607a2b962fcbfae553a64510ae49c4b016aed5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 15:32:14 +0000 Subject: [PATCH 1/4] feat: add target/N-x-y labels to main branch Chromium roll PRs When rolling Chromium on main, label the roll PR with target/N-x-y for every supported release branch whose scheduled Chromium version (per https://releases.electronjs.org/schedule.json) is >= the Chromium major being rolled to, so the roll is backported to the branches that will ship it. no-backport is now only applied when no target branch applies. If the release schedule cannot be fetched, the roll still proceeds and falls back to the previous no-backport behavior. --- src/utils/get-target-branch-labels.ts | 45 +++++++++ src/utils/roll.ts | 26 ++++- tests/fixtures/electron-release-schedule.json | 68 +++++++++++++ tests/utils/get-target-branch-labels.spec.ts | 88 +++++++++++++++++ tests/utils/roll.spec.ts | 96 +++++++++++++++++++ 5 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 src/utils/get-target-branch-labels.ts create mode 100644 tests/fixtures/electron-release-schedule.json create mode 100644 tests/utils/get-target-branch-labels.spec.ts diff --git a/src/utils/get-target-branch-labels.ts b/src/utils/get-target-branch-labels.ts new file mode 100644 index 0000000..d546c59 --- /dev/null +++ b/src/utils/get-target-branch-labels.ts @@ -0,0 +1,45 @@ +import { Octokit } from '@octokit/rest'; + +import { REPOS } from '../constants.js'; +import { ReposListBranchesResponseItem } from '../types.js'; +import { getSupportedBranches } from './get-supported-branches.js'; + +export const ELECTRON_RELEASE_SCHEDULE_URL = 'https://releases.electronjs.org/schedule.json'; + +interface ReleaseScheduleEntry { + version: string; + branch: string; + chromiumVersion: number; +} + +// Returns the `target/N-x-y` label for every supported release branch whose +// scheduled Chromium version is greater than or equal to the Chromium major +// being rolled to, per https://releases.electronjs.org/schedule. +export async function getTargetBranchLabels( + octokit: Octokit, + chromiumMajorVersion: number, +): Promise { + const response = await fetch(ELECTRON_RELEASE_SCHEDULE_URL, { + headers: { accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(`Failed to fetch Electron release schedule: ${response.status}`); + } + const schedule = (await response.json()) as ReleaseScheduleEntry[]; + + const branches: ReposListBranchesResponseItem[] = await octokit.paginate( + octokit.repos.listBranches.endpoint.merge({ + ...REPOS.electron, + protected: true, + }), + ); + const supported = getSupportedBranches(branches); + + return schedule + .filter( + (entry) => + supported.includes(entry.branch) && Number(entry.chromiumVersion) >= chromiumMajorVersion, + ) + .map((entry) => `target/${entry.branch}`) + .sort(); +} diff --git a/src/utils/roll.ts b/src/utils/roll.ts index c1e26b0..1d3789f 100644 --- a/src/utils/roll.ts +++ b/src/utils/roll.ts @@ -17,6 +17,7 @@ import { getPRText } from './pr-text.js'; import { updateDepsFile } from './update-deps.js'; import { Octokit } from '@octokit/rest'; import { addLabels, removeLabel } from './label-utils.js'; +import { getTargetBranchLabels } from './get-target-branch-labels.js'; interface RollParams { rollTarget: RollTarget; @@ -30,10 +31,33 @@ async function updateLabels( octokit: Octokit, { rollTarget, electronBranch, targetVersion, previousVersion, prNumber }: RollParams, ) { + const d = debug(`roller/${rollTarget.name}:updateLabels()`); let labels: string[] = []; let labelToRemove: string; - labels.push(electronBranch.name === MAIN_BRANCH ? NO_BACKPORT : BACKPORT_CHECK_SKIP); + if (electronBranch.name === MAIN_BRANCH) { + let targetBranchLabels: string[] = []; + + // Chromium rolls to main should be labeled for backport to every supported + // release branch whose scheduled Chromium version is >= the rolled version. + if (rollTarget === ROLL_TARGETS.chromium) { + const chromiumMajorVersion = Number(targetVersion.split('.')[0]); + try { + targetBranchLabels = await getTargetBranchLabels(octokit, chromiumMajorVersion); + } catch (e) { + d(`Failed to determine target branch labels: ${e.message} - skipping target labels`); + } + } + + if (targetBranchLabels.length > 0) { + d(`Adding target branch labels: ${targetBranchLabels.join(', ')}`); + labels.push(...targetBranchLabels); + } else { + labels.push(NO_BACKPORT); + } + } else { + labels.push(BACKPORT_CHECK_SKIP); + } // Chromium bumps & roll bumps to the main branch are always patch bumps. if (electronBranch.name === MAIN_BRANCH || rollTarget === ROLL_TARGETS.chromium) { diff --git a/tests/fixtures/electron-release-schedule.json b/tests/fixtures/electron-release-schedule.json new file mode 100644 index 0000000..5ce1c22 --- /dev/null +++ b/tests/fixtures/electron-release-schedule.json @@ -0,0 +1,68 @@ +[ + { + "version": "46.0.0", + "branch": "main", + "alphaDate": "2026-10-22", + "betaDate": "2026-12-01", + "stableDate": "2027-01-05", + "chromiumVersion": 160, + "nodeVersion": "24.20.0", + "eolDate": "2027-06-22", + "status": "nightly" + }, + { + "version": "45.0.0", + "branch": "45-x-y", + "alphaDate": "2026-08-27", + "betaDate": "2026-09-29", + "stableDate": "2026-10-20", + "chromiumVersion": 156, + "nodeVersion": "24.19.0", + "eolDate": "2027-04-27", + "status": "prerelease" + }, + { + "version": "44.0.0", + "branch": "44-x-y", + "alphaDate": "2026-07-02", + "betaDate": "2026-07-28", + "stableDate": "2026-08-25", + "chromiumVersion": 152, + "nodeVersion": "24.18.1", + "eolDate": "2027-03-02", + "status": "stable" + }, + { + "version": "43.0.0", + "branch": "43-x-y", + "alphaDate": "2026-05-07", + "betaDate": "2026-06-02", + "stableDate": "2026-06-30", + "chromiumVersion": 150, + "nodeVersion": "24.17.0", + "eolDate": "2027-01-05", + "status": "stable" + }, + { + "version": "42.0.0", + "branch": "42-x-y", + "alphaDate": "2026-03-12", + "betaDate": "2026-04-07", + "stableDate": "2026-05-05", + "chromiumVersion": 148, + "nodeVersion": "24.15.0", + "eolDate": "2026-10-20", + "status": "stable" + }, + { + "version": "41.0.0", + "branch": "41-x-y", + "alphaDate": "2026-01-15", + "betaDate": "2026-02-10", + "stableDate": "2026-03-10", + "chromiumVersion": 146, + "nodeVersion": "24.14.0", + "eolDate": "2026-08-25", + "status": "eol" + } +] diff --git a/tests/utils/get-target-branch-labels.spec.ts b/tests/utils/get-target-branch-labels.spec.ts new file mode 100644 index 0000000..fbb609d --- /dev/null +++ b/tests/utils/get-target-branch-labels.spec.ts @@ -0,0 +1,88 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import nock from 'nock'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + ELECTRON_RELEASE_SCHEDULE_URL, + getTargetBranchLabels, +} from '../../src/utils/get-target-branch-labels.js'; + +describe('getTargetBranchLabels', () => { + let mockOctokit: any; + + const fixture = fs.readFileSync( + path.join(import.meta.dirname, '../fixtures/electron-release-schedule.json'), + 'utf8', + ); + const url = new URL(ELECTRON_RELEASE_SCHEDULE_URL); + + beforeEach(() => { + nock.cleanAll(); + mockOctokit = { + paginate: vi.fn().mockResolvedValue( + // 41-x-y is EOL and outside the supported window of 4. + ['41-x-y', '42-x-y', '43-x-y', '44-x-y', '45-x-y'].map((name) => ({ name })), + ), + repos: { + listBranches: { + endpoint: { + merge: vi.fn(), + }, + }, + }, + }; + }); + + it('returns labels for supported branches scheduled for >= the rolled major', async () => { + nock(url.origin).get(url.pathname).reply(200, fixture); + + // 45-x-y ships Chromium 156, 44-x-y ships 152 - rolling to 154 only + // targets 45-x-y. + await expect(getTargetBranchLabels(mockOctokit, 154)).resolves.toEqual(['target/45-x-y']); + }); + + it('includes a branch whose scheduled major equals the rolled major', async () => { + nock(url.origin).get(url.pathname).reply(200, fixture); + + await expect(getTargetBranchLabels(mockOctokit, 156)).resolves.toEqual(['target/45-x-y']); + }); + + it('returns labels for every matching supported branch', async () => { + nock(url.origin).get(url.pathname).reply(200, fixture); + + await expect(getTargetBranchLabels(mockOctokit, 150)).resolves.toEqual([ + 'target/43-x-y', + 'target/44-x-y', + 'target/45-x-y', + ]); + }); + + it('returns no labels if the rolled major is newer than every scheduled version', async () => { + nock(url.origin).get(url.pathname).reply(200, fixture); + + await expect(getTargetBranchLabels(mockOctokit, 157)).resolves.toEqual([]); + }); + + it('ignores schedule entries for unsupported branches', async () => { + nock(url.origin).get(url.pathname).reply(200, fixture); + + // 41-x-y ships Chromium 146 but is no longer supported, and main is not a + // release branch - neither should produce a label. + await expect(getTargetBranchLabels(mockOctokit, 140)).resolves.toEqual([ + 'target/42-x-y', + 'target/43-x-y', + 'target/44-x-y', + 'target/45-x-y', + ]); + }); + + it('throws if the schedule fetch fails', async () => { + nock(url.origin).get(url.pathname).reply(500); + + await expect(getTargetBranchLabels(mockOctokit, 154)).rejects.toThrowError( + 'Failed to fetch Electron release schedule: 500', + ); + }); +}); diff --git a/tests/utils/roll.spec.ts b/tests/utils/roll.spec.ts index 5dd69e8..1f26348 100644 --- a/tests/utils/roll.spec.ts +++ b/tests/utils/roll.spec.ts @@ -3,15 +3,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { roll } from '../../src/utils/roll.js'; import { getOctokit } from '../../src/utils/octokit.js'; import { + BACKPORT_CHECK_SKIP, CHROMIUM_UPGRADE_WORKFLOW, MAIN_BRANCH, + NO_BACKPORT, REPOS, ROLL_TARGETS, } from '../../src/constants.js'; +import { getTargetBranchLabels } from '../../src/utils/get-target-branch-labels.js'; import { updateDepsFile } from '../../src/utils/update-deps.js'; vi.mock('../../src/utils/octokit.js'); vi.mock('../../src/utils/update-deps.js'); +vi.mock('../../src/utils/get-target-branch-labels.js'); describe('roll()', () => { let mockOctokit: any; @@ -58,6 +62,7 @@ describe('roll()', () => { previousDEPSVersion: 'v4.0.0', newDEPSVersion: 'v10.0.0', }); + vi.mocked(getTargetBranchLabels).mockReset().mockResolvedValue([]); }); it('takes no action if versions are identical', async () => { @@ -367,6 +372,97 @@ describe('roll()', () => { }); }); + describe('backport labels', () => { + const mainBranch = { ...branch, name: MAIN_BRANCH }; + + it('adds target branch labels instead of no-backport for chromium rolls on main', async () => { + mockOctokit.paginate.mockReturnValue([]); + vi.mocked(getTargetBranchLabels).mockResolvedValue(['target/44-x-y', 'target/45-x-y']); + + await roll({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: mainBranch, + targetVersion: '152.0.0.0', + }); + + expect(getTargetBranchLabels).toHaveBeenCalledWith(mockOctokit, 152); + expect(mockOctokit.issues.addLabels).toHaveBeenCalledWith( + expect.objectContaining({ + labels: ['target/44-x-y', 'target/45-x-y', 'semver/patch'], + }), + ); + }); + + it('adds no-backport for chromium rolls on main when no target branch applies', async () => { + mockOctokit.paginate.mockReturnValue([]); + vi.mocked(getTargetBranchLabels).mockResolvedValue([]); + + await roll({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: mainBranch, + targetVersion: '160.0.0.0', + }); + + expect(mockOctokit.issues.addLabels).toHaveBeenCalledWith( + expect.objectContaining({ + labels: [NO_BACKPORT, 'semver/patch'], + }), + ); + }); + + it('falls back to no-backport if target branch labels cannot be determined', async () => { + mockOctokit.paginate.mockReturnValue([]); + vi.mocked(getTargetBranchLabels).mockRejectedValue(new Error('schedule unavailable')); + + await roll({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: mainBranch, + targetVersion: '152.0.0.0', + }); + + expect(mockOctokit.pulls.create).toHaveBeenCalled(); + expect(mockOctokit.issues.addLabels).toHaveBeenCalledWith( + expect.objectContaining({ + labels: [NO_BACKPORT, 'semver/patch'], + }), + ); + }); + + it('adds no-backport for node rolls on main without checking the schedule', async () => { + mockOctokit.paginate.mockReturnValue([]); + + await roll({ + rollTarget: ROLL_TARGETS.node, + electronBranch: mainBranch, + targetVersion: 'v10.0.0', + }); + + expect(getTargetBranchLabels).not.toHaveBeenCalled(); + expect(mockOctokit.issues.addLabels).toHaveBeenCalledWith( + expect.objectContaining({ + labels: [NO_BACKPORT, 'semver/patch'], + }), + ); + }); + + it('adds backport-check-skip for chromium rolls on a release branch', async () => { + mockOctokit.paginate.mockReturnValue([]); + + await roll({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: branch, + targetVersion: '152.0.0.0', + }); + + expect(getTargetBranchLabels).not.toHaveBeenCalled(); + expect(mockOctokit.issues.addLabels).toHaveBeenCalledWith( + expect.objectContaining({ + labels: [BACKPORT_CHECK_SKIP, 'semver/patch'], + }), + ); + }); + }); + it('skips PR if existing one has been paused', async () => { mockOctokit.paginate.mockReturnValue([ { From 4cc3cc0ec6ed2bcb49e077e6ce5b10f56536e660 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 15:55:21 +0000 Subject: [PATCH 2/4] feat: suppress tracked release branch rolls and reconcile stale labels Skip the independent Chromium roll for a release branch whose scheduled Chromium version is >= the major the main branch roll targets (the latest Canary) - such branches receive the main roll via their target/N-x-y label instead, so rolling them separately would produce a duplicate roll PR. The check uses the same schedule predicate as the target label computation; if it cannot be determined, the branch rolls independently as before. Since the main roll PR is updated daily and labels were add-only, also remove roller-managed labels that no longer apply on each update: stale target/N-x-y labels that no longer qualify, and no-backport when target labels currently apply. Other labels are never touched, and removal failures are non-fatal. --- src/chromium-handler.ts | 31 +++++++ src/utils/get-target-branch-labels.ts | 24 ++++-- src/utils/roll.ts | 46 ++++++++++ tests/handlers.spec.ts | 54 ++++++++++++ tests/utils/get-target-branch-labels.spec.ts | 7 ++ tests/utils/roll.spec.ts | 90 ++++++++++++++++++++ 6 files changed, 246 insertions(+), 6 deletions(-) diff --git a/src/chromium-handler.ts b/src/chromium-handler.ts index 9d37c3b..684ffbd 100644 --- a/src/chromium-handler.ts +++ b/src/chromium-handler.ts @@ -4,6 +4,7 @@ import { MAIN_BRANCH, REPOS, ROLL_TARGETS } from './constants.js'; import { compareChromiumVersions } from './utils/compare-chromium-versions.js'; import { getChromiumReleases, Release } from './utils/get-chromium-tags.js'; import { getSupportedBranches } from './utils/get-supported-branches.js'; +import { getBranchesTrackedByMain } from './utils/get-target-branch-labels.js'; import { getContent } from './utils/github-utils.js'; import { getOctokit } from './utils/octokit.js'; import { roll } from './utils/roll.js'; @@ -12,9 +13,39 @@ import { Octokit } from '@octokit/rest'; type BranchItem = ReposGetBranchResponseItem | ReposListBranchesResponseItem; +// A release branch is tracked by the main branch roll if its scheduled +// Chromium version is >= the Chromium major the main roll targets (the latest +// Canary) - the roll it needs is the main roll, backported via its +// `target/N-x-y` label, so it should not get an independent roll of its own. +async function isTrackedByMainRoll(github: Octokit, branchName: string): Promise { + const canaryReleases = await getChromiumReleases({ channel: 'Canary' }); + const latestCanaryVersion = canaryReleases[canaryReleases.length - 1]; + if (!latestCanaryVersion) return false; + + const mainChromiumMajorVersion = Number(latestCanaryVersion.split('.')[0]); + if (Number.isNaN(mainChromiumMajorVersion)) return false; + + const trackedBranches = await getBranchesTrackedByMain(github, mainChromiumMajorVersion); + return trackedBranches.includes(branchName); +} + async function rollReleaseBranch(github: Octokit, branch: BranchItem) { const d = debug(`roller/chromium:rollReleaseBranch('${branch.name}')`); + d(`Checking whether ${branch.name} is tracked by the ${MAIN_BRANCH} branch roll`); + try { + if (await isTrackedByMainRoll(github, branch.name)) { + d( + `${branch.name} is scheduled to ship the Chromium version targeted by the ${MAIN_BRANCH} roll - skipping independent roll`, + ); + return; + } + } catch (e) { + d( + `Could not determine whether ${branch.name} is tracked by the ${MAIN_BRANCH} roll: ${e.message} - rolling independently`, + ); + } + d(`Fetching DEPS for ${branch.name}`); const deps = await getContent(github, { ...REPOS.electron, diff --git a/src/utils/get-target-branch-labels.ts b/src/utils/get-target-branch-labels.ts index d546c59..2388efe 100644 --- a/src/utils/get-target-branch-labels.ts +++ b/src/utils/get-target-branch-labels.ts @@ -12,10 +12,12 @@ interface ReleaseScheduleEntry { chromiumVersion: number; } -// Returns the `target/N-x-y` label for every supported release branch whose -// scheduled Chromium version is greater than or equal to the Chromium major -// being rolled to, per https://releases.electronjs.org/schedule. -export async function getTargetBranchLabels( +// Returns the names of the supported release branches whose scheduled Chromium +// version is greater than or equal to the given Chromium major, per +// https://releases.electronjs.org/schedule - i.e. the branches whose Chromium +// upgrade is tracked by rolls to the main branch and will receive it as a +// backport rather than an independent roll. +export async function getBranchesTrackedByMain( octokit: Octokit, chromiumMajorVersion: number, ): Promise { @@ -40,6 +42,16 @@ export async function getTargetBranchLabels( (entry) => supported.includes(entry.branch) && Number(entry.chromiumVersion) >= chromiumMajorVersion, ) - .map((entry) => `target/${entry.branch}`) - .sort(); + .map((entry) => entry.branch); +} + +// Returns the `target/N-x-y` label for every supported release branch whose +// scheduled Chromium version is greater than or equal to the Chromium major +// being rolled to, per https://releases.electronjs.org/schedule. +export async function getTargetBranchLabels( + octokit: Octokit, + chromiumMajorVersion: number, +): Promise { + const branches = await getBranchesTrackedByMain(octokit, chromiumMajorVersion); + return branches.map((branch) => `target/${branch}`).sort(); } diff --git a/src/utils/roll.ts b/src/utils/roll.ts index 1d3789f..e9c15b7 100644 --- a/src/utils/roll.ts +++ b/src/utils/roll.ts @@ -27,6 +27,51 @@ interface RollParams { previousVersion?: string; } +const TARGET_BRANCH_LABEL_PATTERN = /^target\/\d+-x-y$/; + +// The main branch roll PR is long-lived and relabeled on every update, and +// labels are otherwise only ever added - so a `target/N-x-y` label that no +// longer qualifies (main rolled past the branch's scheduled Chromium version) +// or a `no-backport` added while the schedule was unavailable would stick +// around forever. Remove only those roller-managed labels; never touch +// anything else (merged/*, trop, semver, ...) and never replace the label set. +async function removeStaleBackportLabels( + octokit: Octokit, + prNumber: number, + targetBranchLabels: string[], +) { + const d = debug('roller/chromium:removeStaleBackportLabels()'); + + const { data: labelData } = await octokit.issues.listLabelsOnIssue({ + ...REPOS.electron, + issue_number: prNumber, + per_page: 100, + page: 1, + }); + + const staleLabels = labelData + .map((label) => label.name) + .filter( + (name) => + (TARGET_BRANCH_LABEL_PATTERN.test(name) && !targetBranchLabels.includes(name)) || + (name === NO_BACKPORT && targetBranchLabels.length > 0), + ); + + for (const name of staleLabels) { + d(`Removing stale backport label ${name} from #${prNumber}`); + try { + await octokit.issues.removeLabel({ + ...REPOS.electron, + issue_number: prNumber, + name, + }); + } catch (e) { + // The label may have been removed out from under us - not fatal. + d(`Failed to remove label ${name} from #${prNumber}: ${e.message}`); + } + } +} + async function updateLabels( octokit: Octokit, { rollTarget, electronBranch, targetVersion, previousVersion, prNumber }: RollParams, @@ -44,6 +89,7 @@ async function updateLabels( const chromiumMajorVersion = Number(targetVersion.split('.')[0]); try { targetBranchLabels = await getTargetBranchLabels(octokit, chromiumMajorVersion); + await removeStaleBackportLabels(octokit, prNumber, targetBranchLabels); } catch (e) { d(`Failed to determine target branch labels: ${e.message} - skipping target labels`); } diff --git a/tests/handlers.spec.ts b/tests/handlers.spec.ts index cde9996..f2e212b 100644 --- a/tests/handlers.spec.ts +++ b/tests/handlers.spec.ts @@ -9,12 +9,14 @@ import { getContent } from '../src/utils/github-utils.js'; import { getOctokit } from '../src/utils/octokit.js'; import { roll } from '../src/utils/roll.js'; import { getLatestLTSVersion } from '../src/utils/get-nodejs-lts.js'; +import { getBranchesTrackedByMain } from '../src/utils/get-target-branch-labels.js'; vi.mock('../src/utils/get-chromium-tags.js'); vi.mock('../src/utils/github-utils.js'); vi.mock('../src/utils/octokit.js'); vi.mock('../src/utils/roll.js'); vi.mock('../src/utils/get-nodejs-lts.js'); +vi.mock('../src/utils/get-target-branch-labels.js'); describe('handleChromiumCheck()', () => { let mockOctokit: any; @@ -41,6 +43,7 @@ describe('handleChromiumCheck()', () => { }, }; vi.mocked(getOctokit).mockReturnValue(mockOctokit); + vi.mocked(getBranchesTrackedByMain).mockReset().mockResolvedValue([]); }); describe('release branches', () => { @@ -144,6 +147,57 @@ describe('handleChromiumCheck()', () => { ); }); + it('skips the roll if the branch is tracked by the main branch roll', async () => { + vi.mocked(getChromiumReleases).mockResolvedValue(['1.1.0.0', '2.1.0.0']); + vi.mocked(getBranchesTrackedByMain).mockResolvedValue(['4-0-x']); + + mockOctokit.repos.getBranch.mockReturnValue({ + data: { + name: '4-0-x', + commit: { + sha: '1234', + }, + }, + }); + + await handleChromiumCheck('4-0-x'); + + // The tracked check uses the major of the latest Canary release, the + // same version the main branch roll targets. + expect(getBranchesTrackedByMain).toHaveBeenCalledWith(mockOctokit, 2); + expect(roll).not.toHaveBeenCalled(); + }); + + it('rolls independently if the branch is not tracked by the main branch roll', async () => { + vi.mocked(getChromiumReleases).mockResolvedValue(['1.1.0.0', '1.2.0.0']); + vi.mocked(getBranchesTrackedByMain).mockResolvedValue(['5-0-x']); + + await handleChromiumCheck(); + + expect(roll).toHaveBeenCalledWith( + expect.objectContaining({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: expect.objectContaining({ name: '4-0-x' }), + targetVersion: '1.2.0.0', + }), + ); + }); + + it('rolls independently if the tracked branches cannot be determined', async () => { + vi.mocked(getChromiumReleases).mockResolvedValue(['1.1.0.0', '1.2.0.0']); + vi.mocked(getBranchesTrackedByMain).mockRejectedValue(new Error('schedule unavailable')); + + await handleChromiumCheck(); + + expect(roll).toHaveBeenCalledWith( + expect.objectContaining({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: expect.objectContaining({ name: '4-0-x' }), + targetVersion: '1.2.0.0', + }), + ); + }); + it('fails if an invalid target is passed', async () => { mockOctokit.repos.getBranch.mockReturnValue(null); diff --git a/tests/utils/get-target-branch-labels.spec.ts b/tests/utils/get-target-branch-labels.spec.ts index fbb609d..4661070 100644 --- a/tests/utils/get-target-branch-labels.spec.ts +++ b/tests/utils/get-target-branch-labels.spec.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ELECTRON_RELEASE_SCHEDULE_URL, + getBranchesTrackedByMain, getTargetBranchLabels, } from '../../src/utils/get-target-branch-labels.js'; @@ -78,6 +79,12 @@ describe('getTargetBranchLabels', () => { ]); }); + it('returns tracked branch names via getBranchesTrackedByMain', async () => { + nock(url.origin).get(url.pathname).reply(200, fixture); + + await expect(getBranchesTrackedByMain(mockOctokit, 154)).resolves.toEqual(['45-x-y']); + }); + it('throws if the schedule fetch fails', async () => { nock(url.origin).get(url.pathname).reply(500); diff --git a/tests/utils/roll.spec.ts b/tests/utils/roll.spec.ts index 1f26348..d459964 100644 --- a/tests/utils/roll.spec.ts +++ b/tests/utils/roll.spec.ts @@ -51,6 +51,7 @@ describe('roll()', () => { }, issues: { addLabels: vi.fn(), + removeLabel: vi.fn(), listLabelsOnIssue: vi.fn().mockReturnValue({ data: [] }), }, actions: { @@ -428,6 +429,95 @@ describe('roll()', () => { ); }); + it('removes stale roller-managed labels that no longer apply', async () => { + mockOctokit.paginate.mockReturnValue([]); + mockOctokit.issues.listLabelsOnIssue.mockReturnValue({ + data: [ + { name: NO_BACKPORT }, + { name: 'target/43-x-y' }, + { name: 'target/45-x-y' }, + { name: 'merged/44-x-y' }, + { name: 'in-flight/45-x-y' }, + { name: 'semver/patch' }, + ], + }); + vi.mocked(getTargetBranchLabels).mockResolvedValue(['target/45-x-y']); + + await roll({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: mainBranch, + targetVersion: '154.0.0.0', + }); + + // target/43-x-y no longer qualifies and no-backport conflicts with the + // target labels that do - both are removed, nothing else is touched. + expect(mockOctokit.issues.removeLabel).toHaveBeenCalledTimes(2); + expect(mockOctokit.issues.removeLabel).toHaveBeenCalledWith( + expect.objectContaining({ name: 'target/43-x-y' }), + ); + expect(mockOctokit.issues.removeLabel).toHaveBeenCalledWith( + expect.objectContaining({ name: NO_BACKPORT }), + ); + }); + + it('removes stale target labels but keeps no-backport when no target branch applies', async () => { + mockOctokit.paginate.mockReturnValue([]); + mockOctokit.issues.listLabelsOnIssue.mockReturnValue({ + data: [{ name: NO_BACKPORT }, { name: 'target/45-x-y' }], + }); + vi.mocked(getTargetBranchLabels).mockResolvedValue([]); + + await roll({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: mainBranch, + targetVersion: '160.0.0.0', + }); + + expect(mockOctokit.issues.removeLabel).toHaveBeenCalledTimes(1); + expect(mockOctokit.issues.removeLabel).toHaveBeenCalledWith( + expect.objectContaining({ name: 'target/45-x-y' }), + ); + }); + + it('does not remove any labels if target branch labels cannot be determined', async () => { + mockOctokit.paginate.mockReturnValue([]); + mockOctokit.issues.listLabelsOnIssue.mockReturnValue({ + data: [{ name: 'target/45-x-y' }], + }); + vi.mocked(getTargetBranchLabels).mockRejectedValue(new Error('schedule unavailable')); + + await roll({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: mainBranch, + targetVersion: '154.0.0.0', + }); + + expect(mockOctokit.issues.removeLabel).not.toHaveBeenCalled(); + }); + + it('ignores failures to remove a stale label', async () => { + mockOctokit.paginate.mockReturnValue([]); + mockOctokit.issues.listLabelsOnIssue.mockReturnValue({ + data: [{ name: 'target/43-x-y' }], + }); + mockOctokit.issues.removeLabel.mockRejectedValue(new Error('Not Found')); + vi.mocked(getTargetBranchLabels).mockResolvedValue(['target/45-x-y']); + + await expect( + roll({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: mainBranch, + targetVersion: '154.0.0.0', + }), + ).resolves.toBeUndefined(); + + expect(mockOctokit.issues.addLabels).toHaveBeenCalledWith( + expect.objectContaining({ + labels: ['target/45-x-y', 'semver/patch'], + }), + ); + }); + it('adds no-backport for node rolls on main without checking the schedule', async () => { mockOctokit.paginate.mockReturnValue([]); From 55452831f64a6882a9cd62244a2fc91dc5ce51b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 14:39:42 +0000 Subject: [PATCH 3/4] fix: address review feedback on roll coverage and label reconciliation - Roll main first and pass the release branches its roll PR actually covers (successfully created/updated and labeled) down to the release branch rolls, instead of re-deriving schedule eligibility per branch. A covered branch is skipped only while its DEPS Chromium version has caught up to the main roll target, so a branch whose backports stall pulls itself forward with its own roll. Explicitly targeted rolls are never suppressed. - Treat the backport label set as a state machine: replacement labels are only added once every conflicting label is confirmed removed (404s count as removed), and on any failure the PR's existing backport labels are left exactly as they were - only a brand-new PR falls back to no-backport. The PR can never carry both no-backport and target/ labels. - Reconcile labels on the open main roll PR even when the DEPS version is unchanged, since the release schedule (e.g. a newly cut branch) moves independently of Chromium. - Widen the stale label pattern to the older N-M-x branch form and guard the Chromium major parse against invalid versions. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011v4QNjFUH5wLUj8eMUigbB --- src/chromium-handler.ts | 79 ++++---- src/utils/get-target-branch-labels.ts | 18 +- src/utils/roll.ts | 90 +++++++-- tests/handlers.spec.ts | 87 ++++++--- tests/utils/get-target-branch-labels.spec.ts | 43 ++--- tests/utils/roll.spec.ts | 191 +++++++++++++++---- 6 files changed, 342 insertions(+), 166 deletions(-) diff --git a/src/chromium-handler.ts b/src/chromium-handler.ts index 684ffbd..2c286ad 100644 --- a/src/chromium-handler.ts +++ b/src/chromium-handler.ts @@ -4,7 +4,6 @@ import { MAIN_BRANCH, REPOS, ROLL_TARGETS } from './constants.js'; import { compareChromiumVersions } from './utils/compare-chromium-versions.js'; import { getChromiumReleases, Release } from './utils/get-chromium-tags.js'; import { getSupportedBranches } from './utils/get-supported-branches.js'; -import { getBranchesTrackedByMain } from './utils/get-target-branch-labels.js'; import { getContent } from './utils/github-utils.js'; import { getOctokit } from './utils/octokit.js'; import { roll } from './utils/roll.js'; @@ -13,39 +12,20 @@ import { Octokit } from '@octokit/rest'; type BranchItem = ReposGetBranchResponseItem | ReposListBranchesResponseItem; -// A release branch is tracked by the main branch roll if its scheduled -// Chromium version is >= the Chromium major the main roll targets (the latest -// Canary) - the roll it needs is the main roll, backported via its -// `target/N-x-y` label, so it should not get an independent roll of its own. -async function isTrackedByMainRoll(github: Octokit, branchName: string): Promise { - const canaryReleases = await getChromiumReleases({ channel: 'Canary' }); - const latestCanaryVersion = canaryReleases[canaryReleases.length - 1]; - if (!latestCanaryVersion) return false; - - const mainChromiumMajorVersion = Number(latestCanaryVersion.split('.')[0]); - if (Number.isNaN(mainChromiumMajorVersion)) return false; - - const trackedBranches = await getBranchesTrackedByMain(github, mainChromiumMajorVersion); - return trackedBranches.includes(branchName); +// The outcome of a main branch roll: the Chromium version it targets and the +// release branches its roll PR covers with `target/N-x-y` labels. +interface MainRollResult { + targetVersion: string; + coveredBranches: string[]; } -async function rollReleaseBranch(github: Octokit, branch: BranchItem) { +async function rollReleaseBranch( + github: Octokit, + branch: BranchItem, + mainRoll?: MainRollResult | null, +) { const d = debug(`roller/chromium:rollReleaseBranch('${branch.name}')`); - d(`Checking whether ${branch.name} is tracked by the ${MAIN_BRANCH} branch roll`); - try { - if (await isTrackedByMainRoll(github, branch.name)) { - d( - `${branch.name} is scheduled to ship the Chromium version targeted by the ${MAIN_BRANCH} roll - skipping independent roll`, - ); - return; - } - } catch (e) { - d( - `Could not determine whether ${branch.name} is tracked by the ${MAIN_BRANCH} roll: ${e.message} - rolling independently`, - ); - } - d(`Fetching DEPS for ${branch.name}`); const deps = await getContent(github, { ...REPOS.electron, @@ -67,6 +47,21 @@ async function rollReleaseBranch(github: Octokit, branch: BranchItem) { throw new Error(`${branch.name} roll failed: ${chromiumVersion} is not a valid version number`); } + // A branch covered by a target/ label on the main roll PR receives the main + // roll as a backport instead of an independent roll - but only skip it while + // it has actually caught up to the main roll's target, so a branch whose + // backports stall can pull itself forward with its own roll. Explicitly + // targeted rolls pass no main roll info and are never suppressed. + if ( + mainRoll?.coveredBranches.includes(branch.name) && + compareChromiumVersions(chromiumVersion, mainRoll.targetVersion) >= 0 + ) { + d( + `${branch.name} is covered by the ${MAIN_BRANCH} roll to ${mainRoll.targetVersion} and has caught up - skipping independent roll`, + ); + return; + } + d(`Computing latest upstream version for Chromium ${chromiumMajorVersion}`); const chromiumReleases = await getChromiumReleases({ milestone: chromiumMajorVersion }); const latestUpstreamVersion = chromiumReleases[chromiumReleases.length - 1]; @@ -92,7 +87,7 @@ async function rollReleaseBranch(github: Octokit, branch: BranchItem) { } } -async function rollMainBranch(github: Octokit) { +async function rollMainBranch(github: Octokit): Promise { const d = debug('roller/chromium:rollMainBranch()'); d(`Fetching ${MAIN_BRANCH} branch for electron/electron`); @@ -132,17 +127,18 @@ async function rollMainBranch(github: Octokit) { if (latestUpstreamVersion && currentVersion !== latestUpstreamVersion) { d(`Updating ${MAIN_BRANCH} from ${currentVersion} to ${latestUpstreamVersion}`); try { - await roll({ + const coveredBranches = await roll({ rollTarget: ROLL_TARGETS.chromium, electronBranch: mainBranch, targetVersion: latestUpstreamVersion, }); + return { targetVersion: latestUpstreamVersion, coveredBranches: coveredBranches ?? [] }; } catch (e) { throw new Error(`Failed to roll ${MAIN_BRANCH} to ${latestUpstreamVersion}: ${e.message}`); } } - return true; + return null; } export async function handleChromiumCheck(target?: string): Promise { @@ -185,21 +181,24 @@ export async function handleChromiumCheck(target?: string): Promise { const releaseBranches = branches.filter((branch) => supported.includes(branch.name)); d(`Found ${releaseBranches.length} release branches`); + // Roll main first, so that the release branches its roll PR covers with + // target/ labels can skip their own rolls in favor of the backports. + let mainRoll: MainRollResult | null = null; + try { + mainRoll = await rollMainBranch(github); + } catch (e) { + failed = true; + } + // Roll all non-main release branches. for (const branch of releaseBranches) { try { - await rollReleaseBranch(github, branch); + await rollReleaseBranch(github, branch, mainRoll); } catch (e) { failed = true; continue; } } - - try { - await rollMainBranch(github); - } catch (e) { - failed = true; - } } if (failed) { diff --git a/src/utils/get-target-branch-labels.ts b/src/utils/get-target-branch-labels.ts index 2388efe..4e7c00a 100644 --- a/src/utils/get-target-branch-labels.ts +++ b/src/utils/get-target-branch-labels.ts @@ -15,8 +15,8 @@ interface ReleaseScheduleEntry { // Returns the names of the supported release branches whose scheduled Chromium // version is greater than or equal to the given Chromium major, per // https://releases.electronjs.org/schedule - i.e. the branches whose Chromium -// upgrade is tracked by rolls to the main branch and will receive it as a -// backport rather than an independent roll. +// upgrade is tracked by rolls to the main branch and should receive them as +// backports (via `target/N-x-y` labels) rather than independent rolls. export async function getBranchesTrackedByMain( octokit: Octokit, chromiumMajorVersion: number, @@ -42,16 +42,6 @@ export async function getBranchesTrackedByMain( (entry) => supported.includes(entry.branch) && Number(entry.chromiumVersion) >= chromiumMajorVersion, ) - .map((entry) => entry.branch); -} - -// Returns the `target/N-x-y` label for every supported release branch whose -// scheduled Chromium version is greater than or equal to the Chromium major -// being rolled to, per https://releases.electronjs.org/schedule. -export async function getTargetBranchLabels( - octokit: Octokit, - chromiumMajorVersion: number, -): Promise { - const branches = await getBranchesTrackedByMain(octokit, chromiumMajorVersion); - return branches.map((branch) => `target/${branch}`).sort(); + .map((entry) => entry.branch) + .sort(); } diff --git a/src/utils/roll.ts b/src/utils/roll.ts index e9c15b7..c9bbfde 100644 --- a/src/utils/roll.ts +++ b/src/utils/roll.ts @@ -17,7 +17,7 @@ import { getPRText } from './pr-text.js'; import { updateDepsFile } from './update-deps.js'; import { Octokit } from '@octokit/rest'; import { addLabels, removeLabel } from './label-utils.js'; -import { getTargetBranchLabels } from './get-target-branch-labels.js'; +import { getBranchesTrackedByMain } from './get-target-branch-labels.js'; interface RollParams { rollTarget: RollTarget; @@ -27,7 +27,7 @@ interface RollParams { previousVersion?: string; } -const TARGET_BRANCH_LABEL_PATTERN = /^target\/\d+-x-y$/; +const TARGET_BRANCH_LABEL_PATTERN = /^target\/\d+-(?:\d+-x|x-y)$/; // The main branch roll PR is long-lived and relabeled on every update, and // labels are otherwise only ever added - so a `target/N-x-y` label that no @@ -35,6 +35,7 @@ const TARGET_BRANCH_LABEL_PATTERN = /^target\/\d+-x-y$/; // or a `no-backport` added while the schedule was unavailable would stick // around forever. Remove only those roller-managed labels; never touch // anything else (merged/*, trop, semver, ...) and never replace the label set. +// Throws if any stale label could not be confirmed removed. async function removeStaleBackportLabels( octokit: Octokit, prNumber: number, @@ -66,39 +67,62 @@ async function removeStaleBackportLabels( name, }); } catch (e) { - // The label may have been removed out from under us - not fatal. - d(`Failed to remove label ${name} from #${prNumber}: ${e.message}`); + // Already removed out from under us - that still counts as removed. + if (e.status === 404) continue; + throw e; } } } +// Updates the labels on a roll PR. Returns the names of the release branches +// the PR's `target/N-x-y` labels cover - non-empty only for Chromium rolls to +// the main branch whose backport labels were successfully reconciled. async function updateLabels( octokit: Octokit, { rollTarget, electronBranch, targetVersion, previousVersion, prNumber }: RollParams, -) { + isNewPr = false, +): Promise { const d = debug(`roller/${rollTarget.name}:updateLabels()`); let labels: string[] = []; let labelToRemove: string; + let coveredBranches: string[] = []; if (electronBranch.name === MAIN_BRANCH) { let targetBranchLabels: string[] = []; + let reconciled = false; // Chromium rolls to main should be labeled for backport to every supported // release branch whose scheduled Chromium version is >= the rolled version. if (rollTarget === ROLL_TARGETS.chromium) { - const chromiumMajorVersion = Number(targetVersion.split('.')[0]); try { - targetBranchLabels = await getTargetBranchLabels(octokit, chromiumMajorVersion); + const chromiumMajorVersion = Number(targetVersion.split('.')[0]); + if (Number.isNaN(chromiumMajorVersion)) { + throw new Error(`${targetVersion} is not a valid version number`); + } + const trackedBranches = await getBranchesTrackedByMain(octokit, chromiumMajorVersion); + targetBranchLabels = trackedBranches.map((branch) => `target/${branch}`); + // The PR must never carry both no-backport and target/ labels - trop + // rejects that as ambiguous. Only transition to the new backport label + // set once every label conflicting with it is confirmed removed. await removeStaleBackportLabels(octokit, prNumber, targetBranchLabels); + coveredBranches = trackedBranches; + reconciled = true; } catch (e) { - d(`Failed to determine target branch labels: ${e.message} - skipping target labels`); + // Leave the PR's existing backport labels exactly as they were - a + // half-applied transition could strand conflicting labels on the PR. + targetBranchLabels = []; + coveredBranches = []; + d(`Failed to reconcile backport labels: ${e.message} - leaving existing labels unchanged`); } } if (targetBranchLabels.length > 0) { d(`Adding target branch labels: ${targetBranchLabels.join(', ')}`); labels.push(...targetBranchLabels); - } else { + } else if (rollTarget !== ROLL_TARGETS.chromium || reconciled || isNewPr) { + // A reconciled empty set means no release branch qualifies; a brand-new + // PR has no existing backport labels to preserve, so it can take the + // fallback even when reconciliation failed. labels.push(NO_BACKPORT); } } else { @@ -109,7 +133,7 @@ async function updateLabels( if (electronBranch.name === MAIN_BRANCH || rollTarget === ROLL_TARGETS.chromium) { labels.push('semver/patch'); await addLabels(octokit, { prNumber, labels }); - return; + return coveredBranches; } // Check Node.js rolls against previous version and determine the semver label to add. @@ -124,6 +148,8 @@ async function updateLabels( await removeLabel(octokit, { prNumber, name: labelToRemove }); await addLabels(octokit, { prNumber, labels }); + + return coveredBranches; } async function triggerChromiumUpgradeWorkflow(octokit: Octokit) { @@ -136,11 +162,15 @@ async function triggerChromiumUpgradeWorkflow(octokit: Octokit) { } } +// Rolls `rollTarget` on `electronBranch` to `targetVersion`. Returns the names +// of the release branches covered by `target/N-x-y` labels on the roll PR - +// non-empty only for a Chromium roll to the main branch whose PR was +// successfully created or updated and correctly labeled. export async function roll({ rollTarget, electronBranch, targetVersion, -}: RollParams): Promise { +}: RollParams): Promise { const d = debug(`roller/${rollTarget.name}:roll()`); const github = await getOctokit(); @@ -149,6 +179,7 @@ export async function roll({ ); let didRoll = false; + let coveredBranches: string[] = []; // Look for a pre-existing PR that targets this branch to see if we can update that. const existingPrsForBranch = (await github.paginate('GET /repos/:owner/:repo/pulls', { @@ -210,6 +241,19 @@ export async function roll({ if (previousDEPSVersion === newDEPSVersion) { d(`DEPS version unchanged - skipping PR body update`); + // The release schedule moves independently of Chromium - a newly cut + // release branch, a schedule edit, or a label call that failed on a + // previous run must still reconcile the labels on the open roll PR + // even on a day with no DEPS change. + if (rollTarget === ROLL_TARGETS.chromium) { + coveredBranches = await updateLabels(github, { + rollTarget, + electronBranch, + targetVersion, + previousVersion: previousDEPSVersion, + prNumber: pr.number, + }); + } continue; } @@ -220,7 +264,7 @@ export async function roll({ if (!prVersionText || prVersionText.length === 0) { d('Could not find PR version text in existing PR - exiting'); - return; + return coveredBranches; } await github.pulls.update({ @@ -234,7 +278,7 @@ export async function roll({ }), }); - await updateLabels(github, { + coveredBranches = await updateLabels(github, { rollTarget, electronBranch, targetVersion, @@ -288,13 +332,17 @@ export async function roll({ }), }); - await updateLabels(github, { - rollTarget, - electronBranch, - targetVersion, - previousVersion: previousDEPSVersion, - prNumber: newPr.data.number, - }); + coveredBranches = await updateLabels( + github, + { + rollTarget, + electronBranch, + targetVersion, + previousVersion: previousDEPSVersion, + prNumber: newPr.data.number, + }, + true, + ); d(`New PR: ${newPr.data.html_url}`); @@ -304,4 +352,6 @@ export async function roll({ if (didRoll && rollTarget === ROLL_TARGETS.chromium && electronBranch.name === MAIN_BRANCH) { await triggerChromiumUpgradeWorkflow(github); } + + return coveredBranches; } diff --git a/tests/handlers.spec.ts b/tests/handlers.spec.ts index f2e212b..da925e9 100644 --- a/tests/handlers.spec.ts +++ b/tests/handlers.spec.ts @@ -9,14 +9,12 @@ import { getContent } from '../src/utils/github-utils.js'; import { getOctokit } from '../src/utils/octokit.js'; import { roll } from '../src/utils/roll.js'; import { getLatestLTSVersion } from '../src/utils/get-nodejs-lts.js'; -import { getBranchesTrackedByMain } from '../src/utils/get-target-branch-labels.js'; vi.mock('../src/utils/get-chromium-tags.js'); vi.mock('../src/utils/github-utils.js'); vi.mock('../src/utils/octokit.js'); vi.mock('../src/utils/roll.js'); vi.mock('../src/utils/get-nodejs-lts.js'); -vi.mock('../src/utils/get-target-branch-labels.js'); describe('handleChromiumCheck()', () => { let mockOctokit: any; @@ -43,7 +41,7 @@ describe('handleChromiumCheck()', () => { }, }; vi.mocked(getOctokit).mockReturnValue(mockOctokit); - vi.mocked(getBranchesTrackedByMain).mockReset().mockResolvedValue([]); + vi.mocked(roll).mockReset().mockResolvedValue([]); }); describe('release branches', () => { @@ -147,33 +145,43 @@ describe('handleChromiumCheck()', () => { ); }); - it('skips the roll if the branch is tracked by the main branch roll', async () => { - vi.mocked(getChromiumReleases).mockResolvedValue(['1.1.0.0', '2.1.0.0']); - vi.mocked(getBranchesTrackedByMain).mockResolvedValue(['4-0-x']); - - mockOctokit.repos.getBranch.mockReturnValue({ - data: { - name: '4-0-x', - commit: { - sha: '1234', - }, - }, - }); + it('skips a release branch the main roll covers once it has caught up', async () => { + vi.mocked(getChromiumReleases).mockResolvedValue(['1.1.0.0', '1.2.0.0']); + // Main DEPS is fetched first and lags the latest Canary; the branch DEPS + // is already at the version the main roll targets. + vi.mocked(getContent) + .mockResolvedValueOnce({ + content: `${ROLL_TARGETS.chromium.depsKey}':\n '1.0.0.0',`, + sha: '1234', + }) + .mockResolvedValue({ + content: `${ROLL_TARGETS.chromium.depsKey}':\n '1.2.0.0',`, + sha: '1234', + }); + // The main roll PR covers 4-0-x with a target/ label. + vi.mocked(roll).mockResolvedValueOnce(['4-0-x']); - await handleChromiumCheck('4-0-x'); + await handleChromiumCheck(); - // The tracked check uses the major of the latest Canary release, the - // same version the main branch roll targets. - expect(getBranchesTrackedByMain).toHaveBeenCalledWith(mockOctokit, 2); - expect(roll).not.toHaveBeenCalled(); + expect(roll).toHaveBeenCalledTimes(1); + expect(roll).toHaveBeenCalledWith( + expect.objectContaining({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: expect.objectContaining({ name: MAIN_BRANCH }), + targetVersion: '1.2.0.0', + }), + ); }); - it('rolls independently if the branch is not tracked by the main branch roll', async () => { + it('rolls a covered branch independently while it lags the main roll target', async () => { vi.mocked(getChromiumReleases).mockResolvedValue(['1.1.0.0', '1.2.0.0']); - vi.mocked(getBranchesTrackedByMain).mockResolvedValue(['5-0-x']); + // Both main and the branch are on 1.0.0.0 - the branch's backport has + // not landed yet, so it must be able to pull itself forward. + vi.mocked(roll).mockResolvedValueOnce(['4-0-x']); await handleChromiumCheck(); + expect(roll).toHaveBeenCalledTimes(2); expect(roll).toHaveBeenCalledWith( expect.objectContaining({ rollTarget: ROLL_TARGETS.chromium, @@ -183,12 +191,41 @@ describe('handleChromiumCheck()', () => { ); }); - it('rolls independently if the tracked branches cannot be determined', async () => { + it('does not skip release branches if the main roll fails', async () => { vi.mocked(getChromiumReleases).mockResolvedValue(['1.1.0.0', '1.2.0.0']); - vi.mocked(getBranchesTrackedByMain).mockRejectedValue(new Error('schedule unavailable')); + vi.mocked(roll).mockImplementationOnce(() => { + throw new Error('main roll failed'); + }); - await handleChromiumCheck(); + await expect(handleChromiumCheck()).rejects.toThrowError( + 'One or more upgrade checks failed - see logs for more details', + ); + + expect(roll).toHaveBeenCalledTimes(2); + expect(roll).toHaveBeenLastCalledWith( + expect.objectContaining({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: expect.objectContaining({ name: '4-0-x' }), + targetVersion: '1.2.0.0', + }), + ); + }); + + it('never skips an explicitly targeted release branch roll', async () => { + vi.mocked(getChromiumReleases).mockResolvedValue(['1.1.0.0', '1.2.0.0']); + + mockOctokit.repos.getBranch.mockReturnValue({ + data: { + name: '4-0-x', + commit: { + sha: '1234', + }, + }, + }); + + await handleChromiumCheck('4-0-x'); + expect(roll).toHaveBeenCalledTimes(1); expect(roll).toHaveBeenCalledWith( expect.objectContaining({ rollTarget: ROLL_TARGETS.chromium, diff --git a/tests/utils/get-target-branch-labels.spec.ts b/tests/utils/get-target-branch-labels.spec.ts index 4661070..824b928 100644 --- a/tests/utils/get-target-branch-labels.spec.ts +++ b/tests/utils/get-target-branch-labels.spec.ts @@ -7,10 +7,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ELECTRON_RELEASE_SCHEDULE_URL, getBranchesTrackedByMain, - getTargetBranchLabels, } from '../../src/utils/get-target-branch-labels.js'; -describe('getTargetBranchLabels', () => { +describe('getBranchesTrackedByMain', () => { let mockOctokit: any; const fixture = fs.readFileSync( @@ -36,59 +35,53 @@ describe('getTargetBranchLabels', () => { }; }); - it('returns labels for supported branches scheduled for >= the rolled major', async () => { + it('returns supported branches scheduled for >= the rolled major', async () => { nock(url.origin).get(url.pathname).reply(200, fixture); // 45-x-y ships Chromium 156, 44-x-y ships 152 - rolling to 154 only // targets 45-x-y. - await expect(getTargetBranchLabels(mockOctokit, 154)).resolves.toEqual(['target/45-x-y']); + await expect(getBranchesTrackedByMain(mockOctokit, 154)).resolves.toEqual(['45-x-y']); }); it('includes a branch whose scheduled major equals the rolled major', async () => { nock(url.origin).get(url.pathname).reply(200, fixture); - await expect(getTargetBranchLabels(mockOctokit, 156)).resolves.toEqual(['target/45-x-y']); + await expect(getBranchesTrackedByMain(mockOctokit, 156)).resolves.toEqual(['45-x-y']); }); - it('returns labels for every matching supported branch', async () => { + it('returns every matching supported branch', async () => { nock(url.origin).get(url.pathname).reply(200, fixture); - await expect(getTargetBranchLabels(mockOctokit, 150)).resolves.toEqual([ - 'target/43-x-y', - 'target/44-x-y', - 'target/45-x-y', + await expect(getBranchesTrackedByMain(mockOctokit, 150)).resolves.toEqual([ + '43-x-y', + '44-x-y', + '45-x-y', ]); }); - it('returns no labels if the rolled major is newer than every scheduled version', async () => { + it('returns no branches if the rolled major is newer than every scheduled version', async () => { nock(url.origin).get(url.pathname).reply(200, fixture); - await expect(getTargetBranchLabels(mockOctokit, 157)).resolves.toEqual([]); + await expect(getBranchesTrackedByMain(mockOctokit, 157)).resolves.toEqual([]); }); it('ignores schedule entries for unsupported branches', async () => { nock(url.origin).get(url.pathname).reply(200, fixture); // 41-x-y ships Chromium 146 but is no longer supported, and main is not a - // release branch - neither should produce a label. - await expect(getTargetBranchLabels(mockOctokit, 140)).resolves.toEqual([ - 'target/42-x-y', - 'target/43-x-y', - 'target/44-x-y', - 'target/45-x-y', + // release branch - neither should be returned. + await expect(getBranchesTrackedByMain(mockOctokit, 140)).resolves.toEqual([ + '42-x-y', + '43-x-y', + '44-x-y', + '45-x-y', ]); }); - it('returns tracked branch names via getBranchesTrackedByMain', async () => { - nock(url.origin).get(url.pathname).reply(200, fixture); - - await expect(getBranchesTrackedByMain(mockOctokit, 154)).resolves.toEqual(['45-x-y']); - }); - it('throws if the schedule fetch fails', async () => { nock(url.origin).get(url.pathname).reply(500); - await expect(getTargetBranchLabels(mockOctokit, 154)).rejects.toThrowError( + await expect(getBranchesTrackedByMain(mockOctokit, 154)).rejects.toThrowError( 'Failed to fetch Electron release schedule: 500', ); }); diff --git a/tests/utils/roll.spec.ts b/tests/utils/roll.spec.ts index d459964..d6422fe 100644 --- a/tests/utils/roll.spec.ts +++ b/tests/utils/roll.spec.ts @@ -10,7 +10,7 @@ import { REPOS, ROLL_TARGETS, } from '../../src/constants.js'; -import { getTargetBranchLabels } from '../../src/utils/get-target-branch-labels.js'; +import { getBranchesTrackedByMain } from '../../src/utils/get-target-branch-labels.js'; import { updateDepsFile } from '../../src/utils/update-deps.js'; vi.mock('../../src/utils/octokit.js'); @@ -63,7 +63,7 @@ describe('roll()', () => { previousDEPSVersion: 'v4.0.0', newDEPSVersion: 'v10.0.0', }); - vi.mocked(getTargetBranchLabels).mockReset().mockResolvedValue([]); + vi.mocked(getBranchesTrackedByMain).mockReset().mockResolvedValue([]); }); it('takes no action if versions are identical', async () => { @@ -367,7 +367,7 @@ describe('roll()', () => { electronBranch: mainBranch, targetVersion: '120.0.0.0', }), - ).resolves.toBeUndefined(); + ).resolves.toEqual([]); expect(mockOctokit.pulls.create).toHaveBeenCalled(); }); @@ -375,18 +375,32 @@ describe('roll()', () => { describe('backport labels', () => { const mainBranch = { ...branch, name: MAIN_BRANCH }; + const existingMainChromiumPr = { + user: { login: 'electron-roller[bot]' }, + title: `chore: bump ${ROLL_TARGETS.chromium.name} to bar`, + number: 1, + head: { + ref: `roller/${ROLL_TARGETS.chromium.name}/${MAIN_BRANCH}`, + repo: { full_name: `${REPOS.electron.owner}/${REPOS.electron.repo}` }, + }, + body: 'Original-Version: 119.0.0.0', + labels: [], + created_at: new Date().toISOString(), + }; it('adds target branch labels instead of no-backport for chromium rolls on main', async () => { mockOctokit.paginate.mockReturnValue([]); - vi.mocked(getTargetBranchLabels).mockResolvedValue(['target/44-x-y', 'target/45-x-y']); + vi.mocked(getBranchesTrackedByMain).mockResolvedValue(['44-x-y', '45-x-y']); - await roll({ - rollTarget: ROLL_TARGETS.chromium, - electronBranch: mainBranch, - targetVersion: '152.0.0.0', - }); + await expect( + roll({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: mainBranch, + targetVersion: '152.0.0.0', + }), + ).resolves.toEqual(['44-x-y', '45-x-y']); - expect(getTargetBranchLabels).toHaveBeenCalledWith(mockOctokit, 152); + expect(getBranchesTrackedByMain).toHaveBeenCalledWith(mockOctokit, 152); expect(mockOctokit.issues.addLabels).toHaveBeenCalledWith( expect.objectContaining({ labels: ['target/44-x-y', 'target/45-x-y', 'semver/patch'], @@ -396,14 +410,36 @@ describe('roll()', () => { it('adds no-backport for chromium rolls on main when no target branch applies', async () => { mockOctokit.paginate.mockReturnValue([]); - vi.mocked(getTargetBranchLabels).mockResolvedValue([]); + vi.mocked(getBranchesTrackedByMain).mockResolvedValue([]); - await roll({ - rollTarget: ROLL_TARGETS.chromium, - electronBranch: mainBranch, - targetVersion: '160.0.0.0', - }); + await expect( + roll({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: mainBranch, + targetVersion: '160.0.0.0', + }), + ).resolves.toEqual([]); + + expect(mockOctokit.issues.addLabels).toHaveBeenCalledWith( + expect.objectContaining({ + labels: [NO_BACKPORT, 'semver/patch'], + }), + ); + }); + it('falls back to no-backport on a new PR if target branches cannot be determined', async () => { + mockOctokit.paginate.mockReturnValue([]); + vi.mocked(getBranchesTrackedByMain).mockRejectedValue(new Error('schedule unavailable')); + + await expect( + roll({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: mainBranch, + targetVersion: '152.0.0.0', + }), + ).resolves.toEqual([]); + + expect(mockOctokit.pulls.create).toHaveBeenCalled(); expect(mockOctokit.issues.addLabels).toHaveBeenCalledWith( expect.objectContaining({ labels: [NO_BACKPORT, 'semver/patch'], @@ -411,17 +447,42 @@ describe('roll()', () => { ); }); - it('falls back to no-backport if target branch labels cannot be determined', async () => { + it('leaves the labels of an existing PR unchanged if target branches cannot be determined', async () => { + mockOctokit.paginate.mockReturnValue([existingMainChromiumPr]); + mockOctokit.issues.listLabelsOnIssue.mockReturnValue({ + data: [{ name: 'target/45-x-y' }], + }); + vi.mocked(getBranchesTrackedByMain).mockRejectedValue(new Error('schedule unavailable')); + + await expect( + roll({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: mainBranch, + targetVersion: '154.0.0.0', + }), + ).resolves.toEqual([]); + + // The PR keeps its existing backport labels: nothing is removed, and + // neither no-backport nor target labels are added on top of them. + expect(mockOctokit.issues.removeLabel).not.toHaveBeenCalled(); + expect(mockOctokit.issues.addLabels).toHaveBeenCalledWith( + expect.objectContaining({ + labels: ['semver/patch'], + }), + ); + }); + + it('falls back without fetching the schedule for an invalid target version', async () => { mockOctokit.paginate.mockReturnValue([]); - vi.mocked(getTargetBranchLabels).mockRejectedValue(new Error('schedule unavailable')); await roll({ rollTarget: ROLL_TARGETS.chromium, electronBranch: mainBranch, - targetVersion: '152.0.0.0', + targetVersion: 'not-a-version', }); - expect(mockOctokit.pulls.create).toHaveBeenCalled(); + expect(getBranchesTrackedByMain).not.toHaveBeenCalled(); + expect(mockOctokit.issues.removeLabel).not.toHaveBeenCalled(); expect(mockOctokit.issues.addLabels).toHaveBeenCalledWith( expect.objectContaining({ labels: [NO_BACKPORT, 'semver/patch'], @@ -435,13 +496,14 @@ describe('roll()', () => { data: [ { name: NO_BACKPORT }, { name: 'target/43-x-y' }, + { name: 'target/4-0-x' }, { name: 'target/45-x-y' }, { name: 'merged/44-x-y' }, { name: 'in-flight/45-x-y' }, { name: 'semver/patch' }, ], }); - vi.mocked(getTargetBranchLabels).mockResolvedValue(['target/45-x-y']); + vi.mocked(getBranchesTrackedByMain).mockResolvedValue(['45-x-y']); await roll({ rollTarget: ROLL_TARGETS.chromium, @@ -449,12 +511,16 @@ describe('roll()', () => { targetVersion: '154.0.0.0', }); - // target/43-x-y no longer qualifies and no-backport conflicts with the - // target labels that do - both are removed, nothing else is touched. - expect(mockOctokit.issues.removeLabel).toHaveBeenCalledTimes(2); + // target/43-x-y and target/4-0-x no longer qualify and no-backport + // conflicts with the target labels that do - all three are removed, + // nothing else is touched. + expect(mockOctokit.issues.removeLabel).toHaveBeenCalledTimes(3); expect(mockOctokit.issues.removeLabel).toHaveBeenCalledWith( expect.objectContaining({ name: 'target/43-x-y' }), ); + expect(mockOctokit.issues.removeLabel).toHaveBeenCalledWith( + expect.objectContaining({ name: 'target/4-0-x' }), + ); expect(mockOctokit.issues.removeLabel).toHaveBeenCalledWith( expect.objectContaining({ name: NO_BACKPORT }), ); @@ -465,7 +531,7 @@ describe('roll()', () => { mockOctokit.issues.listLabelsOnIssue.mockReturnValue({ data: [{ name: NO_BACKPORT }, { name: 'target/45-x-y' }], }); - vi.mocked(getTargetBranchLabels).mockResolvedValue([]); + vi.mocked(getBranchesTrackedByMain).mockResolvedValue([]); await roll({ rollTarget: ROLL_TARGETS.chromium, @@ -479,29 +545,41 @@ describe('roll()', () => { ); }); - it('does not remove any labels if target branch labels cannot be determined', async () => { - mockOctokit.paginate.mockReturnValue([]); + it('does not transition backport labels if a stale label removal fails', async () => { + mockOctokit.paginate.mockReturnValue([existingMainChromiumPr]); mockOctokit.issues.listLabelsOnIssue.mockReturnValue({ - data: [{ name: 'target/45-x-y' }], + data: [{ name: 'target/43-x-y' }], }); - vi.mocked(getTargetBranchLabels).mockRejectedValue(new Error('schedule unavailable')); + mockOctokit.issues.removeLabel.mockRejectedValue(new Error('server error')); + vi.mocked(getBranchesTrackedByMain).mockResolvedValue(['45-x-y']); - await roll({ - rollTarget: ROLL_TARGETS.chromium, - electronBranch: mainBranch, - targetVersion: '154.0.0.0', - }); + // The stale target/43-x-y could not be confirmed removed, so the new + // label set must not be applied on top of it - and the failure must not + // fail the roll. + await expect( + roll({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: mainBranch, + targetVersion: '154.0.0.0', + }), + ).resolves.toEqual([]); - expect(mockOctokit.issues.removeLabel).not.toHaveBeenCalled(); + expect(mockOctokit.issues.addLabels).toHaveBeenCalledWith( + expect.objectContaining({ + labels: ['semver/patch'], + }), + ); }); - it('ignores failures to remove a stale label', async () => { + it('treats an already-removed stale label as removed', async () => { mockOctokit.paginate.mockReturnValue([]); mockOctokit.issues.listLabelsOnIssue.mockReturnValue({ - data: [{ name: 'target/43-x-y' }], + data: [{ name: NO_BACKPORT }], }); - mockOctokit.issues.removeLabel.mockRejectedValue(new Error('Not Found')); - vi.mocked(getTargetBranchLabels).mockResolvedValue(['target/45-x-y']); + mockOctokit.issues.removeLabel.mockRejectedValue( + Object.assign(new Error('Not Found'), { status: 404 }), + ); + vi.mocked(getBranchesTrackedByMain).mockResolvedValue(['45-x-y']); await expect( roll({ @@ -509,7 +587,7 @@ describe('roll()', () => { electronBranch: mainBranch, targetVersion: '154.0.0.0', }), - ).resolves.toBeUndefined(); + ).resolves.toEqual(['45-x-y']); expect(mockOctokit.issues.addLabels).toHaveBeenCalledWith( expect.objectContaining({ @@ -518,6 +596,35 @@ describe('roll()', () => { ); }); + it('reconciles labels on an existing PR even when the DEPS version is unchanged', async () => { + mockOctokit.paginate.mockReturnValue([existingMainChromiumPr]); + mockOctokit.issues.listLabelsOnIssue.mockReturnValue({ + data: [{ name: 'target/45-x-y' }], + }); + vi.mocked(updateDepsFile).mockResolvedValue({ + previousDEPSVersion: '154.0.0.0', + newDEPSVersion: '154.0.0.0', + }); + // A newly cut release branch appears in the schedule without any DEPS + // change - the open roll PR must still gain its target label. + vi.mocked(getBranchesTrackedByMain).mockResolvedValue(['45-x-y', '46-x-y']); + + await expect( + roll({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: mainBranch, + targetVersion: '154.0.0.0', + }), + ).resolves.toEqual(['45-x-y', '46-x-y']); + + expect(mockOctokit.pulls.update).not.toHaveBeenCalled(); + expect(mockOctokit.issues.addLabels).toHaveBeenCalledWith( + expect.objectContaining({ + labels: ['target/45-x-y', 'target/46-x-y', 'semver/patch'], + }), + ); + }); + it('adds no-backport for node rolls on main without checking the schedule', async () => { mockOctokit.paginate.mockReturnValue([]); @@ -527,7 +634,7 @@ describe('roll()', () => { targetVersion: 'v10.0.0', }); - expect(getTargetBranchLabels).not.toHaveBeenCalled(); + expect(getBranchesTrackedByMain).not.toHaveBeenCalled(); expect(mockOctokit.issues.addLabels).toHaveBeenCalledWith( expect.objectContaining({ labels: [NO_BACKPORT, 'semver/patch'], @@ -544,7 +651,7 @@ describe('roll()', () => { targetVersion: '152.0.0.0', }); - expect(getTargetBranchLabels).not.toHaveBeenCalled(); + expect(getBranchesTrackedByMain).not.toHaveBeenCalled(); expect(mockOctokit.issues.addLabels).toHaveBeenCalledWith( expect.objectContaining({ labels: [BACKPORT_CHECK_SKIP, 'semver/patch'], From 5700933f1fc3ec8d4956a46ac88f3fd7b15dc3ad Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 17:07:03 +0000 Subject: [PATCH 4/4] fix: compare release branch DEPS against what main has landed The skip condition compared the branch's DEPS Chromium version against the version the open main roll PR targets, which a backport-fed branch can never reach - it is at most level with main's landed version - so the skip was unreachable in steady state and every tracked branch still rolled independently alongside its backport. Compare against main's landed DEPS version instead: level with main means take the backport, behind main means roll independently and self-heal. MainRollResult now carries currentVersion (the version main is on) rather than the roll target, and the handler tests cover the kept-pace skip, the everyday case of a branch level with main while the roll PR is ahead of both, and a lagging branch rolling itself forward. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011v4QNjFUH5wLUj8eMUigbB --- src/chromium-handler.ts | 19 ++++++++------- tests/handlers.spec.ts | 52 +++++++++++++++++++++++++++++------------ 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/src/chromium-handler.ts b/src/chromium-handler.ts index 2c286ad..1db9489 100644 --- a/src/chromium-handler.ts +++ b/src/chromium-handler.ts @@ -12,10 +12,11 @@ import { Octokit } from '@octokit/rest'; type BranchItem = ReposGetBranchResponseItem | ReposListBranchesResponseItem; -// The outcome of a main branch roll: the Chromium version it targets and the -// release branches its roll PR covers with `target/N-x-y` labels. +// The outcome of a main branch roll: the Chromium version main is currently on +// (its landed DEPS version, not the version the open roll PR targets) and the +// release branches the roll PR covers with `target/N-x-y` labels. interface MainRollResult { - targetVersion: string; + currentVersion: string; coveredBranches: string[]; } @@ -49,15 +50,15 @@ async function rollReleaseBranch( // A branch covered by a target/ label on the main roll PR receives the main // roll as a backport instead of an independent roll - but only skip it while - // it has actually caught up to the main roll's target, so a branch whose - // backports stall can pull itself forward with its own roll. Explicitly - // targeted rolls pass no main roll info and are never suppressed. + // it has kept pace with the Chromium version main has actually landed, so a + // branch whose backports stall can pull itself forward with its own roll. + // Explicitly targeted rolls pass no main roll info and are never suppressed. if ( mainRoll?.coveredBranches.includes(branch.name) && - compareChromiumVersions(chromiumVersion, mainRoll.targetVersion) >= 0 + compareChromiumVersions(chromiumVersion, mainRoll.currentVersion) >= 0 ) { d( - `${branch.name} is covered by the ${MAIN_BRANCH} roll to ${mainRoll.targetVersion} and has caught up - skipping independent roll`, + `${branch.name} is covered by the ${MAIN_BRANCH} roll and has kept pace with ${MAIN_BRANCH} at ${mainRoll.currentVersion} - skipping independent roll`, ); return; } @@ -132,7 +133,7 @@ async function rollMainBranch(github: Octokit): Promise { electronBranch: mainBranch, targetVersion: latestUpstreamVersion, }); - return { targetVersion: latestUpstreamVersion, coveredBranches: coveredBranches ?? [] }; + return { currentVersion, coveredBranches: coveredBranches ?? [] }; } catch (e) { throw new Error(`Failed to roll ${MAIN_BRANCH} to ${latestUpstreamVersion}: ${e.message}`); } diff --git a/tests/handlers.spec.ts b/tests/handlers.spec.ts index da925e9..4eb080f 100644 --- a/tests/handlers.spec.ts +++ b/tests/handlers.spec.ts @@ -145,19 +145,10 @@ describe('handleChromiumCheck()', () => { ); }); - it('skips a release branch the main roll covers once it has caught up', async () => { + it('skips a release branch the main roll covers when it has kept pace with main', async () => { vi.mocked(getChromiumReleases).mockResolvedValue(['1.1.0.0', '1.2.0.0']); - // Main DEPS is fetched first and lags the latest Canary; the branch DEPS - // is already at the version the main roll targets. - vi.mocked(getContent) - .mockResolvedValueOnce({ - content: `${ROLL_TARGETS.chromium.depsKey}':\n '1.0.0.0',`, - sha: '1234', - }) - .mockResolvedValue({ - content: `${ROLL_TARGETS.chromium.depsKey}':\n '1.2.0.0',`, - sha: '1234', - }); + // Main and the branch are level on 1.0.0.0 (the branch's backports have + // kept pace with what main has landed) while main rolls to 1.2.0.0. // The main roll PR covers 4-0-x with a target/ label. vi.mocked(roll).mockResolvedValueOnce(['4-0-x']); @@ -173,10 +164,41 @@ describe('handleChromiumCheck()', () => { ); }); - it('rolls a covered branch independently while it lags the main roll target', async () => { + it('skips a covered branch level with main while the main roll PR is ahead of both', async () => { + // The everyday path: main has landed 1.1.0.0, its open roll PR targets + // 1.2.0.0, and the branch's backports have kept it level with main. vi.mocked(getChromiumReleases).mockResolvedValue(['1.1.0.0', '1.2.0.0']); - // Both main and the branch are on 1.0.0.0 - the branch's backport has - // not landed yet, so it must be able to pull itself forward. + vi.mocked(getContent).mockResolvedValue({ + content: `${ROLL_TARGETS.chromium.depsKey}':\n '1.1.0.0',`, + sha: '1234', + }); + vi.mocked(roll).mockResolvedValueOnce(['4-0-x']); + + await handleChromiumCheck(); + + expect(roll).toHaveBeenCalledTimes(1); + expect(roll).toHaveBeenCalledWith( + expect.objectContaining({ + rollTarget: ROLL_TARGETS.chromium, + electronBranch: expect.objectContaining({ name: MAIN_BRANCH }), + targetVersion: '1.2.0.0', + }), + ); + }); + + it('rolls a covered branch independently while it lags what main has landed', async () => { + vi.mocked(getChromiumReleases).mockResolvedValue(['1.1.0.0', '1.2.0.0']); + // Main has landed 1.1.0.0 but the branch is still on 1.0.0.0 - its + // backport stalled, so it must be able to pull itself forward. + vi.mocked(getContent) + .mockResolvedValueOnce({ + content: `${ROLL_TARGETS.chromium.depsKey}':\n '1.1.0.0',`, + sha: '1234', + }) + .mockResolvedValue({ + content: `${ROLL_TARGETS.chromium.depsKey}':\n '1.0.0.0',`, + sha: '1234', + }); vi.mocked(roll).mockResolvedValueOnce(['4-0-x']); await handleChromiumCheck();