Skip to content

Commit 75dc08a

Browse files
build: roll windows arc images automatically
1 parent 7e33eff commit 75dc08a

7 files changed

+288
-0
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22
lib
33
node_modules
44
coverage
5+
.envrc

src/constants.ts

+9
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ export const REPOS = {
33
owner: 'electron',
44
repo: 'electron',
55
},
6+
electronInfra: {
7+
owner: 'electron',
8+
repo: 'infra',
9+
},
610
node: {
711
owner: 'nodejs',
812
repo: 'node',
@@ -41,6 +45,11 @@ export const ORB_TARGETS = [
4145
export const BACKPORT_CHECK_SKIP = 'backport-check-skip';
4246
export const NO_BACKPORT = 'no-backport';
4347

48+
export const ARC_RUNNER_ENVIRONMENTS = {
49+
prod: 'argo/arc-cluster/runner-sets/runners.yaml',
50+
};
51+
export const WINDOWS_DOCKER_IMAGE_NAME = 'windows-actions-runner';
52+
4453
export interface Commit {
4554
sha: string;
4655
message: string;

src/utils/arc-image.ts

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Octokit } from '@octokit/rest';
2+
import { MAIN_BRANCH, REPOS } from '../constants';
3+
import { getOctokit } from './octokit';
4+
5+
const WINDOWS_IMAGE_REGEX =
6+
/electronarc\.azurecr\.io\/win-actions-runner:main-[a-f0-9]{7}@sha256:[a-f0-9]{64}/;
7+
const LINUX_IMAGE_REGEX =
8+
/ghcr\.io\/actions\/actions-runner:[0-9]+\.[0-9]+\.[0-9]+@sha256:[a-f0-9]{64}/;
9+
10+
export async function getFileContent(octokit: Octokit, filePath: string, ref = MAIN_BRANCH) {
11+
const { data } = await octokit.repos.getContent({
12+
...REPOS.electronInfra,
13+
path: filePath,
14+
ref,
15+
});
16+
if ('content' in data) {
17+
return { raw: Buffer.from(data.content, 'base64').toString('utf8'), sha: data.sha };
18+
}
19+
throw 'wat';
20+
}
21+
22+
export const currentWindowsImage = (content: string) => {
23+
return content.match(WINDOWS_IMAGE_REGEX)?.[0];
24+
};
25+
26+
export const didFileChangeBetweenShas = async (file: string, sha1: string, sha2: string) => {
27+
const octokit = await getOctokit();
28+
const [start, end] = await Promise.all([
29+
await getFileContent(octokit, file, sha1),
30+
await getFileContent(octokit, file, sha2),
31+
]);
32+
33+
return start.raw.trim() !== end.raw.trim();
34+
};

src/utils/pr-text.ts

+7
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,10 @@ Original-Version: ${previousVersion}
5757
Notes: Updated Node.js to ${newVersion}.`,
5858
};
5959
}
60+
61+
export function getInfraPRText(bumpSubject: string, newShortVersion: string) {
62+
return {
63+
title: `chore: bump ${bumpSubject} to ${newShortVersion}`,
64+
body: `Updating ${bumpSubject} to \`${newShortVersion}\``,
65+
};
66+
}

src/utils/roll-infra.ts

