diff --git a/.gitignore b/.gitignore index e8d1aa0..2faa63e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ lib node_modules coverage +.envrc diff --git a/src/constants.ts b/src/constants.ts index 1174fe9..311a532 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,6 +3,10 @@ export const REPOS = { owner: 'electron', repo: 'electron', }, + electronInfra: { + owner: 'electron', + repo: 'infra', + }, node: { owner: 'nodejs', repo: 'node', @@ -41,6 +45,11 @@ export const ORB_TARGETS = [ export const BACKPORT_CHECK_SKIP = 'backport-check-skip'; export const NO_BACKPORT = 'no-backport'; +export const ARC_RUNNER_ENVIRONMENTS = { + prod: 'argo/arc-cluster/runner-sets/runners.yaml', +}; +export const WINDOWS_DOCKER_IMAGE_NAME = 'windows-actions-runner'; + export interface Commit { sha: string; message: string; diff --git a/src/utils/arc-image.ts b/src/utils/arc-image.ts new file mode 100644 index 0000000..73e32af --- /dev/null +++ b/src/utils/arc-image.ts @@ -0,0 +1,34 @@ +import { Octokit } from '@octokit/rest'; +import { MAIN_BRANCH, REPOS } from '../constants'; +import { getOctokit } from './octokit'; + +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}/; + +export async function getFileContent(octokit: Octokit, filePath: string, ref = MAIN_BRANCH) { + const { data } = await octokit.repos.getContent({ + ...REPOS.electronInfra, + path: filePath, + ref, + }); + if ('content' in data) { + return { raw: Buffer.from(data.content, 'base64').toString('utf8'), sha: data.sha }; + } + throw 'wat'; +} + +export const currentWindowsImage = (content: string) => { + return content.match(WINDOWS_IMAGE_REGEX)?.[0]; +}; + +export const didFileChangeBetweenShas = async (file: string, sha1: string, sha2: string) => { + const octokit = await getOctokit(); + const [start, end] = await Promise.all([ + await getFileContent(octokit, file, sha1), + await getFileContent(octokit, file, sha2), + ]); + + return start.raw.trim() !== end.raw.trim(); +}; diff --git a/src/utils/pr-text.ts b/src/utils/pr-text.ts index 05bfa67..aa2229d 100644 --- a/src/utils/pr-text.ts +++ b/src/utils/pr-text.ts @@ -57,3 +57,10 @@ Original-Version: ${previousVersion} Notes: Updated Node.js to ${newVersion}.`, }; } + +export function getInfraPRText(bumpSubject: string, newShortVersion: string) { + return { + title: `build: bump ${bumpSubject} to ${newShortVersion}`, + body: `Updating ${bumpSubject} to \`${newShortVersion}\``, + }; +} diff --git a/src/utils/roll-infra.ts b/src/utils/roll-infra.ts new file mode 100644 index 0000000..b375ee7 --- /dev/null +++ b/src/utils/roll-infra.ts @@ -0,0 +1,144 @@ +import * as debug from 'debug'; + +import { MAIN_BRANCH, REPOS } from '../constants'; +import { getOctokit } from './octokit'; +import { PullsListResponseItem } from '../types'; +import { Octokit } from '@octokit/rest'; +import { getInfraPRText } from './pr-text'; +import { getFileContent } from './arc-image'; + +export async function rollInfra( + rollKey: string, + bumpSubject: string, + newShortVersion: string, + filePath: string, + newContent: string, +): Promise { + const d = debug(`roller/infra/${rollKey}:rollInfra()`); + const octokit = await getOctokit(); + + const branchName = `roller/infra/${rollKey}`; + const shortRef = `heads/${branchName}`; + const ref = `refs/${shortRef}`; + + const { owner, repo } = REPOS.electronInfra; + + // Look for a pre-existing PR that targets this branch to see if we can update that. + let existingPrsForBranch: PullsListResponseItem[] = []; + try { + existingPrsForBranch = (await octokit.paginate('GET /repos/:owner/:repo/pulls', { + head: branchName, + owner, + repo, + state: 'open', + })) as PullsListResponseItem[]; + } catch {} + + const prs = existingPrsForBranch.filter((pr) => + pr.title.startsWith(`build: bump ${bumpSubject}`), + ); + + const defaultBranchHeadSha = ( + await octokit.repos.getBranch({ + owner, + repo, + branch: MAIN_BRANCH, + }) + ).data.commit.sha; + + if (prs.length) { + // Update existing PR(s) + for (const pr of prs) { + d(`Found existing PR: #${pr.number} opened by ${pr.user.login}`); + + // Check to see if automatic infra roll has been temporarily disabled + const hasPauseLabel = pr.labels.some((label) => label.name === 'roller/pause'); + if (hasPauseLabel) { + d(`Automatic updates have been paused for #${pr.number}, skipping infra roll.`); + continue; + } + + d(`Attempting infra update for #${pr.number}`); + const { raw: currentContent, sha: currentSha } = await getFileContent( + octokit, + filePath, + pr.head.ref, + ); + if (currentContent.trim() !== newContent.trim()) { + await updateFile( + octokit, + bumpSubject, + newShortVersion, + filePath, + newContent, + branchName, + currentSha, + ); + } + + await octokit.pulls.update({ + owner, + repo, + pull_number: pr.number, + ...getInfraPRText(bumpSubject, newShortVersion), + }); + } + } else { + try { + d(`roll triggered for ${bumpSubject}=${newShortVersion}`); + + try { + await octokit.git.getRef({ owner, repo, ref: shortRef }); + d(`Ref ${ref} already exists, deleting`); + await octokit.git.deleteRef({ owner, repo, ref: shortRef }); + } catch { + // Ignore + } finally { + d(`Creating ref=${ref} at sha=${defaultBranchHeadSha}`); + await octokit.git.createRef({ owner, repo, ref, sha: defaultBranchHeadSha }); + } + + const { sha: currentSha } = await getFileContent(octokit, filePath); + + await updateFile( + octokit, + bumpSubject, + newShortVersion, + filePath, + newContent, + branchName, + currentSha, + ); + + d(`Raising a PR for ${branchName} to ${repo}`); + await octokit.pulls.create({ + owner, + repo, + base: MAIN_BRANCH, + head: `${owner}:${branchName}`, + ...getInfraPRText(bumpSubject, newShortVersion), + }); + } catch (e) { + d(`Error rolling ${owner}/${repo} to ${newShortVersion}`, e); + } + } +} + +async function updateFile( + octokit: Octokit, + bumpSubject: string, + newShortVersion: string, + filePath: string, + newContent: string, + branchName: string, + currentSha: string, +) { + await octokit.repos.createOrUpdateFileContents({ + ...REPOS.electronInfra, + path: filePath, + message: `build: bump ${bumpSubject} in ${filePath} to ${newShortVersion}`, + content: Buffer.from(newContent).toString('base64'), + branch: branchName, + sha: currentSha, + }); +} diff --git a/src/windows-image-cron.ts b/src/windows-image-cron.ts new file mode 100644 index 0000000..a94fa66 --- /dev/null +++ b/src/windows-image-cron.ts @@ -0,0 +1,9 @@ +import { rollWindowsArcImage } from './windows-image-handler'; + +if (require.main === module) { + rollWindowsArcImage().catch((err: Error) => { + console.log('Windows Image Cron Failed'); + console.error(err); + process.exit(1); + }); +} diff --git a/src/windows-image-handler.ts b/src/windows-image-handler.ts new file mode 100644 index 0000000..cd9aa08 --- /dev/null +++ b/src/windows-image-handler.ts @@ -0,0 +1,92 @@ +import * as debug from 'debug'; + +import { + REPOS, + WINDOWS_DOCKER_IMAGE_NAME, + ARC_RUNNER_ENVIRONMENTS, + MAIN_BRANCH, +} from './constants'; +import { getOctokit } from './utils/octokit'; +import { currentWindowsImage, didFileChangeBetweenShas } from './utils/arc-image'; +import { rollInfra } from './utils/roll-infra'; + +async function getLatestVersionOfImage() { + const octokit = await getOctokit(); + // return a list of tags for the repo, filter out any that aren't valid semver + const versions = await octokit.paginate( + 'GET /orgs/{org}/packages/{package_type}/{package_name}/versions', + { + org: REPOS.electronInfra.owner, + package_type: 'container', + package_name: WINDOWS_DOCKER_IMAGE_NAME, + }, + ); + + let best = null; + let bestMainTag = null; + for (const version of versions) { + // Only images built from main should be bumped to + const mainTag = version.metadata?.container?.tags?.find((t) => t.startsWith(`${MAIN_BRANCH}-`)); + if (!mainTag) continue; + if (!best) { + best = version; + bestMainTag = mainTag; + continue; + } + + if (new Date(best.created_at).getTime() < new Date(version.created_at).getTime()) { + best = version; + bestMainTag = mainTag; + } + } + return [`electronarc.azurecr.io/win-actions-runner:${bestMainTag}@${best.name}`, bestMainTag]; +} + +const WINDOWS_IMAGE_DOCKERFILE_PATH = 'docker/windows-actions-runner/Dockerfile'; + +export async function rollWindowsArcImage() { + const d = debug(`roller/infra:rollWindowsArcImage()`); + const octokit = await getOctokit(); + + const [latestWindowsImage, shortLatestTag] = await getLatestVersionOfImage(); + + for (const arcEnv of Object.keys(ARC_RUNNER_ENVIRONMENTS)) { + d(`Fetching current version of "${arcEnv}" arc image in: ${ARC_RUNNER_ENVIRONMENTS[arcEnv]}`); + + const currentVersion = await octokit.repos.getContent({ + owner: REPOS.electronInfra.owner, + repo: REPOS.electronInfra.repo, + path: ARC_RUNNER_ENVIRONMENTS[arcEnv], + }); + const data = currentVersion.data; + if ('content' in data) { + const currentContent = Buffer.from(data.content, 'base64').toString('utf8'); + const currentImage = currentWindowsImage(currentContent); + + if (currentImage !== latestWindowsImage) { + const currentSha = currentImage.split(`${MAIN_BRANCH}-`)[1].split('@')[0]; + if ( + await didFileChangeBetweenShas( + WINDOWS_IMAGE_DOCKERFILE_PATH, + currentSha, + shortLatestTag.split('-')[1], + ) + ) { + d(`Current image in "${arcEnv}" is outdated, updating...`); + const newContent = currentContent.replace(currentImage, latestWindowsImage); + await rollInfra( + `${arcEnv}/windows-image`, + 'windows arc image', + shortLatestTag, + ARC_RUNNER_ENVIRONMENTS[arcEnv], + newContent, + ); + } else { + d( + `Current image in "${arcEnv}" is not latest sha but is considered up-to-date, skipping...`, + ); + } + } + } + } +}