Skip to content

Commit 704ccca

Browse files
committed
build: roll github actions automatically
1 parent dc8639a commit 704ccca

File tree

5 files changed

+215
-4
lines changed

5 files changed

+215
-4
lines changed

src/actions-runner-cron.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { rollActionsRunner } from './actions-runner-handler';
2+
3+
if (require.main === module) {
4+
rollActionsRunner().catch((err: Error) => {
5+
console.log('Actions Runner Cron Failed');
6+
console.error(err);
7+
process.exit(1);
8+
});
9+
}

src/actions-runner-handler.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import * as debug from 'debug';
2+
3+
import { WINDOWS_DOCKER_FILE, ARC_RUNNER_ENVIRONMENTS } from './constants';
4+
import { getOctokit } from './utils/octokit';
5+
6+
import { getLatestRunnerImages } from './utils/get-latest-runner-images';
7+
import {
8+
getCurrentWindowsRunnerVersion,
9+
getFileContent,
10+
currentLinuxImages,
11+
} from './utils/arc-image';
12+
import { rollInfra } from './utils/roll-infra';
13+
14+
export async function rollActionsRunner() {
15+
const d = debug(`roller/infra:rollActionsRunner()`);
16+
17+
const octokit = await getOctokit();
18+
const { archDigests, latestVersion } = (await getLatestRunnerImages(octokit)) ?? {
19+
archDigests: {},
20+
latestVersion: '',
21+
};
22+
if (latestVersion === '') {
23+
d('No latest version found for github actions runner, exiting...');
24+
return;
25+
}
26+
27+
const windowsDockerFile = await getFileContent(octokit, WINDOWS_DOCKER_FILE);
28+
const currentWindowsRunnerVersion = await getCurrentWindowsRunnerVersion(windowsDockerFile.raw);
29+
if (currentWindowsRunnerVersion !== latestVersion) {
30+
d(`Runner version ${currentWindowsRunnerVersion} is outdated, updating to ${latestVersion}.`);
31+
const newDockerFile = windowsDockerFile.raw.replace(currentWindowsRunnerVersion, latestVersion);
32+
await rollInfra(
33+
`prod/actions-runner`,
34+
'github actions runner images',
35+
latestVersion,
36+
WINDOWS_DOCKER_FILE,
37+
newDockerFile,
38+
);
39+
}
40+
41+
for (const arcEnv of Object.keys(ARC_RUNNER_ENVIRONMENTS)) {
42+
d(`Fetching current version of "${arcEnv}" arc image in: ${ARC_RUNNER_ENVIRONMENTS[arcEnv]}`);
43+
44+
const runnerFile = await getFileContent(octokit, ARC_RUNNER_ENVIRONMENTS['prod']);
45+
46+
const currentImages = currentLinuxImages(runnerFile.raw);
47+
if (currentImages.amd64 !== archDigests.amd64 || currentImages.arm64 !== archDigests.arm64) {
48+
d(`Current linux images in "${arcEnv}" are outdated, updating to ${latestVersion}.`);
49+
let newContent = runnerFile.raw.replace(currentImages.amd64, archDigests.amd64);
50+
newContent = newContent.replace(currentImages.arm64, archDigests.arm64);
51+
await rollInfra(
52+
`${arcEnv}/actions-runner`,
53+
'github actions runner images',
54+
latestVersion,
55+
ARC_RUNNER_ENVIRONMENTS[arcEnv],
56+
newContent,
57+
);
58+
} else {
59+
d(`Current linux images in "${arcEnv}" are up-to-date, skipping...`);
60+
}
61+
}
62+
}

src/constants.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export const NO_BACKPORT = 'no-backport';
4848
export const ARC_RUNNER_ENVIRONMENTS = {
4949
prod: 'argo/arc-cluster/runner-sets/runners.yaml',
5050
};
51+
export const WINDOWS_DOCKER_FILE = 'docker/windows-actions-runner/Dockerfile';
5152
export const WINDOWS_DOCKER_IMAGE_NAME = 'windows-actions-runner';
5253