+136
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import * as debug from 'debug';
2+
3+
import { MAIN_BRANCH, REPOS } from '../constants';
4+
import { getOctokit } from './octokit';
5+
import { PullsListResponseItem } from '../types';
6+
import { Octokit } from '@octokit/rest';
7+
import { getInfraPRText } from './pr-text';
8+
import { getFileContent } from './arc-image';
9+
10+
export async function rollInfra(
11+
rollKey: string,
12+
bumpSubject: string,
13+
newShortVersion: string,
14+
filePath: string,
15+
newContent: string,
16+
): Promise<any> {
17+
const d = debug(`roller/infra/${rollKey}:rollInfra()`);
18+
const octokit = await getOctokit();
19+
20+
const branchName = `roller/infra/${rollKey}`;
21+
const shortRef = `heads/${branchName}`;
22+
const ref = `refs/${shortRef}`;
23+
24+
const { owner, repo } = REPOS.electronInfra;
25+
26+
// Look for a pre-existing PR that targets this branch to see if we can update that.
27+
let existingPrsForBranch: PullsListResponseItem[] = [];
28+
try {
29+
existingPrsForBranch = (await octokit.paginate('GET /repos/:owner/:repo/pulls', {
30+
head: branchName,
31+
owner,
32+
repo,
33+
state: 'open',
34+
})) as PullsListResponseItem[];
35+
} catch {}
36+
37+
const prs = existingPrsForBranch.filter((pr) =>
38+
pr.title.startsWith(`build: bump ${bumpSubject}`),
39+
);
40+
41+
const defaultBranchHeadSha = (
42+
await octokit.repos.getBranch({
43+
owner,
44+
repo,
45+
branch: MAIN_BRANCH,
46+
})
47+
).data.commit.sha;
48+
49+
const { raw: currentContent, sha: currentSha } = await getFileContent(octokit, filePath);
50+
51+
if (prs.length) {
52+
// Update existing PR(s)
53+
for (const pr of prs) {
54+
d(`Found existing PR: #${pr.number} opened by ${pr.user.login}`);
55+
56+
// Check to see if automatic infra roll has been temporarily disabled
57+
const hasPauseLabel = pr.labels.some((label) => label.name === 'roller/pause');
58+
if (hasPauseLabel) {
59+
d(`Automatic updates have been paused for #${pr.number}, skipping infra roll.`);
60+
continue;
61+
}
62+
63+
d(`Attempting infra update for #${pr.number}`);
64+
if (currentContent.trim() !== newContent.trim()) {
65+
await updateFile(
66+
octokit,
67+
bumpSubject,
68+
newShortVersion,
69+
filePath,
70+
newContent,
71+
branchName,
72+
currentSha,
73+
);
74+
}
75+
76+
await octokit.pulls.update({
77+
owner,
78+
repo,
79+
pull_number: pr.number,
80+
...getInfraPRText(bumpSubject, newShortVersion),
81+
});
82+
}
83+
} else {
84+
try {
85+
d(`roll triggered for ${bumpSubject}=${newShortVersion}`);
86+
87+
try {
88+
await octokit.git.getRef({ owner, repo, ref: shortRef });
89+
d(`Ref ${ref} already exists`);
90+
} catch {
91+
d(`Creating ref=${ref} at sha=${defaultBranchHeadSha}`);
92+
await octokit.git.createRef({ owner, repo, ref, sha: defaultBranchHeadSha });
93+
}
94+
95+
await updateFile(
96+
octokit,
97+
bumpSubject,
98+
newShortVersion,
99+
filePath,
100+
newContent,
101+
branchName,
102+
currentSha,
103+
);
104+
105+
d(`Raising a PR for ${branchName} to ${repo}`);
106+
await octokit.pulls.create({
107+
owner,
108+
repo,
109+
base: MAIN_BRANCH,
110+
head: `${owner}:${branchName}`,
111+
...getInfraPRText(bumpSubject, newShortVersion),
112+
});
113+
} catch (e) {
114+
d(`Error rolling ${owner}/${repo} to ${newShortVersion}`, e);
115+
}
116+
}
117+
}
118+
119+
async function updateFile(
120+
octokit: Octokit,
121+
bumpSubject: string,
122+
newShortVersion: string,
123+
filePath: string,
124+
newContent: string,
125+
branchName: string,
126+
currentSha: string,
127+
) {
128+
await octokit.repos.createOrUpdateFileContents({
129+
...REPOS.electronInfra,
130+
path: filePath,
131+
message: `chore: bump ${bumpSubject} in ${filePath} to ${newShortVersion}`,
132+
content: Buffer.from(newContent).toString('base64'),
133+
branch: branchName,
134+
sha: currentSha,
135+
});
136+
}

src/windows-image-cron.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { rollWindowsArcImage } from './windows-image-handler';
2+
3+
if (require.main === module) {
4+
rollWindowsArcImage().catch((err: Error) => {
5+
console.log('Windows Image Cron Failed');
6+
console.error(err);
7+
process.exit(1);
8+
});
9+
}

