Skip to content

build: roll github actions runners automatically #147

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
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
9 changes: 9 additions & 0 deletions src/actions-runner-cron.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { rollActionsRunner } from './actions-runner-handler';

if (require.main === module) {
rollActionsRunner().catch((err: Error) => {
console.log('Actions Runner Cron Failed');
console.error(err);
process.exit(1);
});
}
62 changes: 62 additions & 0 deletions src/actions-runner-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import debug from 'debug';

import { WINDOWS_DOCKER_FILE, ARC_RUNNER_ENVIRONMENTS } from './constants';
import { getOctokit } from './utils/octokit';

import { getLatestRunnerImages } from './utils/get-latest-runner-images';
import {
getCurrentWindowsRunnerVersion,
getFileContent,
currentLinuxImages,
} from './utils/arc-image';
import { rollInfra } from './utils/roll-infra';

export async function rollActionsRunner() {
const d = debug(`roller/infra:rollActionsRunner()`);

const octokit = await getOctokit();
const { archDigests, latestVersion } = (await getLatestRunnerImages(octokit)) ?? {
archDigests: {},
latestVersion: '',
};
if (latestVersion === '') {
d('No latest version found for github actions runner, exiting...');
return;
}

const windowsDockerFile = await getFileContent(octokit, WINDOWS_DOCKER_FILE);
const currentWindowsRunnerVersion = await getCurrentWindowsRunnerVersion(windowsDockerFile.raw);
if (currentWindowsRunnerVersion !== latestVersion) {
d(`Runner version ${currentWindowsRunnerVersion} is outdated, updating to ${latestVersion}.`);
const newDockerFile = windowsDockerFile.raw.replace(currentWindowsRunnerVersion, latestVersion);
await rollInfra(
`prod/actions-runner`,
'github actions runner images',
latestVersion,
WINDOWS_DOCKER_FILE,
newDockerFile,
);
}

for (const arcEnv of Object.keys(ARC_RUNNER_ENVIRONMENTS)) {
d(`Fetching current version of "${arcEnv}" arc image in: ${ARC_RUNNER_ENVIRONMENTS[arcEnv]}`);

const runnerFile = await getFileContent(octokit, ARC_RUNNER_ENVIRONMENTS['prod']);

const currentImages = currentLinuxImages(runnerFile.raw);
if (currentImages.amd64 !== archDigests.amd64 || currentImages.arm64 !== archDigests.arm64) {
d(`Current linux images in "${arcEnv}" are outdated, updating to ${latestVersion}.`);
let newContent = runnerFile.raw.replace(currentImages.amd64, archDigests.amd64);
newContent = newContent.replace(currentImages.arm64, archDigests.arm64);
await rollInfra(
`${arcEnv}/actions-runner`,
'github actions runner images',
latestVersion,
ARC_RUNNER_ENVIRONMENTS[arcEnv],
newContent,
);
} else {
d(`Current linux images in "${arcEnv}" are up-to-date, skipping...`);
}
}
}
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const NO_BACKPORT = 'no-backport';
export const ARC_RUNNER_ENVIRONMENTS = {
prod: 'argo/arc-cluster/runner-sets/runners.yaml',
};
export const WINDOWS_DOCKER_FILE = 'docker/windows-actions-runner/Dockerfile';
export const WINDOWS_DOCKER_IMAGE_NAME = 'windows-actions-runner';

export interface Commit {
Expand Down
25 changes: 22 additions & 3 deletions src/utils/arc-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import { Octokit } from '@octokit/rest';
import { MAIN_BRANCH, REPOS } from '../constants';
import { getOctokit } from './octokit';

const WINDOWS_RUNNER_REGEX = /ARG RUNNER_VERSION=([\d.]+)/;
const WINDOWS_IMAGE_REGEX =
/electronarc\.azurecr\.io\/win-actions-runner:main-[a-f0-9]{7}@sha256:[a-f0-9]{64}/;
// TODO: Also roll the linux ARC container
// const LINUX_IMAGE_REGEX =
// /ghcr\.io\/actions\/actions-runner:[0-9]+\.[0-9]+\.[0-9]+@sha256:[a-f0-9]{64}/;
const LINUX_IMAGE_REGEX =
/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})/;

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

export const currentLinuxImages = (content: string) => {
const matches = content.match(LINUX_IMAGE_REGEX);
if (!matches || matches.length < 3) {
return {
amd64: '',
arm64: '',
};
} else {
return {
amd64: matches[1],
arm64: matches[2],
};
}
};

export async function getCurrentWindowsRunnerVersion(content: string) {
return content.match(WINDOWS_RUNNER_REGEX)?.[1];
}

