Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 42 additions & 11 deletions src/chromium-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,19 @@ import { Octokit } from '@octokit/rest';

type BranchItem = ReposGetBranchResponseItem | ReposListBranchesResponseItem;

async function rollReleaseBranch(github: Octokit, branch: BranchItem) {
// 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 {
currentVersion: string;
coveredBranches: string[];
}

async function rollReleaseBranch(
github: Octokit,
branch: BranchItem,
mainRoll?: MainRollResult | null,
) {
const d = debug(`roller/chromium:rollReleaseBranch('${branch.name}')`);

d(`Fetching DEPS for ${branch.name}`);
Expand All @@ -36,6 +48,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 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.currentVersion) >= 0
) {
d(
`${branch.name} is covered by the ${MAIN_BRANCH} roll and has kept pace with ${MAIN_BRANCH} at ${mainRoll.currentVersion} - skipping independent roll`,
);
Comment thread
claude[bot] marked this conversation as resolved.
return;
}

d(`Computing latest upstream version for Chromium ${chromiumMajorVersion}`);
const chromiumReleases = await getChromiumReleases({ milestone: chromiumMajorVersion });
const latestUpstreamVersion = chromiumReleases[chromiumReleases.length - 1];
Expand All @@ -61,7 +88,7 @@ async function rollReleaseBranch(github: Octokit, branch: BranchItem) {
}
}

async function rollMainBranch(github: Octokit) {
async function rollMainBranch(github: Octokit): Promise<MainRollResult | null> {
const d = debug('roller/chromium:rollMainBranch()');

d(`Fetching ${MAIN_BRANCH} branch for electron/electron`);
Expand Down Expand Up @@ -101,17 +128,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 { currentVersion, 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<void> {
Expand Down Expand Up @@ -154,21 +182,24 @@ export async function handleChromiumCheck(target?: string): Promise<void> {
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) {
Expand Down
47 changes: 47 additions & 0 deletions src/utils/get-target-branch-labels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
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 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 should receive them as
// backports (via `target/N-x-y` labels) rather than independent rolls.
export async function getBranchesTrackedByMain(
octokit: Octokit,
chromiumMajorVersion: number,
): Promise<string[]> {
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,
}),
);
Comment on lines +32 to +37

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should move away from this pattern , as it is quite slow (as in 20+ seconds, see electron/sudowoodo#419). There was a time historically when protected: true filtered us down to release branches for free as they were the only ones protected, but with our modern branch protection rulesets on e/e all branches count as protected, so this just paginates all branches (which at the time of this writing, is 478).

I'll open a follow up PR for speeding this up, as the changes in this PR add another pagination to the main code paths so we're going to be losing nearly a minute of wall time just paginating branches from e/e.

const supported = getSupportedBranches(branches);

return schedule
.filter(
(entry) =>
supported.includes(entry.branch) && Number(entry.chromiumVersion) >= chromiumMajorVersion,
)
.map((entry) => entry.branch)
.sort();
}
146 changes: 133 additions & 13 deletions src/utils/roll.ts
Comment thread
claude[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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 { getBranchesTrackedByMain } from './get-target-branch-labels.js';

interface RollParams {
rollTarget: RollTarget;
Expand All @@ -26,20 +27,113 @@ interface RollParams {
previousVersion?: string;
}

const TARGET_BRANCH_LABEL_PATTERN = /^target\/\d+-(?:\d+-x|x-y)$/;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const TARGET_BRANCH_LABEL_PATTERN = /^target\/\d+-(?:\d+-x|x-y)$/;
const TARGET_BRANCH_LABEL_PATTERN = /^target\/\d+-x-y$/;

This was supporting a very old branch naming pattern (<major>-x) that we never use for target branch labels.


// 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.
// Throws if any stale label could not be confirmed removed.
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) {
// 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<string[]> {
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) {
try {
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) {
// 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`);
}
}

labels.push(electronBranch.name === MAIN_BRANCH ? NO_BACKPORT : BACKPORT_CHECK_SKIP);
if (targetBranchLabels.length > 0) {
d(`Adding target branch labels: ${targetBranchLabels.join(', ')}`);
labels.push(...targetBranchLabels);
} 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);
}
Comment thread
claude[bot] marked this conversation as resolved.
} 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) {
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.
Expand All @@ -54,6 +148,8 @@ async function updateLabels(

await removeLabel(octokit, { prNumber, name: labelToRemove });
await addLabels(octokit, { prNumber, labels });

return coveredBranches;
}

async function triggerChromiumUpgradeWorkflow(octokit: Octokit) {
Expand All @@ -66,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<void> {
}: RollParams): Promise<string[]> {
const d = debug(`roller/${rollTarget.name}:roll()`);
const github = await getOctokit();

Expand All @@ -79,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', {
Expand Down Expand Up @@ -140,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;
}

Expand All @@ -150,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({
Expand All @@ -164,7 +278,7 @@ export async function roll({
}),
});

await updateLabels(github, {
coveredBranches = await updateLabels(github, {
rollTarget,
electronBranch,
targetVersion,
Expand Down Expand Up @@ -218,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}`);

Expand All @@ -234,4 +352,6 @@ export async function roll({
if (didRoll && rollTarget === ROLL_TARGETS.chromium && electronBranch.name === MAIN_BRANCH) {
await triggerChromiumUpgradeWorkflow(github);
}

return coveredBranches;
}
Loading