pw_env_setup: Add GitHub environment visitor

Add an environment variable visitor that writes environment variables in
the format expected by GitHub Actions.

Bug: b/340900493
Change-Id: I40b6e1684c0416cd4046e1c5f20a6dff7e63c356
Reviewed-on: https://pigweed-review.googlesource.com/c/pigweed/pigweed/+/210045
Commit-Queue: Rob Mohr <mohrr@google.com>
Lint: Lint 🤖 <android-build-ayeaye@system.gserviceaccount.com>
Reviewed-by: Wyatt Hepler <hepler@google.com>
diff --git a/pw_env_setup/py/BUILD.gn b/pw_env_setup/py/BUILD.gn
index bbb5f09..697af67 100644
--- a/pw_env_setup/py/BUILD.gn
+++ b/pw_env_setup/py/BUILD.gn
@@ -34,6 +34,7 @@
     "pw_env_setup/entry_points/arm_gdb.py",
     "pw_env_setup/env_setup.py",
     "pw_env_setup/environment.py",
+    "pw_env_setup/github_visitor.py",
     "pw_env_setup/gni_visitor.py",
     "pw_env_setup/json_visitor.py",
     "pw_env_setup/npm_action.py",
diff --git a/pw_env_setup/py/pw_env_setup/env_setup.py b/pw_env_setup/py/pw_env_setup/env_setup.py
index 3359163..3daff58 100755
--- a/pw_env_setup/py/pw_env_setup/env_setup.py
+++ b/pw_env_setup/py/pw_env_setup/env_setup.py
@@ -603,6 +603,10 @@
             # every step.
             self._write_gni_file()
 
+        # Only write stuff for GitHub Actions once, at the end.
+        if 'GITHUB_ACTIONS' in os.environ:
+            self._env.github(self._install_dir)
+
         self._log('')
         self._env.echo('')
 
diff --git a/pw_env_setup/py/pw_env_setup/environment.py b/pw_env_setup/py/pw_env_setup/environment.py
index be51576..a624d15 100644
--- a/pw_env_setup/py/pw_env_setup/environment.py
+++ b/pw_env_setup/py/pw_env_setup/environment.py
@@ -28,6 +28,7 @@
 
 from . import apply_visitor
 from . import batch_visitor
+from . import github_visitor
 from . import gni_visitor
 from . import json_visitor
 from . import shell_visitor
@@ -447,6 +448,9 @@
         for action in self._actions:
             action.accept(visitor)
 
+    def github(self, root):
+        github_visitor.GitHubVisitor().serialize(self, root)
+
     def gni(self, outs, project_root, gni_file):
         gni_visitor.GNIVisitor(project_root, gni_file).serialize(self, outs)
 
diff --git a/pw_env_setup/py/pw_env_setup/github_visitor.py b/pw_env_setup/py/pw_env_setup/github_visitor.py
new file mode 100644
index 0000000..aa208a4
--- /dev/null
+++ b/pw_env_setup/py/pw_env_setup/github_visitor.py
@@ -0,0 +1,135 @@
+# Copyright 2024 The Pigweed Authors
+#
+# 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
+#
+#     https://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.
+# pylint: disable=line-too-long
+"""Serializes an Environment into files in a way GitHub Actions understands.
+
+See also:
+
+* https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-environment-variable
+* https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#adding-a-system-path
+"""
+# pylint: enable=line-too-long
+
+from __future__ import print_function
+
+import contextlib
+import os
+
+# Disable Python 2-related warnings since this file must be Python 2
+# compatible.
+# pylint: disable=super-with-arguments, useless-object-inheritance
+
+
+class GitHubVisitor(object):
+    """Serializes an Environment into files GitHub Actions understands."""
+
+    def __init__(self, *args, **kwargs):
+        super(GitHubVisitor, self).__init__(*args, **kwargs)
+        self._replacements = ()
+        self._github_env = None
+        self._github_path = None
+        self._log = None
+
+    def serialize(self, env, root):
+        """Write a shell file based on the given environment.
+
+        Args:
+            env (environment.Environment): Environment variables to use.
+            outs (file): Shell file to write.
+        """
+        try:
+            self._replacements = tuple(
+                (key, env.get(key) if value is None else value)
+                for key, value in env.replacements
+            )
+
+            with contextlib.ExitStack() as stack:
+                github_env = os.environ.get('GITHUB_ENV')
+                mode = 'a'
+                if not github_env:
+                    github_env = os.path.join(root, 'github_env.log')
+                    mode = 'w'
+                self._github_env = stack.enter_context(open(github_env, mode))
+
+                github_path = os.environ.get('GITHUB_PATH')
+                mode = 'a'
+                if not github_path:
+                    github_path = os.path.join(root, 'github_path.log')
+                    mode = 'w'
+                self._github_path = stack.enter_context(open(github_path, mode))
+
+                self._log = stack.enter_context(
+                    open(os.path.join(root, 'github.log'), 'w')
+                )
+
+                env.accept(self)
+
+        finally:
+            self._replacements = ()
+            self._github_env = None
+            self._github_path = None
+            self._log = None
+
+    def visit_set(self, set):  # pylint: disable=redefined-builtin
+        if '\n' in set.value:
+            eof = '__EOF__'
+            assert f'\n{eof}\n' not in set.value
+            print(
+                f'{set.name}=<<{eof}\n{set.value}\n{eof}',
+                file=self._github_env,
+            )
+        else:
+            print(f'{set.name}={set.value}', file=self._github_env)
+        print(f'setting {set.name!r} = {set.value!r}', file=self._log)
+
+    def visit_clear(self, clear):
+        print(f'{clear.name}=', file=self._github_env)
+        print(f'setting {clear.name!r} = ""', file=self._log)
+
+    def visit_prepend(self, prepend):
+        if prepend.name == 'PATH':
+            print(prepend.value, file=self._github_path)
+            print(f'adding {prepend.value!r} to PATH', file=self._log)
+        else:
+            print(
+                f'unsupported prepend: {prepend.name!r} += {prepend.value!r}',
+                file=self._log,
+            )
+
+    def visit_append(self, append):
+        print(
+            f'unsupported append: {append.name!r} += {append.value!r}',
+            file=self._log,
+        )
+
+    def visit_remove(self, remove):
+        pass
+
+    def visit_echo(self, echo):
+        pass
+
+    def visit_comment(self, comment):
+        pass
+
+    def visit_command(self, command):
+        pass
+
+    def visit_doctor(self, doctor):
+        pass
+
+    def visit_blank_line(self, blank_line):
+        pass
+
+    def visit_function(self, function):
+        pass