src/windows-image-handler.ts

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import * as debug from 'debug';
2+
3+
import {
4+
REPOS,
5+
WINDOWS_DOCKER_IMAGE_NAME,
6+
ARC_RUNNER_ENVIRONMENTS,
7+
MAIN_BRANCH,
8+
} from './constants';
9+
import { getOctokit } from './utils/octokit';
10+
import { currentWindowsImage, didFileChangeBetweenShas } from './utils/arc-image';
11+
import { rollInfra } from './utils/roll-infra';
12+
13+
async function getLatestVersionOfImage() {
14+
const octokit = await getOctokit();
15+
// return a list of tags for the repo, filter out any that aren't valid semver
16+
const versions = await octokit.paginate(
17+
'GET /orgs/{org}/packages/{package_type}/{package_name}/versions',
18+
{
19+
org: REPOS.electronInfra.owner,
20+
package_type: 'container',
21+
package_name: WINDOWS_DOCKER_IMAGE_NAME,
22+
},
23+
);
24+
25+
let best = null;
26+
let bestMainTag = null;
27+
for (const version of versions) {
28+
// Only images built from main should be bumped to
29+
const mainTag = version.metadata?.container?.tags?.find((t) => t.startsWith(`${MAIN_BRANCH}-`));
30+
if (!mainTag) continue;
31+
if (!best) {
32+
best = version;
33+
bestMainTag = mainTag;
34+
continue;
35+
}
36+
37+
if (new Date(best.created_at).getTime() < new Date(version.created_at).getTime()) {
38+
best = version;
39+
bestMainTag = mainTag;
40+
}
41+
}
42+
return [`electronarc.azurecr.io/win-actions-runner:${bestMainTag}@${best.name}`, bestMainTag];
43+
}
44+
45+
const WINDOWS_IMAGE_DOCKERFILE_PATH = 'docker/windows-actions-runner/Dockerfile';
46+
47+
export async function rollWindowsArcImage() {
48+
const d = debug(`roller/infra:rollWindowsArcImage()`);
49+
const octokit = await getOctokit();
50+
51+
const [latestWindowsImage, shortLatestTag] = await getLatestVersionOfImage();
52+
53+
for (const arcEnv of Object.keys(ARC_RUNNER_ENVIRONMENTS)) {
54+
d(`Fetching current version of "${arcEnv}" arc image in: ${ARC_RUNNER_ENVIRONMENTS[arcEnv]}`);
55+
56+
const currentVersion = await octokit.repos.getContent({
57+
owner: REPOS.electronInfra.owner,
58+
repo: REPOS.electronInfra.repo,
59+
path: ARC_RUNNER_ENVIRONMENTS[arcEnv],
60+
});
61+
const data = currentVersion.data;
62+
if ('content' in data) {
63+
const currentContent = Buffer.from(data.content, 'base64').toString('utf8');
64+
const currentImage = currentWindowsImage(currentContent);
65+
66+
if (currentImage !== latestWindowsImage) {
67+
const currentSha = currentImage.split(`${MAIN_BRANCH}-`)[1].split('@')[0];
68+
if (
69+
await didFileChangeBetweenShas(
70+
WINDOWS_IMAGE_DOCKERFILE_PATH,
71+
currentSha,
72+
shortLatestTag.split('-')[1],
73+
)
74+
) {
75+
d(`Current image in "${arcEnv}" is outdated, updating...`);
76+
const newContent = currentContent.replace(currentImage, latestWindowsImage);
77+
await rollInfra(
78+
`${arcEnv}/windows-image`,
79+
'windows arc image',
80+
shortLatestTag,
81+
ARC_RUNNER_ENVIRONMENTS[arcEnv],
82+
newContent,
83+
);
84+
} else {
85+
d(
86+
`Current image in "${arcEnv}" is not latest sha but is considered up-to-date, skipping...`,
87+
);
88+
}
89+
}
90+
}
91+
}
92+
}

0 commit comments

Comments
 (0)