export const didFileChangeBetweenShas = async (file: string, sha1: string, sha2: string) => {
const octokit = await getOctokit();
const [start, end] = await Promise.all([
Expand Down
96 changes: 96 additions & 0 deletions src/utils/get-latest-runner-images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Fetches the latest linux/amd64 and linux/arm64 images for actions/runner from GitHub Container Registry

import { Octokit } from '@octokit/rest';

const OWNER = 'actions';
const PACKAGE = 'actions-runner';

type PackageVersion = {
metadata?: {
container?: {
tags?: string[];
};
};
};

type ManifestPlatform = {
os?: string;
architecture?: string;
};

type Manifest = {
platform?: ManifestPlatform;
digest: string;
};

type ManifestList = {
manifests?: Manifest[];
};

export async function getLatestRunnerImages(
octokit: Octokit,
): Promise<{ archDigests: Record<string, string>; latestVersion: string } | null> {
let versions: PackageVersion[];
try {
const response = await octokit.rest.packages.getAllPackageVersionsForPackageOwnedByOrg({
package_type: 'container',
package_name: PACKAGE,
org: OWNER,
per_page: 10,
});
versions = response.data as PackageVersion[];
} catch (e) {
console.error('Failed to fetch package versions:', e);
return null;
}

// Find the version with the 'latest' tag
const latestVersion = versions.find((v) => v.metadata?.container?.tags?.includes('latest'));
const tags = latestVersion?.metadata?.container?.tags || [];
// Find the first tag that matches a semver version (e.g., 2.315.0)
const tagVersion = tags.find((t) => /^\d+\.\d+\.\d+$/.test(t));

if (!latestVersion || !tagVersion) {
console.error("No version with the 'latest' tag found; tags were:", tags);
return null;
}

// Fetch the manifest list for the latest tag
const manifestUrl = `https://ghcr.io/v2/${OWNER}/${PACKAGE}/manifests/${tagVersion}`;
const manifestHeaders = {
'User-Agent': 'node.js',
Accept: 'application/vnd.oci.image.index.v1+json',
Authorization: 'Bearer QQ==',
};
let manifestList: ManifestList;
try {
const manifestResponse = await fetch(manifestUrl, {
Copy link
Member

Choose a reason for hiding this comment

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

Should check manifestResponse.ok in case there's a non-200 response code, which won't throw an error here.

headers: manifestHeaders,
});
manifestList = await manifestResponse.json();
} catch (e) {
console.error('Failed to fetch manifest list:', e);
return null;
}

// Find digests for linux/amd64 and linux/arm64
const archDigests: Record<string, string> = {};
for (const manifest of manifestList.manifests || []) {
const platform = manifest.platform;
if (
platform?.os === 'linux' &&
(platform.architecture === 'amd64' || platform.architecture === 'arm64')
) {
archDigests[platform.architecture] = manifest.digest;
}
}

if (!archDigests.amd64 && !archDigests.arm64) {
console.error('No linux/amd64 or linux/arm64 digests found in manifest list.');
return null;
}
return {
archDigests,
latestVersion: tagVersion,
};
}
98 changes: 98 additions & 0 deletions tests/actions-runner-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as arcImage from '../src/utils/arc-image';
import * as getLatestRunnerImagesModule from '../src/utils/get-latest-runner-images';
import * as rollInfraModule from '../src/utils/roll-infra';
import * as constants from '../src/constants';
import { getOctokit } from '../src/utils/octokit';

import { rollActionsRunner } from '../src/actions-runner-handler';

vi.mock('debug', () => ({ default: vi.fn(() => vi.fn()) }));
vi.mock('../src/utils/get-latest-runner-images');
vi.mock('../src/utils/arc-image');
vi.mock('../src/utils/roll-infra');
vi.mock('../src/utils/octokit');

const mockOctokit = {};

const mockFileContent = (raw: string) => ({ raw, sha: 'sha' });

const latestVersion = '2.325.0';
const archDigests = {
amd64: 'sha256:amd64digest',
arm64: 'sha256:arm64digest',
};

const newWinDockerFile = 'ARG RUNNER_VERSION=2.325.0';
const oldWinDockerFile = 'ARG RUNNER_VERSION=2.324.0';

const oldLinuxImages = {
amd64: 'ghcr.io/actions/actions-runner:2.324.0@sha256:oldamd64',
arm64: 'ghcr.io/actions/actions-runner:2.324.0@sha256:oldarm64',
};
const newLinuxImages = {
amd64: archDigests.amd64,
arm64: archDigests.arm64,
};

beforeEach(() => {
vi.clearAllMocks();
(getOctokit as any).mockResolvedValue(mockOctokit);
vi.mocked(getLatestRunnerImagesModule.getLatestRunnerImages).mockResolvedValue({
archDigests,
latestVersion,
});
vi.mocked(arcImage.getFileContent).mockImplementation(async (_octokit: any, file: string) => {
if (file === constants.WINDOWS_DOCKER_FILE) return mockFileContent(oldWinDockerFile);
if (file === constants.ARC_RUNNER_ENVIRONMENTS.prod)
return mockFileContent(`amd64: ${oldLinuxImages.amd64}\narm64: ${oldLinuxImages.arm64}`);
return mockFileContent('');
});
vi.mocked(arcImage.getCurrentWindowsRunnerVersion).mockImplementation(
async (raw: string) => raw.match(/([\d.]+)/)?.[1] || '',
);
vi.mocked(arcImage.currentLinuxImages).mockImplementation((raw: string) => {
if (raw.includes('oldamd64')) return oldLinuxImages;
return newLinuxImages;
});
vi.mocked(rollInfraModule.rollInfra).mockResolvedValue(undefined);
});

describe('rollActionsRunner', () => {
it('should update windows runner if version is outdated', async () => {
await rollActionsRunner();
expect(rollInfraModule.rollInfra).toHaveBeenCalledWith(
'prod/actions-runner',
'github actions runner images',
latestVersion,
constants.WINDOWS_DOCKER_FILE,
expect.stringContaining(latestVersion),
);
});

it('should update linux images if digests are outdated', async () => {
await rollActionsRunner();
expect(rollInfraModule.rollInfra).toHaveBeenCalledWith(
'prod/actions-runner',
'github actions runner images',
latestVersion,
constants.ARC_RUNNER_ENVIRONMENTS.prod,
expect.any(String),
);
});

it('should skip update if everything is up-to-date', async () => {
vi.mocked(arcImage.getFileContent).mockImplementation(async (_octokit: any, file: string) => {
if (file === constants.WINDOWS_DOCKER_FILE) return mockFileContent(newWinDockerFile);
if (file === constants.ARC_RUNNER_ENVIRONMENTS.prod)
return mockFileContent(`amd64: ${archDigests.amd64}\narm64: ${archDigests.arm64}`);
return mockFileContent('');
});
vi.mocked(arcImage.getCurrentWindowsRunnerVersion).mockImplementation(
async (raw: string) => latestVersion,
);
vi.mocked(arcImage.currentLinuxImages).mockImplementation((raw: string) => newLinuxImages);
await rollActionsRunner();
expect(rollInfraModule.rollInfra).not.toHaveBeenCalled();
});
});
58 changes: 58 additions & 0 deletions tests/utils/arc-image.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import {
currentWindowsImage,
currentLinuxImages,
getCurrentWindowsRunnerVersion,
} from '../../src/utils/arc-image';

const windowsImageContent = `electronarc.azurecr.io/win-actions-runner:main-abcdef0@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef`;

const linuxImageContent = `containers:
- name: runner
resources:
requests:
cpu: "{{ .cpuRequest }}"
memory: "{{ .memoryRequest }}"
{{- if eq .cpuPlatform "linux" }}
{{- if eq .cpuArch "amd64" }}
image: ghcr.io/actions/actions-runner:2.325.0@sha256:b865e3f046f0a92a4b936ae75c5bc5615b99b64eb4801b0e5220f13f8867d6b8
{{- else }}
image: ghcr.io/actions/actions-runner:2.325.0@sha256:ab3fb968f7bcc8b34677b93a98f576142a2affde57ea2e7b461f515fd8a12453
{{- end }}`;

const runnerVersionContent = `FROM something-else:latest
LABEL name=arc-runner-windows

ARG RUNNER_VERSION=2.325.0
ENV RUNNER_VERSION=$RUNNER_VERSION
`;

const invalidLinuxContent = `{{- if eq .cpuArch "amd64" }}\nimage: something-else\n{{- else }}\nimage: something-else\n`;

describe('arc-image utils', () => {
it('should extract the current Windows image', () => {
expect(currentWindowsImage(windowsImageContent)).toBe(
'electronarc.azurecr.io/win-actions-runner:main-abcdef0@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
);
});

it('should extract both linux images', () => {
const images = currentLinuxImages(linuxImageContent);
expect(images.amd64).toBe(
'ghcr.io/actions/actions-runner:2.325.0@sha256:b865e3f046f0a92a4b936ae75c5bc5615b99b64eb4801b0e5220f13f8867d6b8',
);
expect(images.arm64).toBe(
'ghcr.io/actions/actions-runner:2.325.0@sha256:ab3fb968f7bcc8b34677b93a98f576142a2affde57ea2e7b461f515fd8a12453',
);
});

it('should return empty strings for missing linux images', () => {
const images = currentLinuxImages(invalidLinuxContent);
expect(images.amd64).toBe('');
expect(images.arm64).toBe('');
});

it('should extract the current Windows runner version', async () => {
expect(await getCurrentWindowsRunnerVersion(runnerVersionContent)).toBe('2.325.0');
});
});
Loading