From 8dd40fe523f60a31511e7dd5a32a153bc49bdb6a Mon Sep 17 00:00:00 2001
From: Maxime Brunet <max@brnt.mx>
Date: Thu, 4 Aug 2022 21:57:33 -0700
Subject: [PATCH] feat(github-actions): update job and service containers
 (#16770)

---
 .../__fixtures__/workflow_1.yml               | 11 +++
 .../__snapshots__/extract.spec.ts.snap        | 38 ++++++++++-
 .../manager/github-actions/extract.spec.ts    | 29 ++++++--
 lib/modules/manager/github-actions/extract.ts | 68 +++++++++++++++++--
 lib/modules/manager/github-actions/types.ts   | 31 +++++++++
 5 files changed, 164 insertions(+), 13 deletions(-)
 create mode 100644 lib/modules/manager/github-actions/types.ts

diff --git a/lib/modules/manager/github-actions/__fixtures__/workflow_1.yml b/lib/modules/manager/github-actions/__fixtures__/workflow_1.yml
index 1513408eee..42fb8da096 100644
--- a/lib/modules/manager/github-actions/__fixtures__/workflow_1.yml
+++ b/lib/modules/manager/github-actions/__fixtures__/workflow_1.yml
@@ -30,3 +30,14 @@ jobs:
     steps:
       - name: Node 6 test
         uses: docker://node:6@sha256:7b65413af120ec5328077775022c78101f103258a1876ec2f83890bce416e896
+  container-job:
+    runs-on: ubuntu-latest
+    container: node:16-bullseye
+    services:
+      redis:
+        image: redis:5
+      postgres: postgres:10
+  container-job-with-image-keyword:
+    runs-on: ubuntu-latest
+    container:
+      image: node:16-bullseye
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 721889f778..8bf855a07b 100644
--- a/lib/modules/manager/github-actions/__snapshots__/extract.spec.ts.snap
+++ b/lib/modules/manager/github-actions/__snapshots__/extract.spec.ts.snap
@@ -157,7 +157,6 @@ Array [
     "depName": "replicated/dockerfilelint",
     "depType": "docker",
     "replaceString": "replicated/dockerfilelint",
-    "versioning": "docker",
   },
   Object {
     "autoReplaceStringTemplate": "{{depName}}/cli@{{#if newDigest}}{{newDigest}}{{#if newValue}} # tag={{newValue}}{{/if}}{{/if}}{{#unless newDigest}}{{newValue}}{{/unless}}",
@@ -178,7 +177,42 @@ Array [
     "depName": "node",
     "depType": "docker",
     "replaceString": "node:6@sha256:7b65413af120ec5328077775022c78101f103258a1876ec2f83890bce416e896",
-    "versioning": "docker",
+  },
+  Object {
+    "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}",
+    "currentDigest": undefined,
+    "currentValue": "16-bullseye",
+    "datasource": "docker",
+    "depName": "node",
+    "depType": "container",
+    "replaceString": "node:16-bullseye",
+  },
+  Object {
+    "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}",
+    "currentDigest": undefined,
+    "currentValue": "5",
+    "datasource": "docker",
+    "depName": "redis",
+    "depType": "service",
+    "replaceString": "redis:5",
+  },
+  Object {
+    "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}",
+    "currentDigest": undefined,
+    "currentValue": "10",
+    "datasource": "docker",
+    "depName": "postgres",
+    "depType": "service",
+    "replaceString": "postgres:10",
+  },
+  Object {
+    "autoReplaceStringTemplate": "{{depName}}{{#if newValue}}:{{newValue}}{{/if}}{{#if newDigest}}@{{newDigest}}{{/if}}",
+    "currentDigest": undefined,
+    "currentValue": "16-bullseye",
+    "datasource": "docker",
+    "depName": "node",
+    "depType": "container",
+    "replaceString": "node:16-bullseye",
   },
 ]
 `;
diff --git a/lib/modules/manager/github-actions/extract.spec.ts b/lib/modules/manager/github-actions/extract.spec.ts
index 55ce36032c..6b541887da 100644
--- a/lib/modules/manager/github-actions/extract.spec.ts
+++ b/lib/modules/manager/github-actions/extract.spec.ts
@@ -4,19 +4,33 @@ import { extractPackageFile } from '.';
 describe('modules/manager/github-actions/extract', () => {
   describe('extractPackageFile()', () => {
     it('returns null for empty', () => {
-      expect(extractPackageFile('nothing here')).toBeNull();
+      expect(
+        extractPackageFile('nothing here', 'empty-workflow.yml')
+      ).toBeNull();
+    });
+
+    it('returns null for invalid yaml', () => {
+      expect(
+        extractPackageFile('nothing here: [', 'invalid-workflow.yml')
+      ).toBeNull();
     });
 
     it('extracts multiple docker image lines from yaml configuration file', () => {
-      const res = extractPackageFile(Fixtures.get('workflow_1.yml'));
+      const res = extractPackageFile(
+        Fixtures.get('workflow_1.yml'),
+        'workflow_1.yml'
+      );
       expect(res?.deps).toMatchSnapshot();
       expect(res?.deps.filter((d) => d.datasource === 'docker')).toHaveLength(
-        2
+        6
       );
     });
 
     it('extracts multiple action tag lines from yaml configuration file', () => {
-      const res = extractPackageFile(Fixtures.get('workflow_2.yml'));
+      const res = extractPackageFile(
+        Fixtures.get('workflow_2.yml'),
+        'workflow_2.yml'
+      );
       expect(res?.deps).toMatchSnapshot();
       expect(
         res?.deps.filter((d) => d.datasource === 'github-tags')
@@ -24,7 +38,10 @@ describe('modules/manager/github-actions/extract', () => {
     });
 
     it('extracts multiple action tag lines with double quotes and comments', () => {
-      const res = extractPackageFile(Fixtures.get('workflow_3.yml'));
+      const res = extractPackageFile(
+        Fixtures.get('workflow_3.yml'),
+        'workflow_3.yml'
+      );
       expect(res?.deps).toMatchSnapshot([
         {
           currentValue: 'v0.13.1',
@@ -78,7 +95,7 @@ describe('modules/manager/github-actions/extract', () => {
             - name: "quoted, no comment, outdated"
               uses: "actions/setup-java@v2"`;
 
-      const res = extractPackageFile(yamlContent);
+      const res = extractPackageFile(yamlContent, 'workflow.yml');
       expect(res?.deps).toMatchObject([
         {
           depName: 'actions/setup-node',
diff --git a/lib/modules/manager/github-actions/extract.ts b/lib/modules/manager/github-actions/extract.ts
index c5162e583a..874e8b4aed 100644
--- a/lib/modules/manager/github-actions/extract.ts
+++ b/lib/modules/manager/github-actions/extract.ts
@@ -1,11 +1,13 @@
+import { load } from 'js-yaml';
 import { logger } from '../../../logger';
 import { newlineRegex, regEx } from '../../../util/regex';
 import { GithubTagsDatasource } from '../../datasource/github-tags';
 import * as dockerVersioning from '../../versioning/docker';
 import { getDep } from '../dockerfile/extract';
 import type { PackageDependency, PackageFile } from '../types';
+import type { Container, Workflow } from './types';
 
-const dockerRe = regEx(/^\s+uses: docker:\/\/([^"]+)\s*$/);
+const dockerActionRe = regEx(/^\s+uses: ['"]?docker:\/\/([^'"]+)\s*$/);
 const actionRe = regEx(
   /^\s+-?\s+?uses: (?<replaceString>['"]?(?<depName>[\w-]+\/[\w-]+)(?<path>\/.*)?@(?<currentValue>[^\s'"]+)['"]?(?:\s+#\s+(?:renovate:\s+)?tag=(?<tag>\S+))?)/
 );
@@ -13,20 +15,19 @@ const actionRe = regEx(
 // SHA1 or SHA256, see https://github.blog/2020-10-19-git-2-29-released/
 const shaRe = regEx(/^[a-z0-9]{40}|[a-z0-9]{64}$/);
 
-export function extractPackageFile(content: string): PackageFile | null {
-  logger.trace('github-actions.extractPackageFile()');
+function extractWithRegex(content: string): PackageDependency[] {
+  logger.trace('github-actions.extractWithRegex()');
   const deps: PackageDependency[] = [];
   for (const line of content.split(newlineRegex)) {
     if (line.trim().startsWith('#')) {
       continue;
     }
 
-    const dockerMatch = dockerRe.exec(line);
+    const dockerMatch = dockerActionRe.exec(line);
     if (dockerMatch) {
       const [, currentFrom] = dockerMatch;
       const dep = getDep(currentFrom);
       dep.depType = 'docker';
-      dep.versioning = dockerVersioning.id;
       deps.push(dep);
       continue;
     }
@@ -68,6 +69,63 @@ export function extractPackageFile(content: string): PackageFile | null {
       deps.push(dep);
     }
   }
+  return deps;
+}
+
+function extractContainer(container: string | Container): PackageDependency {
+  let dep: PackageDependency;
+  if (typeof container === 'string') {
+    dep = getDep(container);
+  } else {
+    dep = getDep(container?.image);
+  }
+  return dep;
+}
+
+function extractWithYAMLParser(
+  content: string,
+  filename: string
+): PackageDependency[] {
+  logger.trace('github-actions.extractWithYAMLParser()');
+  const deps: PackageDependency[] = [];
+
+  let pkg: Workflow;
+  try {
+    pkg = load(content, { json: true }) as Workflow;
+  } catch (err) {
+    logger.debug(
+      { filename, err },
+      'Failed to parse GitHub Actions Workflow YAML'
+    );
+    return [];
+  }
+
+  for (const job of Object.values(pkg.jobs ?? {})) {
+    if (job.container !== undefined) {
+      const dep = extractContainer(job.container);
+      dep.depType = 'container';
+      deps.push(dep);
+    }
+
+    for (const service of Object.values(job.services ?? {})) {
+      const dep = extractContainer(service);
+      dep.depType = 'service';
+      deps.push(dep);
+    }
+  }
+
+  return deps;
+}
+
+export function extractPackageFile(
+  content: string,
+  filename: string
+): PackageFile | null {
+  logger.trace('github-actions.extractPackageFile()');
+  const deps = [
+    ...extractWithRegex(content),
+    ...extractWithYAMLParser(content, filename),
+  ];
   if (!deps.length) {
     return null;
   }
diff --git a/lib/modules/manager/github-actions/types.ts b/lib/modules/manager/github-actions/types.ts
new file mode 100644
index 0000000000..4787ca108a
--- /dev/null
+++ b/lib/modules/manager/github-actions/types.ts
@@ -0,0 +1,31 @@
+/**
+ * Container describes a Docker container used within a {@link Job}.
+ * {@link https://docs.github.com/en/actions/using-containerized-services}
+ *
+ * @param image - The Docker image to use as the container to run the action. The value can be the Docker Hub image name or a registry name.
+ */
+export interface Container {
+  image: string;
+}
+
+/**
+ * Job describes a single job within a {@link Workflow}.
+ * {@link https://docs.github.com/en/actions/using-jobs}
+ *
+ * @param container - {@link https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idcontainer}
+ * @param services - {@link https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idservices}
+ */
+export interface Job {
+  container?: string | Container;
+  services?: Record<string, string | Container>;
+}
+
+/**
+ * Workflow describes a GitHub Actions Workflow.
+ * {@link https://docs.github.com/en/actions/using-workflows}
+ *
+ * @param jobs - {@link https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobs}
+ */
+export interface Workflow {
+  jobs: Record<string, Job>;
+}
-- 
GitLab