From 1ecaab241d4ffd769630eb3ac03797465fc2de7e Mon Sep 17 00:00:00 2001
From: PeterNitsche <79380100+PeterNitsche@users.noreply.github.com>
Date: Tue, 8 Aug 2023 21:59:41 +0200
Subject: [PATCH] feat(github-actions): support GitHub actions runners (#23633)

Co-authored-by: HonkingGoose <34918129+HonkingGoose@users.noreply.github.com>
---
 lib/modules/datasource/api.ts                 |   2 +
 .../datasource/github-runners/index.spec.ts   |  30 +++++
 .../datasource/github-runners/index.ts        |  58 +++++++++
 .../datasource/github-runners/readme.md       |  11 ++
 .../__snapshots__/extract.spec.ts.snap        |  81 +++++++++++++
 .../manager/github-actions/extract.spec.ts    | 112 ++++++++++++++++++
 lib/modules/manager/github-actions/extract.ts |  47 ++++++++
 lib/modules/manager/github-actions/index.ts   |   6 +-
 lib/modules/manager/github-actions/readme.md  |  15 +++
 lib/modules/manager/github-actions/types.ts   |   1 +
 10 files changed, 362 insertions(+), 1 deletion(-)
 create mode 100644 lib/modules/datasource/github-runners/index.spec.ts
 create mode 100644 lib/modules/datasource/github-runners/index.ts
 create mode 100644 lib/modules/datasource/github-runners/readme.md

diff --git a/lib/modules/datasource/api.ts b/lib/modules/datasource/api.ts
index ed22a7daa4..0abd7bfd7e 100644
--- a/lib/modules/datasource/api.ts
+++ b/lib/modules/datasource/api.ts
@@ -25,6 +25,7 @@ import { GitRefsDatasource } from './git-refs';
 import { GitTagsDatasource } from './git-tags';
 import { GithubReleaseAttachmentsDatasource } from './github-release-attachments';
 import { GithubReleasesDatasource } from './github-releases';
+import { GithubRunnersDatasource } from './github-runners';
 import { GithubTagsDatasource } from './github-tags';
 import { GitlabPackagesDatasource } from './gitlab-packages';
 import { GitlabReleasesDatasource } from './gitlab-releases';
@@ -90,6 +91,7 @@ api.set(
   new GithubReleaseAttachmentsDatasource()
 );
 api.set(GithubReleasesDatasource.id, new GithubReleasesDatasource());
+api.set(GithubRunnersDatasource.id, new GithubRunnersDatasource());
 api.set(GithubTagsDatasource.id, new GithubTagsDatasource());
 api.set(GitlabPackagesDatasource.id, new GitlabPackagesDatasource());
 api.set(GitlabReleasesDatasource.id, new GitlabReleasesDatasource());
diff --git a/lib/modules/datasource/github-runners/index.spec.ts b/lib/modules/datasource/github-runners/index.spec.ts
new file mode 100644
index 0000000000..2461c625f5
--- /dev/null
+++ b/lib/modules/datasource/github-runners/index.spec.ts
@@ -0,0 +1,30 @@
+import { getPkgReleases } from '..';
+import { GithubRunnersDatasource } from '.';
+
+describe('modules/datasource/github-runners/index', () => {
+  describe('getReleases', () => {
+    it('returns releases if package is known', async () => {
+      const res = await getPkgReleases({
+        datasource: GithubRunnersDatasource.id,
+        packageName: 'ubuntu',
+      });
+
+      expect(res).toMatchObject({
+        releases: [
+          { version: '18.04' },
+          { version: '20.04' },
+          { version: '22.04' },
+        ],
+      });
+    });
+
+    it('returns null if package is unknown', async () => {
+      const res = await getPkgReleases({
+        datasource: GithubRunnersDatasource.id,
+        packageName: 'unknown',
+      });
+
+      expect(res).toBeNull();
+    });
+  });
+});
diff --git a/lib/modules/datasource/github-runners/index.ts b/lib/modules/datasource/github-runners/index.ts
new file mode 100644
index 0000000000..37dda6fdba
--- /dev/null
+++ b/lib/modules/datasource/github-runners/index.ts
@@ -0,0 +1,58 @@
+import { id as dockerVersioningId } from '../../versioning/docker';
+import { Datasource } from '../datasource';
+import type { GetReleasesConfig, Release, ReleaseResult } from '../types';
+
+export class GithubRunnersDatasource extends Datasource {
+  static readonly id = 'github-runners';
+
+  /**
+   * Only add stable runners to the datasource. See datasource readme for details.
+   */
+  private static readonly releases: Record<string, Release[] | undefined> = {
+    ubuntu: [{ version: '22.04' }, { version: '20.04' }, { version: '18.04' }],
+    macos: [
+      { version: '13' },
+      { version: '13-xl' },
+      { version: '12' },
+      { version: '12-xl' },
+      { version: '11' },
+      { version: '10.15' },
+    ],
+    windows: [{ version: '2022' }, { version: '2019' }],
+  };
+
+  public static isValidRunner(
+    runnerName: string,
+    runnerVersion: string
+  ): boolean {
+    const runnerReleases = GithubRunnersDatasource.releases[runnerName];
+    if (!runnerReleases) {
+      return false;
+    }
+
+    const versionExists = runnerReleases.some(
+      ({ version }) => version === runnerVersion
+    );
+
+    return runnerVersion === 'latest' || versionExists;
+  }
+
+  override readonly defaultVersioning = dockerVersioningId;
+
+  constructor() {
+    super(GithubRunnersDatasource.id);
+  }
+
+  override getReleases({
+    packageName,
+  }: GetReleasesConfig): Promise<ReleaseResult | null> {
+    const releases = GithubRunnersDatasource.releases[packageName];
+    const releaseResult: ReleaseResult | null = releases
+      ? {
+          releases,
+          sourceUrl: 'https://github.com/actions/runner-images',
+        }
+      : null;
+    return Promise.resolve(releaseResult);
+  }
+}
diff --git a/lib/modules/datasource/github-runners/readme.md b/lib/modules/datasource/github-runners/readme.md
new file mode 100644
index 0000000000..08f336fe3c
--- /dev/null
+++ b/lib/modules/datasource/github-runners/readme.md
@@ -0,0 +1,11 @@
+This datasource returns a list of all _stable_ runners that are hosted by GitHub.
+This datasource ignores beta releases.
+The datasource is based on [GitHub's `runner-images` repository](https://github.com/actions/runner-images).
+
+Examples: `windows-2019` / `ubuntu-22.04` / `macos-13`
+
+## Maintenance
+
+New _stable_ runner versions must be added to the datasource with a pull request.
+Unstable runners are tagged as `[beta]` in the readme of the [`runner-images` repository](https://github.com/actions/runner-images).
+Once a runner version becomes stable, the `[beta]` tag is removed and the suffix `latest` is added to its YAML label.
diff --git a/lib/modules/manager/github-actions/__snapshots__/extract.spec.ts.snap b/lib/modules/manager/github-actions/__snapshots__/extract.spec.ts.snap
index 47d08862bf..c10544c99c 100644
--- a/lib/modules/manager/github-actions/__snapshots__/extract.spec.ts.snap
+++ b/lib/modules/manager/github-actions/__snapshots__/extract.spec.ts.snap
@@ -87,6 +87,33 @@ exports[`modules/manager/github-actions/extract extractPackageFile() extracts mu
     "replaceString": "actions-rs/cargo@v1.0.3",
     "versioning": "docker",
   },
+  {
+    "autoReplaceStringTemplate": "{{depName}}-{{newValue}}",
+    "currentValue": "latest",
+    "datasource": "github-runners",
+    "depName": "ubuntu",
+    "depType": "github-runner",
+    "replaceString": "ubuntu-latest",
+    "skipReason": "invalid-version",
+  },
+  {
+    "autoReplaceStringTemplate": "{{depName}}-{{newValue}}",
+    "currentValue": "latest",
+    "datasource": "github-runners",
+    "depName": "ubuntu",
+    "depType": "github-runner",
+    "replaceString": "ubuntu-latest",
+    "skipReason": "invalid-version",
+  },
+  {
+    "autoReplaceStringTemplate": "{{depName}}-{{newValue}}",
+    "currentValue": "latest",
+    "datasource": "github-runners",
+    "depName": "ubuntu",
+    "depType": "github-runner",
+    "replaceString": "ubuntu-latest",
+    "skipReason": "invalid-version",
+  },
 ]
 `;
 
@@ -132,6 +159,42 @@ exports[`modules/manager/github-actions/extract extractPackageFile() extracts mu
     "depType": "docker",
     "replaceString": "node:6@sha256:7b65413af120ec5328077775022c78101f103258a1876ec2f83890bce416e896",
   },
+  {
+    "autoReplaceStringTemplate": "{{depName}}-{{newValue}}",
+    "currentValue": "latest",
+    "datasource": "github-runners",
+    "depName": "ubuntu",
+    "depType": "github-runner",
+    "replaceString": "ubuntu-latest",
+    "skipReason": "invalid-version",
+  },
+  {
+    "autoReplaceStringTemplate": "{{depName}}-{{newValue}}",
+    "currentValue": "latest",
+    "datasource": "github-runners",
+    "depName": "ubuntu",
+    "depType": "github-runner",
+    "replaceString": "ubuntu-latest",
+    "skipReason": "invalid-version",
+  },
+  {
+    "autoReplaceStringTemplate": "{{depName}}-{{newValue}}",
+    "currentValue": "latest",
+    "datasource": "github-runners",
+    "depName": "ubuntu",
+    "depType": "github-runner",
+    "replaceString": "ubuntu-latest",
+    "skipReason": "invalid-version",
+  },
+  {
+    "autoReplaceStringTemplate": "{{depName}}-{{newValue}}",
+    "currentValue": "latest",
+    "datasource": "github-runners",
+    "depName": "ubuntu",
+    "depType": "github-runner",
+    "replaceString": "ubuntu-latest",
+    "skipReason": "invalid-version",
+  },
   {
     "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}",
     "currentDigest": undefined,
@@ -159,6 +222,15 @@ exports[`modules/manager/github-actions/extract extractPackageFile() extracts mu
     "depType": "service",
     "replaceString": "postgres:10",
   },
+  {
+    "autoReplaceStringTemplate": "{{depName}}-{{newValue}}",
+    "currentValue": "latest",
+    "datasource": "github-runners",
+    "depName": "ubuntu",
+    "depType": "github-runner",
+    "replaceString": "ubuntu-latest",
+    "skipReason": "invalid-version",
+  },
   {
     "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}",
     "currentDigest": undefined,
@@ -168,5 +240,14 @@ exports[`modules/manager/github-actions/extract extractPackageFile() extracts mu
     "depType": "container",
     "replaceString": "node:16-bullseye",
   },
+  {
+    "autoReplaceStringTemplate": "{{depName}}-{{newValue}}",
+    "currentValue": "latest",
+    "datasource": "github-runners",
+    "depName": "ubuntu",
+    "depType": "github-runner",
+    "replaceString": "ubuntu-latest",
+    "skipReason": "invalid-version",
+  },
 ]
 `;
diff --git a/lib/modules/manager/github-actions/extract.spec.ts b/lib/modules/manager/github-actions/extract.spec.ts
index ec9bea9051..7c20146113 100644
--- a/lib/modules/manager/github-actions/extract.spec.ts
+++ b/lib/modules/manager/github-actions/extract.spec.ts
@@ -2,6 +2,35 @@ import { Fixtures } from '../../../../test/fixtures';
 import { GlobalConfig } from '../../../config/global';
 import { extractPackageFile } from '.';
 
+const runnerTestWorkflow = `
+jobs:
+  test1:
+    runs-on: ubuntu-latest
+  test2:
+    runs-on:
+      ubuntu-22.04
+  test3:
+    runs-on: "macos-12-xl"
+  test4:
+    runs-on: 'macos-latest'
+  test5:
+    runs-on: |
+      windows-2019
+  test6:
+    runs-on: >
+      windows-2022
+  test7:
+    runs-on: [windows-2022, selfhosted]
+  test8:
+     runs-on: \${{ env.RUNNER }}
+  test9:
+     runs-on:
+       group: ubuntu-runners
+       labels: ubuntu-20.04-16core
+  test10:
+      runs-on: abc-123
+`;
+
 describe('modules/manager/github-actions/extract', () => {
   beforeEach(() => {
     GlobalConfig.reset();
@@ -121,6 +150,7 @@ describe('modules/manager/github-actions/extract', () => {
         Fixtures.get('workflow_3.yml'),
         'workflow_3.yml'
       );
+
       expect(res?.deps).toMatchObject([
         {
           currentValue: 'v0.13.1',
@@ -155,6 +185,20 @@ describe('modules/manager/github-actions/extract', () => {
           replaceString: '"actions/checkout@v1.1.2"',
           versioning: 'docker',
         },
+        {
+          currentValue: 'latest',
+          datasource: 'github-runners',
+          depName: 'ubuntu',
+          depType: 'github-runner',
+          replaceString: 'ubuntu-latest',
+        },
+        {
+          currentValue: 'latest',
+          datasource: 'github-runners',
+          depName: 'ubuntu',
+          depType: 'github-runner',
+          replaceString: 'ubuntu-latest',
+        },
       ]);
     });
 
@@ -342,5 +386,73 @@ describe('modules/manager/github-actions/extract', () => {
         },
       ]);
     });
+
+    it('extracts multiple action runners from yaml configuration file', () => {
+      const res = extractPackageFile(runnerTestWorkflow, 'workflow.yml');
+
+      expect(res?.deps).toMatchObject([
+        {
+          depName: 'ubuntu',
+          currentValue: 'latest',
+          replaceString: 'ubuntu-latest',
+          depType: 'github-runner',
+          datasource: 'github-runners',
+          autoReplaceStringTemplate: '{{depName}}-{{newValue}}',
+          skipReason: 'invalid-version',
+        },
+        {
+          depName: 'ubuntu',
+          currentValue: '22.04',
+          replaceString: 'ubuntu-22.04',
+          depType: 'github-runner',
+          datasource: 'github-runners',
+          autoReplaceStringTemplate: '{{depName}}-{{newValue}}',
+        },
+        {
+          depName: 'macos',
+          currentValue: '12-xl',
+          replaceString: 'macos-12-xl',
+          depType: 'github-runner',
+          datasource: 'github-runners',
+          autoReplaceStringTemplate: '{{depName}}-{{newValue}}',
+        },
+        {
+          depName: 'macos',
+          currentValue: 'latest',
+          replaceString: 'macos-latest',
+          depType: 'github-runner',
+          datasource: 'github-runners',
+          autoReplaceStringTemplate: '{{depName}}-{{newValue}}',
+          skipReason: 'invalid-version',
+        },
+        {
+          depName: 'windows',
+          currentValue: '2019',
+          replaceString: 'windows-2019',
+          depType: 'github-runner',
+          datasource: 'github-runners',
+          autoReplaceStringTemplate: '{{depName}}-{{newValue}}',
+        },
+        {
+          depName: 'windows',
+          currentValue: '2022',
+          replaceString: 'windows-2022',
+          depType: 'github-runner',
+          datasource: 'github-runners',
+          autoReplaceStringTemplate: '{{depName}}-{{newValue}}',
+        },
+        {
+          depName: 'windows',
+          currentValue: '2022',
+          replaceString: 'windows-2022',
+          depType: 'github-runner',
+          datasource: 'github-runners',
+          autoReplaceStringTemplate: '{{depName}}-{{newValue}}',
+        },
+      ]);
+      expect(
+        res?.deps.filter((d) => d.datasource === 'github-runners')
+      ).toHaveLength(7);
+    });
   });
 });
diff --git a/lib/modules/manager/github-actions/extract.ts b/lib/modules/manager/github-actions/extract.ts
index 0671794f4f..4b2916c9fc 100644
--- a/lib/modules/manager/github-actions/extract.ts
+++ b/lib/modules/manager/github-actions/extract.ts
@@ -2,7 +2,9 @@ import is from '@sindresorhus/is';
 import { load } from 'js-yaml';
 import { GlobalConfig } from '../../../config/global';
 import { logger } from '../../../logger';
+import { isNotNullOrUndefined } from '../../../util/array';
 import { newlineRegex, regEx } from '../../../util/regex';
+import { GithubRunnersDatasource } from '../../datasource/github-runners';
 import { GithubTagsDatasource } from '../../datasource/github-tags';
 import * as dockerVersioning from '../../versioning/docker';
 import { getDep } from '../dockerfile/extract';
@@ -112,6 +114,49 @@ function extractContainer(container: unknown): PackageDependency | undefined {
   return undefined;
 }
 
+const runnerVersionRegex = regEx(
+  /^\s*(?<depName>[a-zA-Z]+)-(?<currentValue>[^\s]+)/
+);
+
+function extractRunner(runner: string): PackageDependency | null {
+  const runnerVersionGroups = runnerVersionRegex.exec(runner)?.groups;
+  if (!runnerVersionGroups) {
+    return null;
+  }
+
+  const { depName, currentValue } = runnerVersionGroups;
+
+  if (!GithubRunnersDatasource.isValidRunner(depName, currentValue)) {
+    return null;
+  }
+
+  const dependency: PackageDependency = {
+    depName,
+    currentValue,
+    replaceString: `${depName}-${currentValue}`,
+    depType: 'github-runner',
+    datasource: GithubRunnersDatasource.id,
+    autoReplaceStringTemplate: '{{depName}}-{{newValue}}',
+  };
+
+  if (!dockerVersioning.api.isValid(currentValue)) {
+    dependency.skipReason = 'invalid-version';
+  }
+
+  return dependency;
+}
+
+function extractRunners(runner: unknown): PackageDependency[] {
+  const runners: string[] = [];
+  if (is.string(runner)) {
+    runners.push(runner);
+  } else if (is.array(runner, is.string)) {
+    runners.push(...runner);
+  }
+
+  return runners.map(extractRunner).filter(isNotNullOrUndefined);
+}
+
 function extractWithYAMLParser(
   content: string,
   packageFile: string
@@ -144,6 +189,8 @@ function extractWithYAMLParser(
         deps.push(dep);
       }
     }
+
+    deps.push(...extractRunners(job?.['runs-on']));
   }
 
   return deps;
diff --git a/lib/modules/manager/github-actions/index.ts b/lib/modules/manager/github-actions/index.ts
index 254cc35903..8ed2e8898e 100644
--- a/lib/modules/manager/github-actions/index.ts
+++ b/lib/modules/manager/github-actions/index.ts
@@ -1,4 +1,5 @@
 import type { Category } from '../../../constants';
+import { GithubRunnersDatasource } from '../../datasource/github-runners';
 import { GithubTagsDatasource } from '../../datasource/github-tags';
 export { extractPackageFile } from './extract';
 
@@ -11,4 +12,7 @@ export const defaultConfig = {
 
 export const categories: Category[] = ['ci'];
 
-export const supportedDatasources = [GithubTagsDatasource.id];
+export const supportedDatasources = [
+  GithubTagsDatasource.id,
+  GithubRunnersDatasource.id,
+];
diff --git a/lib/modules/manager/github-actions/readme.md b/lib/modules/manager/github-actions/readme.md
index 19603da5e6..891eaf827b 100644
--- a/lib/modules/manager/github-actions/readme.md
+++ b/lib/modules/manager/github-actions/readme.md
@@ -24,3 +24,18 @@ If you want to automatically pin action digests add the `helpers:pinGitHubAction
   "extends": ["helpers:pinGitHubActionDigests"]
 }
 ```
+
+Renovate ignores any GitHub runners which are configured in variables.
+For example, Renovate ignores the runner configured in the `RUNNER` variable:
+
+```yaml
+name: build
+on: [push]
+
+env:
+  RUNNER: ubuntu-20.04
+
+jobs:
+  build:
+    runs-on: ${{ env.RUNNER }}
+```
diff --git a/lib/modules/manager/github-actions/types.ts b/lib/modules/manager/github-actions/types.ts
index 4787ca108a..7b81e0fd79 100644
--- a/lib/modules/manager/github-actions/types.ts
+++ b/lib/modules/manager/github-actions/types.ts
@@ -18,6 +18,7 @@ export interface Container {
 export interface Job {
   container?: string | Container;
   services?: Record<string, string | Container>;
+  'runs-on'?: string | string[];
 }
 
 /**
-- 
GitLab