You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by oc...@apache.org on 2021/02/16 22:39:25 UTC

[trafficcontrol] branch master updated: Workflow to check for and PR minor Go revision updates (#5506)

This is an automated email from the ASF dual-hosted git repository.

ocket8888 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git


The following commit(s) were added to refs/heads/master by this push:
     new cbf74a9  Workflow to check for and PR minor Go revision updates (#5506)
cbf74a9 is described below

commit cbf74a9f3b23e3ea2e25c0cb67a18038c2426ebc
Author: Zach Hoffman <zr...@apache.org>
AuthorDate: Tue Feb 16 15:39:11 2021 -0700

    Workflow to check for and PR minor Go revision updates (#5506)
    
    * Workflow to open a PR if a minor Go version update is available
    
    * Check if the branch or PR exists before trying to create it
    
    * Tests
    
    * Rename workflow
    
    * Quote and explain cron expression
    
    * Add workflow_dispatch trigger
    
    * Do not error out if a "go version" label was not found
    
    * Spaces -> tabs
    
    * Rename GoPRMaker module to go_pr_maker
    
    * Reorder imports
    
    * Add docstrings
    
    * Remove HTML comments
    
    * Revise grammar
    
    * Update golang.org/x/ dependencies after updating the Go version
    
    * If running from the apache organization, only run for the master branch
    
    * Mention in the PR template that the PR also updates golang.org/x/
    dependencies
    
    * Remove `f`s from f-strings that can be regular strings
    
    * Remove unused import
---
 .github/actions/pr-to-update-go/README.rst         |  74 ++++
 .../pr-to-update-go/pr_to_update_go/__main__.py    |  32 ++
 .../pr-to-update-go/pr_to_update_go/constants.py   |  23 ++
 .../pr-to-update-go/pr_to_update_go/go_pr_maker.py | 390 +++++++++++++++++++++
 .../pr-to-update-go/pr_to_update_go/pr_template.md |  79 +++++
 .github/actions/pr-to-update-go/requirements.txt   |  14 +
 .github/actions/pr-to-update-go/setup.cfg          |  37 ++
 .github/actions/pr-to-update-go/setup.py           |  20 ++
 .../pr-to-update-go/tests/test_go_pr_maker.py      |  36 ++
 .../actions/pr-to-update-go/update_golang_org_x.sh |  56 +++
 .github/workflows/pr-to-update-go.yml              |  48 +++
 11 files changed, 809 insertions(+)

diff --git a/.github/actions/pr-to-update-go/README.rst b/.github/actions/pr-to-update-go/README.rst
new file mode 100644
index 0000000..c902404
--- /dev/null
+++ b/.github/actions/pr-to-update-go/README.rst
@@ -0,0 +1,74 @@
+..
+..
+.. Licensed under the Apache License, Version 2.0 (the "License");
+.. you may not use this file except in compliance with the License.
+.. You may obtain a copy of the License at
+..
+..     http://www.apache.org/licenses/LICENSE-2.0
+..
+.. Unless required by applicable law or agreed to in writing, software
+.. distributed under the License is distributed on an "AS IS" BASIS,
+.. WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+.. See the License for the specific language governing permissions and
+.. limitations under the License.
+..
+
+***************
+pr-to-update-go
+***************
+
+Opens a PR if a new minor Go revision is available.
+
+For example, if the ``GO_VERSION`` contains ``1.14.7`` but Go versions 1.15.1 and 1.14.8 are available, it will
+
+1. Create a branch named ``go-1.14.8`` to update the repo's Go version to 1.14.8
+2. Updates all golang.org/x/ dependencies of the project, since these are meant to be updated with the Go compiler.
+3. Open a PR targeting the ``master`` branch from branch ``go-1.14.8``
+
+Other behavior in this scenario:
+
+- If a branch named ``go-1.14.8`` already exists, no additional branch is created.
+- If a PR titled *Update Go version to 1.14.8* already exists, no additional PR is opened.
+
+Environment Variables
+=====================
+
++----------------------------+----------------------------------------------------------------------------------+
+| Environment Variable Name  | Value                                                                            |
++============================+==================================================================================+
+| ``GIT_AUTHOR_NAME``        | Optional. The username to associate with the commit that updates the Go version. |
++----------------------------+----------------------------------------------------------------------------------+
+| ``GITHUB_TOKEN``           | Required. ``${{ github.token }}`` or ``${{ secrets.GITHUB_TOKEN }}``             |
++----------------------------+----------------------------------------------------------------------------------+
+| ``GO_VERSION_FILE``        | Required. The file in the repo containing the version of Go used by the repo.    |
++----------------------------+----------------------------------------------------------------------------------+
+
+
+Outputs
+=======
+
+``exit-code``
+-------------
+
+Exit code is 0 unless an error was encountered.
+
+Example usage
+=============
+
+.. code-block:: yaml
+
+	- name: PR to Update Go
+	  run: python3 -m pr_to_update_go
+	  env:
+	    GIT_AUTHOR_NAME: asfgit
+	    GITHUB_TOKEN: ${{ github.token }}
+	    GO_VERSION_FILE: GO_VERSION
+
+Tests
+=====
+
+To run the unit tests:
+
+.. code-block:: shell
+
+	python3 -m unittest discover ./tests
diff --git a/.github/actions/pr-to-update-go/pr_to_update_go/__main__.py b/.github/actions/pr-to-update-go/pr_to_update_go/__main__.py
new file mode 100644
index 0000000..99b1c09
--- /dev/null
+++ b/.github/actions/pr-to-update-go/pr_to_update_go/__main__.py
@@ -0,0 +1,32 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+import os
+import sys
+
+from github.MainClass import Github
+
+from pr_to_update_go.go_pr_maker import GoPRMaker
+from pr_to_update_go.constants import ENV_GITHUB_TOKEN
+
+
+def main() -> None:
+	try:
+		github_token: str = os.environ[ENV_GITHUB_TOKEN]
+	except KeyError:
+		print(f'Environment variable {ENV_GITHUB_TOKEN} must be defined.')
+		sys.exit(1)
+	gh = Github(login_or_token=github_token)
+	GoPRMaker(gh).run()
+
+
+main()
diff --git a/.github/actions/pr-to-update-go/pr_to_update_go/constants.py b/.github/actions/pr-to-update-go/pr_to_update_go/constants.py
new file mode 100644
index 0000000..b3d8770
--- /dev/null
+++ b/.github/actions/pr-to-update-go/pr_to_update_go/constants.py
@@ -0,0 +1,23 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+from typing import Final
+
+ENV_GIT_AUTHOR_NAME: Final = 'GIT_AUTHOR_NAME'
+ENV_GITHUB_REPOSITORY: Final = 'GITHUB_REPOSITORY'
+ENV_GITHUB_REPOSITORY_OWNER: Final = 'GITHUB_REPOSITORY_OWNER'
+ENV_GITHUB_TOKEN: Final = 'GITHUB_TOKEN'
+ENV_GO_VERSION_FILE: Final = 'GO_VERSION_FILE'
+GIT_AUTHOR_EMAIL_TEMPLATE: Final = '{git_author_name}@users.noreply.github.com'
+GO_REPO_NAME: Final = 'golang/go'
+GO_VERSION_URL: Final = 'https://golang.org/dl/?mode=json'
+RELEASE_PAGE_URL: Final = 'https://golang.org/doc/devel/release.html'
diff --git a/.github/actions/pr-to-update-go/pr_to_update_go/go_pr_maker.py b/.github/actions/pr-to-update-go/pr_to_update_go/go_pr_maker.py
new file mode 100644
index 0000000..ada3672
--- /dev/null
+++ b/.github/actions/pr-to-update-go/pr_to_update_go/go_pr_maker.py
@@ -0,0 +1,390 @@
+#!/usr/bin/env python3
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+"""
+Generate pull requests that update a repository's Go version.
+
+Classes:
+
+    GoPRMaker
+
+"""
+import json
+import os
+import re
+import subprocess
+import sys
+from typing import Union
+
+import requests
+from github.Branch import Branch
+from github.Commit import Commit
+from github.GitCommit import GitCommit
+from github.GitTree import GitTree
+from github.GithubObject import NotSet
+from github.InputGitTreeElement import InputGitTreeElement
+from github.Requester import Requester
+
+from requests import Response
+
+from github.GithubException import BadCredentialsException, GithubException, UnknownObjectException
+from github.InputGitAuthor import InputGitAuthor
+from github.Label import Label
+from github.MainClass import Github
+from github.Milestone import Milestone
+from github.PaginatedList import PaginatedList
+from github.PullRequest import PullRequest
+from github.Repository import Repository
+
+from pr_to_update_go.constants import ENV_GITHUB_TOKEN, GO_VERSION_URL, ENV_GITHUB_REPOSITORY, \
+	ENV_GITHUB_REPOSITORY_OWNER, GO_REPO_NAME, RELEASE_PAGE_URL, ENV_GO_VERSION_FILE, \
+	ENV_GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL_TEMPLATE
+
+
+class GoPRMaker:
+	"""
+	A class to generate pull requests for the purpose of updating the Go version in a repository.
+	"""
+	gh: Github
+	latest_go_version: str
+	repo: Repository
+	author: InputGitAuthor
+
+	def __init__(self, gh: Github) -> None:
+		"""
+		:param gh: Github
+		:rtype: None
+		"""
+		self.gh = gh
+		repo_name: str = self.get_repo_name()
+		self.repo = self.get_repo(repo_name)
+
+		try:
+			git_author_name = self.getenv(ENV_GIT_AUTHOR_NAME)
+			git_author_email = GIT_AUTHOR_EMAIL_TEMPLATE.format(git_author_name=git_author_name)
+			self.author = InputGitAuthor(git_author_name, git_author_email)
+		except KeyError:
+			self.author = NotSet
+			print('Will commit using the default author')
+
+	def branch_exists(self, branch: str) -> bool:
+		"""
+		:param branch:
+		:type branch:
+		:return:
+		:rtype: bool
+		"""
+		try:
+			repo_go_version = self.get_repo_go_version(branch)
+			if self.latest_go_version == repo_go_version:
+				print(f'Branch {branch} already exists')
+				return True
+		except GithubException as e:
+			message = e.data.get('message')
+			if not re.match(r'No commit found for the ref', message):
+				raise e
+		return False
+
+	def update_branch(self, branch_name: str, sha: str) -> None:
+		"""
+		:param branch_name:
+		:type branch_name:
+		:param sha:
+		:type sha:
+		:return:
+		:rtype: None
+		"""
+		requester: Requester = self.repo._requester
+		patch_parameters = {
+			'sha': sha,
+		}
+		requester.requestJsonAndCheck(
+			'PATCH', self.repo.url + f'/git/refs/heads/{branch_name}', input=patch_parameters
+		)
+		return
+
+	def run(self) -> None:
+		"""
+		:return:
+		:rtype: None
+		"""
+		repo_go_version = self.get_repo_go_version()
+		self.latest_go_version = self.get_latest_major_upgrade(repo_go_version)
+		commit_message: str = f'Update Go version to {self.latest_go_version}'
+
+		source_branch_name: str = f'go-{self.latest_go_version}'
+		target_branch: str = 'master'
+		if repo_go_version == self.latest_go_version:
+			print(f'Go version is up-to-date on {target_branch}, nothing to do.')
+			return
+
+		if not self.branch_exists(source_branch_name):
+			commit: Commit = self.set_go_version(self.latest_go_version, commit_message,
+				source_branch_name)
+			update_golang_org_x_commit: Union[GitCommit, None] = self.update_golang_org_x(commit)
+			if isinstance(update_golang_org_x_commit, GitCommit):
+				sha: str = update_golang_org_x_commit.sha
+				self.update_branch(source_branch_name, sha)
+
+		owner: str = self.get_repo_owner()
+		self.create_pr(self.latest_go_version, commit_message, owner, source_branch_name,
+			target_branch)
+
+	@staticmethod
+	def getenv(env_name: str) -> str:
+		"""
+		:param env_name: str
+		:return:
+		:rtype: str
+		"""
+		return os.environ[env_name]
+
+	def get_repo(self, repo_name: str) -> Repository:
+		"""
+		:param repo_name: str
+		:return:
+		:rtype: Repository
+		"""
+		try:
+			repo: Repository = self.gh.get_repo(repo_name)
+		except BadCredentialsException:
+			print(f'Credentials from {ENV_GITHUB_TOKEN} were bad.')
+			sys.exit(1)
+		return repo
+
+	@staticmethod
+	def get_major_version(from_go_version: str) -> str:
+		"""
+		:param from_go_version: str
+		:return:
+		:rtype: str
+		"""
+		return re.search(pattern=r'^\d+\.\d+', string=from_go_version).group(0)
+
+	def get_latest_major_upgrade(self, from_go_version: str) -> str:
+		"""
+		:param from_go_version: str
+		:return:
+		:rtype: str
+		"""
+		major_version = self.get_major_version(from_go_version)
+		go_version_response: Response = requests.get(GO_VERSION_URL)
+		go_version_response.raise_for_status()
+		go_version_content: list = json.loads(go_version_response.content)
+		index = 0
+		fetched_go_version: str = ''
+		while True:
+			if not go_version_content[index]['stable']:
+				continue
+			go_version_name: str = go_version_content[index]['version']
+			fetched_go_version = re.search(pattern=r'[\d.]+', string=go_version_name).group(0)
+			if major_version == self.get_major_version(fetched_go_version):
+				break
+			index += 1
+		if major_version != self.get_major_version(fetched_go_version):
+			raise Exception(f'No supported {major_version} Go versions exist.')
+		print(f'Latest version of Go {major_version} is {fetched_go_version}')
+		return fetched_go_version
+
+	def get_repo_name(self) -> str:
+		"""
+		:return:
+		:rtype: str
+		"""
+		repo_name: str = self.getenv(ENV_GITHUB_REPOSITORY)
+		return repo_name
+
+	def get_repo_owner(self) -> str:
+		"""
+		:return:
+		:rtype: str
+		"""
+		repo_name: str = self.getenv(ENV_GITHUB_REPOSITORY_OWNER)
+		return repo_name
+
+	def get_go_milestone(self, go_version: str) -> str:
+		"""
+		:param go_version: str
+		:return:
+		"""
+		go_repo: Repository = self.get_repo(GO_REPO_NAME)
+		milestones: PaginatedList[Milestone] = go_repo.get_milestones(state='all', sort='due_on',
+			direction='desc')
+		milestone_title = f'Go{go_version}'
+		for milestone in milestones:  # type: Milestone
+			if milestone.title == milestone_title:
+				print(f'Found Go milestone {milestone.title}')
+				return milestone.raw_data.get('html_url')
+		raise Exception(f'Could not find a milestone named {milestone_title}.')
+
+	@staticmethod
+	def get_release_notes_page() -> str:
+		"""
+		:return:
+		:rtype: str
+		"""
+		release_history_response: Response = requests.get(RELEASE_PAGE_URL)
+		release_history_response.raise_for_status()
+		return release_history_response.content.decode()
+
+	@staticmethod
+	def get_release_notes(go_version: str, release_notes_content: str) -> str:
+		"""
+		:param go_version: str
+		:param release_notes_content: str
+		:return:
+		:rtype: str
+		"""
+		go_version_pattern = go_version.replace('.', '\\.')
+		release_notes_pattern: str = f'<p>\\s*\\n\\s*go{go_version_pattern}.*?</p>'
+		release_notes_matches = re.search(release_notes_pattern, release_notes_content,
+			re.MULTILINE | re.DOTALL)
+		if release_notes_matches is None:
+			raise Exception(f'Could not find release notes on {RELEASE_PAGE_URL}')
+		release_notes = re.sub(r'[\s\t]+', ' ', release_notes_matches.group(0))
+		return release_notes
+
+	def get_pr_body(self, go_version: str, milestone_url: str) -> str:
+		"""
+		:param go_version: str
+		:param milestone_url: str
+		:return:
+		:rtype: str
+		"""
+		with open(os.path.dirname(__file__) + '/pr_template.md') as file:
+			pr_template = file.read()
+		go_major_version = self.get_major_version(go_version)
+
+		release_notes = self.get_release_notes(go_version, self.get_release_notes_page())
+		pr_body: str = pr_template.format(GO_VERSION=go_version, GO_MAJOR_VERSION=go_major_version,
+			RELEASE_NOTES=release_notes, MILESTONE_URL=milestone_url)
+		print('Templated PR body')
+		return pr_body
+
+	def get_repo_go_version(self, branch: str = 'master') -> str:
+		"""
+		:param branch: str
+		:return:
+		:rtype: str
+		"""
+		return self.repo.get_contents(self.getenv(ENV_GO_VERSION_FILE),
+			f'refs/heads/{branch}').decoded_content.rstrip().decode()
+
+	def set_go_version(self, go_version: str, commit_message: str,
+			source_branch_name: str) -> Commit:
+		"""
+		:param go_version: str
+		:param commit_message: str
+		:param source_branch_name: str
+		:return:
+		:rtype: str
+		"""
+		master: Branch = self.repo.get_branch('master')
+		sha: str = master.commit.sha
+		ref: str = f'refs/heads/{source_branch_name}'
+		self.repo.create_git_ref(ref, sha)
+
+		print(f'Created branch {source_branch_name}')
+		go_version_file: str = self.getenv(ENV_GO_VERSION_FILE)
+		go_file_contents = self.repo.get_contents(go_version_file, ref)
+		kwargs = {'path': go_version_file,
+			'message': commit_message,
+			'content': (go_version + '\n'),
+			'sha': go_file_contents.sha,
+			'branch': source_branch_name,
+		}
+		try:
+			git_author_name = self.getenv(ENV_GIT_AUTHOR_NAME)
+			git_author_email = GIT_AUTHOR_EMAIL_TEMPLATE.format(git_author_name=git_author_name)
+			author: InputGitAuthor = InputGitAuthor(name=git_author_name, email=git_author_email)
+			kwargs['author'] = author
+			kwargs['committer'] = author
+		except KeyError:
+			print('Committing using the default author')
+
+		commit: Commit = self.repo.update_file(**kwargs).get('commit')
+		print(f'Updated {go_version_file} on {self.repo.name}')
+		return commit
+
+	def update_golang_org_x(self, previous_commit: Commit) -> Union[GitCommit, None]:
+		"""
+		:param previous_commit:
+		:type previous_commit:
+		:return:
+		:rtype: Union[GitCommit, None]
+		"""
+		subprocess.run(['git', 'fetch', 'origin'], check=True)
+		subprocess.run(['git', 'checkout', previous_commit.sha], check=True)
+		script_path: str = '.github/actions/pr-to-update-go/update_golang_org_x.sh'
+		subprocess.run([script_path], check=True)
+		files_to_check: list[str] = ['go.mod', 'go.sum', 'vendor/modules.txt']
+		tree_elements: list[InputGitTreeElement] = []
+		for file in files_to_check:
+			diff_process = subprocess.run(['git', 'diff', '--exit-code', '--', file])
+			if diff_process.returncode == 0:
+				continue
+			with open(file) as stream:
+				content: str = stream.read()
+			tree_element: InputGitTreeElement = InputGitTreeElement(path=file, mode='100644',
+				type='blob', content=content)
+			tree_elements.append(tree_element)
+		if len(tree_elements) == 0:
+			print('No golang.org/x/ dependencies need to be updated.')
+			return
+		tree_hash = subprocess.check_output(
+			['git', 'log', '-1', '--pretty=%T', previous_commit.sha]).decode().strip()
+		base_tree: GitTree = self.repo.get_git_tree(sha=tree_hash)
+		tree: GitTree = self.repo.create_git_tree(tree_elements, base_tree)
+		commit_message: str = f'Update golang.org/x/ dependencies for go{self.latest_go_version}'
+		previous_git_commit: GitCommit = self.repo.get_git_commit(previous_commit.sha)
+		git_commit: GitCommit = self.repo.create_git_commit(message=commit_message, tree=tree,
+			parents=[previous_git_commit],
+			author=self.author, committer=self.author)
+		print('Updated golang.org/x/ dependencies')
+		return git_commit
+
+	def create_pr(self, latest_go_version: str, commit_message: str, owner: str,
+			source_branch_name: str, target_branch: str) -> None:
+		"""
+		:param latest_go_version: str
+		:param commit_message: str
+		:param owner: str
+		:param source_branch_name: str
+		:param target_branch: str
+		:return:
+		:rtype: None
+		"""
+		prs: PaginatedList = self.gh.search_issues(
+			f'repo:{self.repo.full_name} is:pr is:open head:{source_branch_name}')
+		for list_item in prs:
+			pr: PullRequest = self.repo.get_pull(list_item.number)
+			if pr.head.ref != source_branch_name:
+				continue
+			print(f'Pull request for branch {source_branch_name} already exists:\n{pr.html_url}')
+			return
+
+		milestone_url: str = self.get_go_milestone(latest_go_version)
+		pr_body: str = self.get_pr_body(latest_go_version, milestone_url)
+		pr: PullRequest = self.repo.create_pull(
+			title=commit_message,
+			body=pr_body,
+			head=f'{owner}:{source_branch_name}',
+			base=target_branch,
+			maintainer_can_modify=True,
+		)
+		try:
+			go_version_label: Label = self.repo.get_label('go version')
+			pr.add_to_labels(go_version_label)
+		except UnknownObjectException:
+			print('Unable to find a label named "go version".')
+		print(f'Created pull request {pr.html_url}')
diff --git a/.github/actions/pr-to-update-go/pr_to_update_go/pr_template.md b/.github/actions/pr-to-update-go/pr_to_update_go/pr_template.md
new file mode 100644
index 0000000..a3ecdd0
--- /dev/null
+++ b/.github/actions/pr-to-update-go/pr_to_update_go/pr_template.md
@@ -0,0 +1,79 @@
+## What does this PR (Pull Request) do?
+- [x] This PR is not related to any Issue
+
+This PR makes the Go components of Traffic Control build using Go version {GO_VERSION} and updates the `golang.org/x/` dependencies..
+
+See the Go {GO_VERSION} [release notes](https://golang.org/doc/devel/release.html#go{GO_MAJOR_VERSION}):
+
+<!--
+The release notes are licensed with the Go license.
+
+Copyright (c) 2009 The Go Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+-->
+> {RELEASE_NOTES}
+
+## Which Traffic Control components are affected by this PR?
+- CDN in a Box - Enroller
+- Grove
+- Traffic Control Client (Go)
+- Traffic Monitor
+- Traffic Ops
+- Traffic Ops ORT
+- Traffic Stats
+- CI tests for Go components
+
+## What is the best way to verify this PR?
+Run unit tests and API tests. Since this is only a patch-level version update, [the only changes]({MILESTONE_URL}?closed=1) were bugfixes. Breaking changes would be unexpected.
+
+## The following criteria are ALL met by this PR
+- [x] Existing tests are sufficient, no additional tests necessary
+- [x] The documentation only mentions the major Go version, no documentation updates necessary.
+- [x] The changelog already mentions updating to Go {GO_MAJOR_VERSION}, no additional changelog message necessary.
+- [x] This PR includes any and all required license headers
+- [x] This PR does not include a database migration
+- [x] This PR **DOES NOT FIX A SERIOUS SECURITY VULNERABILITY** (see [the Apache Software Foundation's security guidelines](https://www.apache.org/security/) for details)
+
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements.  See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership.  The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License.  You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied.  See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
diff --git a/.github/actions/pr-to-update-go/requirements.txt b/.github/actions/pr-to-update-go/requirements.txt
new file mode 100644
index 0000000..05cf2ea
--- /dev/null
+++ b/.github/actions/pr-to-update-go/requirements.txt
@@ -0,0 +1,14 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+PyGithub
+requests
\ No newline at end of file
diff --git a/.github/actions/pr-to-update-go/setup.cfg b/.github/actions/pr-to-update-go/setup.cfg
new file mode 100644
index 0000000..167f562
--- /dev/null
+++ b/.github/actions/pr-to-update-go/setup.cfg
@@ -0,0 +1,37 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+[metadata]
+name = pr-to-update-go
+version = 0.0.0
+description = Opens a PR if a new minor Go revision is available.
+long_description = file: README.rst
+long_description_content_type = text/x-rst
+author = Apache Traffic Control
+author_email = dev@trafficcontrol.apache.org
+classifiers = OSI Approved :: Apache Software License
+
+[options]
+python_requires = >=3.9
+packages = pr_to_update_go
+install_requires =
+    PyGithub
+    requests
+
+[options.entry_points]
+console_scripts = pr-to-update-go = pr_to_update_go:main
+
+[options.extras_require]
+test = unittest
+
+[options.package_data]
+pr_to_update_go = pr_template.md
diff --git a/.github/actions/pr-to-update-go/setup.py b/.github/actions/pr-to-update-go/setup.py
new file mode 100755
index 0000000..0b9856c
--- /dev/null
+++ b/.github/actions/pr-to-update-go/setup.py
@@ -0,0 +1,20 @@
+#!/usr/bin/env python3
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+"""
+The setuptools-based install script for the pr-to-update-go GitHub Action
+"""
+from setuptools import setup
+
+setup()
diff --git a/.github/actions/pr-to-update-go/tests/test_go_pr_maker.py b/.github/actions/pr-to-update-go/tests/test_go_pr_maker.py
new file mode 100644
index 0000000..1e0c378
--- /dev/null
+++ b/.github/actions/pr-to-update-go/tests/test_go_pr_maker.py
@@ -0,0 +1,36 @@
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+from unittest import TestCase
+
+from pr_to_update_go.go_pr_maker import GoPRMaker
+
+
+class TestGoPRMaker(TestCase):
+	def test_get_major_version(self) -> None:
+		version: str = '1.2.3'
+		expected_major_version: str = '1.2'
+		actual_major_version: str = GoPRMaker.get_major_version(version)
+		self.assertEqual(expected_major_version, actual_major_version)
+		return
+
+	def test_get_release_notes(self) -> None:
+		go_version: str = '4.15.6'
+		expected_release_notes: str = f'<p> go4.15.6 The expected release notes </p>'
+		release_notes_with_whitespace: str = f"""<p>  
+                go{go_version} The expected release notes
+            </p>"""
+		content: str = f"""go4.15.5 text before
+        {release_notes_with_whitespace}
+        text <p>after</p> 4.15.7"""
+		actual_release_notes: str = GoPRMaker.get_release_notes(go_version, content)
+		self.assertEqual(expected_release_notes, actual_release_notes)
diff --git a/.github/actions/pr-to-update-go/update_golang_org_x.sh b/.github/actions/pr-to-update-go/update_golang_org_x.sh
new file mode 100755
index 0000000..4410b82
--- /dev/null
+++ b/.github/actions/pr-to-update-go/update_golang_org_x.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+set -o errexit -o nounset
+trap 'echo "Error on line ${LINENO} of ${0}"; exit 1' ERR
+
+# download_go downloads and installs the GO version specified in GO_VERSION
+download_go() {
+	. build/functions.sh
+	if verify_and_set_go_version; then
+		return
+	fi
+	go_version="$(cat "${GITHUB_WORKSPACE}/GO_VERSION")"
+	wget -O go.tar.gz "https://dl.google.com/go/go${go_version}.linux-amd64.tar.gz" --no-verbose
+	echo "Extracting Go ${go_version}..."
+	<<-'SUDO_COMMANDS' sudo sh
+		set -o errexit
+    go_dir="$(command -v go | xargs realpath | xargs dirname | xargs dirname)"
+		mv "$go_dir" "${go_dir}.unused"
+		tar -C /usr/local -xzf go.tar.gz
+	SUDO_COMMANDS
+	rm go.tar.gz
+	go version
+}
+
+GOROOT=/usr/local/go
+export PATH="${PATH}:${GOROOT}/bin"
+export GOPATH="${HOME}/go"
+
+download_go
+
+# update all golang.org/x dependencies in go.mod/go.sum
+go get -u \
+	golang.org/x/crypto \
+	golang.org/x/net \
+	golang.org/x/sys \
+	golang.org/x/text \
+	golang.org/x/xerrors
+
+# update vendor/modules.txt
+go mod vendor -v
diff --git a/.github/workflows/pr-to-update-go.yml b/.github/workflows/pr-to-update-go.yml
new file mode 100644
index 0000000..fb8575a
--- /dev/null
+++ b/.github/workflows/pr-to-update-go.yml
@@ -0,0 +1,48 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: Is it time to update Go?
+
+on:
+  # run manually
+  workflow_dispatch:
+  schedule:
+    # 14:00 UTC every day
+    - cron: '0 14 * * *'
+
+jobs:
+  pr-to-update-go:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout repo
+        uses: actions/checkout@master
+        if: ${{ (github.repository_owner == 'apache' && github.ref == 'refs/heads/master' ) || github.event_name != 'schedule' }}
+        id: checkout
+      - name: Install Python 3.9
+        uses: actions/setup-python@v2
+        if: ${{ steps.checkout.outcome == 'success' }}
+        with: { python-version: 3.9 }
+      - name: Install dependencies
+        if: ${{ steps.checkout.outcome == 'success' }}
+        run: pip install .github/actions/pr-to-update-go
+      - name: PR to Update Go
+        if: ${{ steps.checkout.outcome == 'success' }}
+        run: python3 -m pr_to_update_go
+        env:
+          GIT_AUTHOR_NAME: asfgit
+          GITHUB_TOKEN: ${{ github.token }}
+          GO_VERSION_FILE: GO_VERSION