5354
export interface Commit {

src/utils/arc-image.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { Octokit } from '@octokit/rest';
2-
import { MAIN_BRANCH, REPOS } from '../constants';
2+
import { MAIN_BRANCH, REPOS, WINDOWS_DOCKER_FILE } from '../constants';
33
import { getOctokit } from './octokit';
44

5+
const WINDOWS_RUNNER_REGEX = /ARG RUNNER_VERSION=([\d.]+)/;
56
const WINDOWS_IMAGE_REGEX =
67
/electronarc\.azurecr\.io\/win-actions-runner:main-[a-f0-9]{7}@sha256:[a-f0-9]{64}/;
7-
// TODO: Also roll the linux ARC container
8-
// const LINUX_IMAGE_REGEX =
9-
// /ghcr\.io\/actions\/actions-runner:[0-9]+\.[0-9]+\.[0-9]+@sha256:[a-f0-9]{64}/;
8+
const LINUX_IMAGE_REGEX =
9+
/if eq .cpuArch "amd64".*\n.*image: (ghcr.io\/actions\/actions-runner:[0-9]+\.[0-9]+\.[0-9]+@sha256:[a-f0-9]{64}).*\n.*{{- else }}.*\n.*image: (ghcr.io\/actions\/actions-runner:[0-9]+\.[0-9]+\.[0-9]+@sha256:[a-f0-9]{64})/;
1010

1111
export async function getFileContent(octokit: Octokit, filePath: string, ref = MAIN_BRANCH) {
1212
const { data } = await octokit.repos.getContent({
@@ -24,6 +24,25 @@ export const currentWindowsImage = (content: string) => {
2424
return content.match(WINDOWS_IMAGE_REGEX)?.[0];
2525
};
2626

27+
export const currentLinuxImages = (content: string) => {
28+
const matches = content.match(LINUX_IMAGE_REGEX);
29+
if (!matches || matches.length < 3) {
30+
return {
31+
amd64: '',
32+
arm64: '',
33+
};
34+
} else {
35+
return {
36+
amd64: matches[1],
37+
arm64: matches[2],
38+
};
39+
}
40+
};
41+
42+
export async function getCurrentWindowsRunnerVersion(content: string) {
43+
return content.match(WINDOWS_RUNNER_REGEX)?.[1];
44+
}
45+
2746
export const didFileChangeBetweenShas = async (file: string, sha1: string, sha2: string) => {
2847
const octokit = await getOctokit();
2948
const [start, end] = await Promise.all([

src/utils/get-latest-runner-images.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// get-latest-runner-images.ts
2+
// Fetches the latest linux/amd64 and linux/arm64 images for actions/runner from GitHub Container Registry
3+
// Usage: ts-node get-latest-runner-images.ts
4+
5+
import https from 'https';
6+
import { Octokit } from '@octokit/rest';
7+
import { getOctokit } from './octokit';
8+
9+
const OWNER = 'actions';
10+
const PACKAGE = 'actions-runner';
11+
12+
type PackageVersion = {
13+
metadata?: {
14+
container?: {
15+
tags?: string[];
16+
};
17+
};
18+
};
19+
20+
type ManifestPlatform = {
21+
os?: string;
22+
architecture?: string;
23+
};
24+
25+
type Manifest = {
26+
platform?: ManifestPlatform;
27+
digest: string;
28+
};
29+
30+
type ManifestList = {
31+
manifests?: Manifest[];
32+
};
33+
34+
// Helper to fetch JSON from a URL
35+
function fetchJson(url: string, headers: Record<string, string> = {}): Promise<any> {
36+
return new Promise((resolve, reject) => {
37+
https
38+
.get(url, { headers }, (res) => {
39+
let data = '';
40+
res.on('data', (chunk) => (data += chunk));
41+
res.on('end', () => {
42+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
43+
try {
44+
resolve(JSON.parse(data));
45+
} catch (e) {
46+
reject(e);
47+
}
48+
} else {
49+
reject(new Error(`HTTP ${res.statusCode}: ${data}`));
50+
}
51+
});
52+
})
53+
.on('error', reject);
54+
});
55+
}
56+
57+
export async function getLatestRunnerImages(
58+
octokit: Octokit,
59+
): Promise<{ archDigests: Record<string, string>; latestVersion: string } | null> {
60+
let versions: PackageVersion[];
61+
try {
62+
const response = await octokit.rest.packages.getAllPackageVersionsForPackageOwnedByOrg({
63+
package_type: 'container',
64+
package_name: PACKAGE,
65+
org: OWNER,
66+
per_page: 10,
67+
});
68+
versions = response.data as PackageVersion[];
69+
} catch (e) {
70+
console.error('Failed to fetch package versions:', e);
71+
return null;
72+
}
73+
74+
// Find the version with the 'latest' tag
75+
const latestVersion = versions.find((v) => v.metadata?.container?.tags?.includes('latest'));
76+
const tags = latestVersion?.metadata?.container?.tags || [];
77+
// Find the first tag that matches a semver version (e.g., 2.315.0)
78+
const tagVersion = tags.find((t) => /^\d+\.\d+\.\d+$/.test(t));
79+
80+
if (!latestVersion || !tagVersion) {
81+
console.error("No version with the 'latest' tag found; tags were:", tags);
82+
return null;
83+
}
84+
85+
// Fetch the manifest list for the latest tag
86+
const manifestUrl = `https://ghcr.io/v2/${OWNER}/${PACKAGE}/manifests/${tagVersion}`;
87+
const manifestHeaders = {
88+
'User-Agent': 'node.js',
89+
Accept: 'application/vnd.oci.image.index.v1+json',
90+
Authorization: 'Bearer QQ==',
91+
};
92+
let manifestList: ManifestList;
93+
try {
94+
manifestList = await fetchJson(manifestUrl, manifestHeaders);
95+
} catch (e) {
96+
console.error('Failed to fetch manifest list:', e);
97+
return null;
98+
}
99+
100+
// Find digests for linux/amd64 and linux/arm64
101+
const archDigests: Record<string, string> = {};
102+
for (const manifest of manifestList.manifests || []) {
103+
const platform = manifest.platform;
104+
if (
105+
platform?.os === 'linux' &&
106+
(platform.architecture === 'amd64' || platform.architecture === 'arm64')
107+
) {
108+
archDigests[platform.architecture] = manifest.digest;
109+
}
110+
}
111+
112+
if (!archDigests.amd64 && !archDigests.arm64) {
113+
console.error('No linux/amd64 or linux/arm64 digests found in manifest list.');
114+
return null;
115+
}
116+
return {
117+
archDigests,
118+
latestVersion: tagVersion,
119+
};
120+
}

0 commit comments

Comments
 (0)