diff -pruN 1.6.8-2/.github/common.env 1.6.9-1/.github/common.env
--- 1.6.8-2/.github/common.env	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/.github/common.env	2023-02-21 14:31:28.000000000 +0000
@@ -1,5 +1,5 @@
 # Shared common variables
 
-CI_IMAGE_VERSION=master-533491591
-CI_TOXENV_MAIN=py36-nocover,py37-nocover,py38-nocover,py39-nocover,py310-nocover
+CI_IMAGE_VERSION=master-784208155
+CI_TOXENV_MAIN=py36-nocover,py37-nocover,py38-nocover,py39-nocover,py310-nocover,py311-nocover
 CI_TOXENV_ALL="${CI_TOXENV_MAIN}"
diff -pruN 1.6.8-2/.github/compose/ci.docker-compose.yml 1.6.9-1/.github/compose/ci.docker-compose.yml
--- 1.6.8-2/.github/compose/ci.docker-compose.yml	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/.github/compose/ci.docker-compose.yml	2023-02-21 14:31:28.000000000 +0000
@@ -1,7 +1,7 @@
 version: '3.4'
 
 x-tests-template: &tests-template
-    image: registry.gitlab.com/buildstream/buildstream-docker-images/testsuite-fedora:35-${CI_IMAGE_VERSION:-latest}
+    image: registry.gitlab.com/buildstream/buildstream-docker-images/testsuite-fedora:36-${CI_IMAGE_VERSION:-latest}
     command: tox -vvvvv -- --color=yes --integration
     environment:
       TOXENV: ${CI_TOXENV_ALL}
@@ -22,14 +22,14 @@ x-tests-template: &tests-template
 
 services:
 
-  fedora-35:
-    <<: *tests-template
-    image: registry.gitlab.com/buildstream/buildstream-docker-images/testsuite-fedora:35-${CI_IMAGE_VERSION:-latest}
-
   fedora-36:
     <<: *tests-template
     image: registry.gitlab.com/buildstream/buildstream-docker-images/testsuite-fedora:36-${CI_IMAGE_VERSION:-latest}
 
+  fedora-37:
+    <<: *tests-template
+    image: registry.gitlab.com/buildstream/buildstream-docker-images/testsuite-fedora:37-${CI_IMAGE_VERSION:-latest}
+
   debian-10:
     <<: *tests-template
     image: registry.gitlab.com/buildstream/buildstream-docker-images/testsuite-debian:10-${CI_IMAGE_VERSION:-latest}
diff -pruN 1.6.8-2/.github/run-ci.sh 1.6.9-1/.github/run-ci.sh
--- 1.6.8-2/.github/run-ci.sh	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/.github/run-ci.sh	2023-02-21 14:31:28.000000000 +0000
@@ -67,8 +67,8 @@ set -e
 if [ -z "${test_names}" ]; then
     runTest "lint"
     runTest "debian-10"
-    runTest "fedora-35"
     runTest "fedora-36"
+    runTest "fedora-37"
 else
     for test_name in "${test_names}"; do
 	runTest "${test_name}"
diff -pruN 1.6.8-2/.github/workflows/ci.yml 1.6.9-1/.github/workflows/ci.yml
--- 1.6.8-2/.github/workflows/ci.yml	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/.github/workflows/ci.yml	2023-02-21 14:31:28.000000000 +0000
@@ -26,8 +26,8 @@ jobs:
         # "../compose/ci.docker-compose.yml"
         test-name:
           - debian-10
-          - fedora-35
           - fedora-36
+          - fedora-37
           - lint
 
     steps:
diff -pruN 1.6.8-2/NEWS 1.6.9-1/NEWS
--- 1.6.8-2/NEWS	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/NEWS	2023-02-21 14:31:28.000000000 +0000
@@ -1,4 +1,10 @@
 =================
+buildstream 1.6.9
+=================
+
+  o Further Python 3.11 fixes to regex flags.
+
+=================
 buildstream 1.6.8
 =================
 
diff -pruN 1.6.8-2/buildstream/_exceptions.py 1.6.9-1/buildstream/_exceptions.py
--- 1.6.8-2/buildstream/_exceptions.py	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/buildstream/_exceptions.py	2023-02-21 14:31:28.000000000 +0000
@@ -24,25 +24,10 @@ from enum import Enum
 # pylint: disable=global-statement
 
 # The last raised exception, this is used in test cases only
-_last_exception = None
 _last_task_error_domain = None
 _last_task_error_reason = None
 
 
-# get_last_exception()
-#
-# Fetches the last exception from the main process
-#
-# Used by regression tests
-#
-def get_last_exception():
-    global _last_exception
-
-    le = _last_exception
-    _last_exception = None
-    return le
-
-
 # get_last_task_error()
 #
 # Fetches the last exception from a task
@@ -102,7 +87,6 @@ class ErrorDomain(Enum):
 class BstError(Exception):
 
     def __init__(self, message, *, detail=None, domain=None, reason=None, temporary=False):
-        global _last_exception
 
         super().__init__(message)
 
@@ -126,9 +110,6 @@ class BstError(Exception):
         self.domain = domain
         self.reason = reason
 
-        # Hold on to the last raised exception for testing purposes
-        _last_exception = self
-
 
 # PluginError
 #
diff -pruN 1.6.8-2/buildstream/_frontend/app.py 1.6.9-1/buildstream/_frontend/app.py
--- 1.6.8-2/buildstream/_frontend/app.py	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/buildstream/_frontend/app.py	2023-02-21 14:31:28.000000000 +0000
@@ -22,9 +22,11 @@ import sys
 import resource
 import traceback
 import datetime
+from enum import Enum
 from textwrap import TextWrapper
 from contextlib import contextmanager
 
+import ujson
 import click
 from click import UsageError
 
@@ -35,7 +37,7 @@ from .. import Scope
 from .._context import Context
 from .._platform import Platform
 from .._project import Project
-from .._exceptions import BstError, StreamError, LoadError, LoadErrorReason, AppError
+from .._exceptions import BstError, StreamError, LoadError, LoadErrorReason, AppError, get_last_task_error
 from .._message import Message, MessageType, unconditional_messages
 from .._stream import Stream
 from .._versions import BST_FORMAT_VERSION
@@ -682,6 +684,20 @@ class App():
             detail = '\n' + indent + indent.join(error.detail.splitlines(True))
             click.echo("{}".format(detail), err=True)
 
+        # Record machine readable errors in a tempfile for the test harness to read back
+        if 'BST_TEST_ERROR_CODES' in os.environ:
+            task_error_domain, task_error_reason = get_last_task_error ()
+            error_codes = ujson.dumps ({
+                'main_error_domain': error.domain.value if error.domain else None,
+                'main_error_reason': error.reason.value if isinstance (error.reason, Enum) else error.reason,
+                'task_error_domain': task_error_domain.value if task_error_domain else None,
+                'task_error_reason': (
+                    task_error_reason.value if isinstance (task_error_reason, Enum) else task_error_reason
+                )
+            })
+            with open (os.environ['BST_TEST_ERROR_CODES'], "w", encoding="utf-8") as f:
+                f.write (error_codes)
+
         sys.exit(-1)
 
     #
diff -pruN 1.6.8-2/buildstream/_version.py 1.6.9-1/buildstream/_version.py
--- 1.6.8-2/buildstream/_version.py	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/buildstream/_version.py	2023-02-21 14:31:28.000000000 +0000
@@ -24,9 +24,9 @@ def get_keywords():
     # setup.py/versioneer.py will grep for the variable names, so they must
     # each be defined on a line of their own. _version.py will just call
     # get_keywords().
-    git_refnames = " (tag: 1.6.8, bst-1)"
-    git_full = "0f431178ffa5a6d3e385378660ba574f5f5fb17e"
-    git_date = "2022-10-13 00:13:38 +0900"
+    git_refnames = " (tag: 1.6.9, bst-1)"
+    git_full = "4abd1f3e1b5e5d128bc24e45ec9a37d61723be87"
+    git_date = "2023-02-21 23:31:28 +0900"
     keywords = {"refnames": git_refnames, "full": git_full, "date": git_date}
     return keywords
 
diff -pruN 1.6.8-2/buildstream/element.py 1.6.9-1/buildstream/element.py
--- 1.6.8-2/buildstream/element.py	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/buildstream/element.py	2023-02-21 14:31:28.000000000 +0000
@@ -2311,7 +2311,9 @@ class Element(Plugin):
         bstdata = self.get_public_data('bst')
         splits = bstdata.get('split-rules')
         self.__splits = {
-            domain: re.compile('^(?:' + '|'.join([utils._glob2re(r) for r in rules]) + ')$')
+            domain: re.compile(
+                "^(?:" + "|".join([utils._glob2re(r) for r in rules]) + ")$", re.MULTILINE | re.DOTALL
+            )
             for domain, rules in self.node_items(splits)
         }
 
@@ -2386,7 +2388,7 @@ class Element(Plugin):
                 for index, exp in enumerate(whitelist)
             ]
             expression = ('^(?:' + '|'.join(whitelist_expressions) + ')$')
-            self.__whitelist_regex = re.compile(expression)
+            self.__whitelist_regex = re.compile(expression, re.MULTILINE | re.DOTALL)
         return self.__whitelist_regex.match(path) or self.__whitelist_regex.match(os.path.join(os.sep, path))
 
     # __extract():
diff -pruN 1.6.8-2/buildstream/plugins/sources/pip.py 1.6.9-1/buildstream/plugins/sources/pip.py
--- 1.6.8-2/buildstream/plugins/sources/pip.py	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/buildstream/plugins/sources/pip.py	2023-02-21 14:31:28.000000000 +0000
@@ -93,6 +93,7 @@ _PYTHON_VERSIONS = [
     'python3.8',
     'python3.9',
     'python3.10',
+    'python3.11',
 ]
 
 # List of allowed extensions taken from
diff -pruN 1.6.8-2/buildstream/utils.py 1.6.9-1/buildstream/utils.py
--- 1.6.8-2/buildstream/utils.py	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/buildstream/utils.py	2023-02-21 14:31:28.000000000 +0000
@@ -200,7 +200,7 @@ def glob(paths, pattern):
         pattern = os.sep + pattern
 
     expression = _glob2re(pattern)
-    regexer = re.compile(expression)
+    regexer = re.compile(expression, re.MULTILINE | re.DOTALL)
 
     for filename in paths:
         filename_try = filename
@@ -1138,7 +1138,7 @@ def _call(*popenargs, terminate=False, *
 #
 def _glob2re(pat):
     i, n = 0, len(pat)
-    res = '(?ms)'
+    res = ''
     while i < n:
         c = pat[i]
         i = i + 1
diff -pruN 1.6.8-2/debian/changelog 1.6.9-1/debian/changelog
--- 1.6.8-2/debian/changelog	2023-02-01 11:43:46.000000000 +0000
+++ 1.6.9-1/debian/changelog	2023-05-25 12:56:21.000000000 +0000
@@ -1,3 +1,11 @@
+buildstream (1.6.9-1) unstable; urgency=medium
+
+  * New upstream release
+  * Drop Python 3.11 patches: applied in new release
+  * Update standards version to 4.6.2, no changes needed
+
+ -- Jeremy Bícha <jbicha@ubuntu.com>  Thu, 25 May 2023 08:56:21 -0400
+
 buildstream (1.6.8-2) unstable; urgency=high
 
   * Cherry-pick commits to support Python 3.11 (Closes: #1030143)
diff -pruN 1.6.8-2/debian/control 1.6.9-1/debian/control
--- 1.6.8-2/debian/control	2023-02-01 11:43:46.000000000 +0000
+++ 1.6.9-1/debian/control	2023-05-25 12:56:21.000000000 +0000
@@ -30,7 +30,7 @@ Build-Depends: debhelper-compat (= 13),
                python3-ruamel.yaml <!nocheck>,
                python3-setuptools,
                python3-ujson <!nocheck>,
-Standards-Version: 4.6.1
+Standards-Version: 4.6.2
 Rules-Requires-Root: no
 Vcs-Browser: https://salsa.debian.org/gnome-team/buildstream
 Vcs-Git: https://salsa.debian.org/gnome-team/buildstream.git
diff -pruN 1.6.8-2/debian/control.in 1.6.9-1/debian/control.in
--- 1.6.8-2/debian/control.in	2023-02-01 11:43:46.000000000 +0000
+++ 1.6.9-1/debian/control.in	2023-05-25 12:56:21.000000000 +0000
@@ -26,7 +26,7 @@ Build-Depends: debhelper-compat (= 13),
                python3-ruamel.yaml <!nocheck>,
                python3-setuptools,
                python3-ujson <!nocheck>,
-Standards-Version: 4.6.1
+Standards-Version: 4.6.2
 Rules-Requires-Root: no
 Vcs-Browser: https://salsa.debian.org/gnome-team/buildstream
 Vcs-Git: https://salsa.debian.org/gnome-team/buildstream.git
diff -pruN 1.6.8-2/debian/patches/Add-support-for-Python-3.11.patch 1.6.9-1/debian/patches/Add-support-for-Python-3.11.patch
--- 1.6.8-2/debian/patches/Add-support-for-Python-3.11.patch	2023-02-01 11:43:46.000000000 +0000
+++ 1.6.9-1/debian/patches/Add-support-for-Python-3.11.patch	1970-01-01 00:00:00.000000000 +0000
@@ -1,61 +0,0 @@
-From: Sam Thursfield <sam.thursfield@codethink.co.uk>
-Date: Fri, 28 Oct 2022 15:55:57 +0200
-Subject: Add support for Python 3.11
-
-Following the steps documented here:
-https://github.com/apache/buildstream/pull/1738/
-
-(cherry picked from commit 071fac27585c9252971ba7b10264b3fb6dd52e8d)
----
- tox.ini | 20 ++++++++++----------
- 1 file changed, 10 insertions(+), 10 deletions(-)
-
-diff --git a/tox.ini b/tox.ini
-index a5d14a2..5e6cec4 100644
---- a/tox.ini
-+++ b/tox.ini
-@@ -2,7 +2,7 @@
- # Tox global configuration
- #
- [tox]
--envlist = py36-nocover,py37-nocover,py38-nocover,py39-nocover,py310-nocover
-+envlist = py37,py{38,39,310,311}-nocover
- skip_missing_interpreters = true
- 
- #
-@@ -13,16 +13,16 @@ skip_missing_interpreters = true
- [testenv]
- commands =
-     # Running with coverage reporting enabled
--    py{36,37,38,39,310}-!nocover: pytest --basetemp {envtmpdir} --cov=buildstream --cov-config .coveragerc {posargs}
--    py{36,37,38,39,310}-!nocover: mkdir -p .coverage-reports
--    py{36,37,38,39,310}-!nocover: mv {envtmpdir}/.coverage {toxinidir}/.coverage-reports/.coverage.{env:COVERAGE_PREFIX:}{envname}
-+    py{36,37,38,39,310,311}-!nocover: pytest --basetemp {envtmpdir} --cov=buildstream --cov-config .coveragerc {posargs}
-+    py{36,37,38,39,310,311}-!nocover: mkdir -p .coverage-reports
-+    py{36,37,38,39,310,311}-!nocover: mv {envtmpdir}/.coverage {toxinidir}/.coverage-reports/.coverage.{env:COVERAGE_PREFIX:}{envname}
- 
-     # Running with coverage reporting disabled
--    py{36,37,38,39,310}-nocover: pytest --basetemp {envtmpdir} {posargs}
-+    py{36,37,38,39,310,311}-nocover: pytest --basetemp {envtmpdir} {posargs}
- deps =
--    py{36,37,38,39,310}: -rrequirements/requirements.txt
--    py{36,37,38,39,310}: -rrequirements/dev-requirements.txt
--    py{36,37,38,39,310}: -rrequirements/plugin-requirements.txt
-+    py{36,37,38,39,310,311}: -rrequirements/requirements.txt
-+    py{36,37,38,39,310,311}: -rrequirements/dev-requirements.txt
-+    py{36,37,38,39,310,311}: -rrequirements/plugin-requirements.txt
- 
-     # Only require coverage and pytest-cov when using it
-     !nocover: -rrequirements/cov-requirements.txt
-@@ -35,9 +35,9 @@ passenv =
- # These keys are not inherited by any other sections
- #
- setenv =
--    py{36,37,38,39,310}: COVERAGE_FILE = {envtmpdir}/.coverage
-+    py{36,37,38,39,310,311}: COVERAGE_FILE = {envtmpdir}/.coverage
- whitelist_externals =
--    py{36,37,38,39,310}:
-+    py{36,37,38,39,310,311}:
-         mv
-         mkdir
- 
diff -pruN 1.6.8-2/debian/patches/Attempt-at-backporting-regexp-fixes.patch 1.6.9-1/debian/patches/Attempt-at-backporting-regexp-fixes.patch
--- 1.6.8-2/debian/patches/Attempt-at-backporting-regexp-fixes.patch	2023-02-01 11:43:46.000000000 +0000
+++ 1.6.9-1/debian/patches/Attempt-at-backporting-regexp-fixes.patch	1970-01-01 00:00:00.000000000 +0000
@@ -1,58 +0,0 @@
-From: Seppo Yli-Olli <seppo.yliolli@gmail.com>
-Date: Sun, 11 Dec 2022 16:23:52 +0200
-Subject: Attempt at backporting regexp fixes
-
-See 782555a674de2ab6f1760f13c4a1b17400f64533
-
-(cherry picked from commit 9e067ec3e854a8b1c1904446597db92a3f8d1dab)
----
- buildstream/element.py | 6 ++++--
- buildstream/utils.py   | 4 ++--
- 2 files changed, 6 insertions(+), 4 deletions(-)
-
-diff --git a/buildstream/element.py b/buildstream/element.py
-index a83fe23..2c38c8f 100644
---- a/buildstream/element.py
-+++ b/buildstream/element.py
-@@ -2311,7 +2311,9 @@ class Element(Plugin):
-         bstdata = self.get_public_data('bst')
-         splits = bstdata.get('split-rules')
-         self.__splits = {
--            domain: re.compile('^(?:' + '|'.join([utils._glob2re(r) for r in rules]) + ')$')
-+            domain: re.compile(
-+                "^(?:" + "|".join([utils._glob2re(r) for r in rules]) + ")$", re.MULTILINE | re.DOTALL
-+            )
-             for domain, rules in self.node_items(splits)
-         }
- 
-@@ -2386,7 +2388,7 @@ class Element(Plugin):
-                 for index, exp in enumerate(whitelist)
-             ]
-             expression = ('^(?:' + '|'.join(whitelist_expressions) + ')$')
--            self.__whitelist_regex = re.compile(expression)
-+            self.__whitelist_regex = re.compile(expression, re.MULTILINE | re.DOTALL)
-         return self.__whitelist_regex.match(path) or self.__whitelist_regex.match(os.path.join(os.sep, path))
- 
-     # __extract():
-diff --git a/buildstream/utils.py b/buildstream/utils.py
-index b26d1f9..9f22e63 100644
---- a/buildstream/utils.py
-+++ b/buildstream/utils.py
-@@ -200,7 +200,7 @@ def glob(paths, pattern):
-         pattern = os.sep + pattern
- 
-     expression = _glob2re(pattern)
--    regexer = re.compile(expression)
-+    regexer = re.compile(expression, re.MULTILINE | re.DOTALL)
- 
-     for filename in paths:
-         filename_try = filename
-@@ -1138,7 +1138,7 @@ def _call(*popenargs, terminate=False, **kwargs):
- #
- def _glob2re(pat):
-     i, n = 0, len(pat)
--    res = '(?ms)'
-+    res = ''
-     while i < n:
-         c = pat[i]
-         i = i + 1
diff -pruN 1.6.8-2/debian/patches/series 1.6.9-1/debian/patches/series
--- 1.6.8-2/debian/patches/series	2023-02-01 11:43:46.000000000 +0000
+++ 1.6.9-1/debian/patches/series	2023-05-25 12:56:21.000000000 +0000
@@ -1,3 +1 @@
 adjust-test-dependencies.patch
-Add-support-for-Python-3.11.patch
-Attempt-at-backporting-regexp-fixes.patch
diff -pruN 1.6.8-2/requirements/requirements.txt 1.6.9-1/requirements/requirements.txt
--- 1.6.8-2/requirements/requirements.txt	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/requirements/requirements.txt	2023-02-21 14:31:28.000000000 +0000
@@ -1,5 +1,5 @@
 click==8.1.3
-grpcio==1.48.0
+grpcio==1.51.1
 Jinja2==3.1.2
 pluginbase==1.0.1
 protobuf==4.21.4
@@ -9,5 +9,5 @@ setuptools==44.1.1
 ujson==5.4.0
 ## The following requirements were added by pip freeze:
 MarkupSafe==2.1.1
-ruamel.yaml.clib==0.2.6
+ruamel.yaml.clib==0.2.7
 six==1.16.0
diff -pruN 1.6.8-2/setup.py 1.6.9-1/setup.py
--- 1.6.8-2/setup.py	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/setup.py	2023-02-21 14:31:28.000000000 +0000
@@ -263,6 +263,8 @@ setup(name='BuildStream',
           'Programming Language :: Python :: 3.7',
           'Programming Language :: Python :: 3.8',
           'Programming Language :: Python :: 3.9',
+          'Programming Language :: Python :: 3.10',
+          'Programming Language :: Python :: 3.11',
           'Topic :: Software Development :: Build Tools'
       ],
       description='A framework for modelling build pipelines in YAML',
diff -pruN 1.6.8-2/tests/artifactcache/expiry.py 1.6.9-1/tests/artifactcache/expiry.py
--- 1.6.8-2/tests/artifactcache/expiry.py	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/tests/artifactcache/expiry.py	2023-02-21 14:31:28.000000000 +0000
@@ -289,6 +289,7 @@ def test_never_delete_required_track(cli
 # has 10K total disk space, and 6K of it is already in use (not
 # including any space used by the artifact cache).
 #
+@pytest.mark.xfail(reason="unittest.mock() not supported when running tests in subprocesses")
 @pytest.mark.parametrize("quota,err_domain,err_reason", [
     # Valid configurations
     ("1", 'success', None),
diff -pruN 1.6.8-2/tests/format/assertion.py 1.6.9-1/tests/format/assertion.py
--- 1.6.8-2/tests/format/assertion.py	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/tests/format/assertion.py	2023-02-21 14:31:28.000000000 +0000
@@ -32,4 +32,4 @@ def test_assertion_cli(cli, datafiles, t
 
     # Assert that the assertion text provided by the user
     # is found in the exception text
-    assert assertion in str(result.exception)
+    assert assertion in str(result.stderr)
diff -pruN 1.6.8-2/tests/format/optionarch.py 1.6.9-1/tests/format/optionarch.py
--- 1.6.8-2/tests/format/optionarch.py	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/tests/format/optionarch.py	2023-02-21 14:31:28.000000000 +0000
@@ -12,6 +12,12 @@ DATA_DIR = os.path.dirname(os.path.realp
 # Context manager to override the reported value of `os.uname()`
 @contextmanager
 def override_uname_arch(name):
+
+    #
+    # Disabling this test since we now run bst in a subprocess during tests.
+    #
+    pytest.xfail("Overriding os.uname() in bst subprocess is unsupported")
+
     orig_uname = os.uname
     orig_tuple = tuple(os.uname())
     override_result = (orig_tuple[0], orig_tuple[1],
diff -pruN 1.6.8-2/tests/frontend/fetch.py 1.6.9-1/tests/frontend/fetch.py
--- 1.6.8-2/tests/frontend/fetch.py	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/tests/frontend/fetch.py	2023-02-21 14:31:28.000000000 +0000
@@ -71,8 +71,7 @@ def test_fetch_consistency_bug(cli, tmpd
     #    more gracefully as a BUG message.
     #
     result = cli.run(project=project, args=['fetch', 'bug.bst'])
-    assert result.exc is not None
-    assert str(result.exc) == "Something went terribly wrong"
+    assert "Something went terribly wrong" in result.stderr
 
 
 @pytest.mark.datafiles(DATA_DIR)
diff -pruN 1.6.8-2/tests/frontend/show.py 1.6.9-1/tests/frontend/show.py
--- 1.6.8-2/tests/frontend/show.py	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/tests/frontend/show.py	2023-02-21 14:31:28.000000000 +0000
@@ -269,6 +269,7 @@ def test_fetched_junction(cli, tmpdir, d
 ###############################################################
 #                   Testing recursion depth                   #
 ###############################################################
+@pytest.mark.xfail(reason="recursion errors not currently detectable")
 @pytest.mark.parametrize("dependency_depth", [100, 500, 1200])
 def test_exceed_max_recursion_depth(cli, tmpdir, dependency_depth):
     project_name = "recursion-test"
@@ -314,8 +315,14 @@ def test_exceed_max_recursion_depth(cli,
     if dependency_depth <= recursion_limit:
         result.assert_success()
     else:
-        #  Assert exception is thown and handled
-        assert not result.unhandled_exception
+        # XXX Assert exception is thown and handled
+        #
+        # We need to assert that the client has not thrown a stack trace for
+        # a recursion error, this should be done by creating a BstError instead
+        # of just handling it in app.py and doing sys.exit(), because we no longer
+        # have any way of detecting whether the client has thrown an exception
+        # otherwise
+        #
         assert result.exit_code == -1
 
     shutil.rmtree(project_path)
diff -pruN 1.6.8-2/tests/integration/symlinks.py 1.6.9-1/tests/integration/symlinks.py
--- 1.6.8-2/tests/integration/symlinks.py	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/tests/integration/symlinks.py	2023-02-21 14:31:28.000000000 +0000
@@ -70,5 +70,5 @@ def test_detect_symlink_overlaps_pointin
     # point outside the sandbox which BuildStream needs to detect before it
     # tries to actually write there.
     result = cli.run(project=project, args=['checkout', element_name, checkout])
-    assert result.exit_code == -1
+    assert result.exit_code != 0
     assert "Destination path resolves to a path outside of the staging area" in result.stderr
diff -pruN 1.6.8-2/tests/loader/junctions.py 1.6.9-1/tests/loader/junctions.py
--- 1.6.8-2/tests/loader/junctions.py	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/tests/loader/junctions.py	2023-02-21 14:31:28.000000000 +0000
@@ -135,10 +135,7 @@ def test_nested_conflict(cli, datafiles)
     copy_subprojects(project, datafiles, ['foo', 'bar'])
 
     result = cli.run(project=project, args=['build', 'target.bst'])
-    assert result.exit_code != 0
-    assert result.exception
-    assert isinstance(result.exception, LoadError)
-    assert result.exception.reason == LoadErrorReason.CONFLICTING_JUNCTION
+    result.assert_main_error(ErrorDomain.LOAD, LoadErrorReason.CONFLICTING_JUNCTION)
 
 
 @pytest.mark.datafiles(DATA_DIR)
@@ -146,10 +143,7 @@ def test_invalid_missing(cli, datafiles)
     project = os.path.join(str(datafiles), 'invalid')
 
     result = cli.run(project=project, args=['build', 'missing.bst'])
-    assert result.exit_code != 0
-    assert result.exception
-    assert isinstance(result.exception, LoadError)
-    assert result.exception.reason == LoadErrorReason.MISSING_FILE
+    result.assert_main_error(ErrorDomain.LOAD, LoadErrorReason.MISSING_FILE)
 
 
 @pytest.mark.datafiles(DATA_DIR)
@@ -158,10 +152,7 @@ def test_invalid_with_deps(cli, datafile
     copy_subprojects(project, datafiles, ['base'])
 
     result = cli.run(project=project, args=['build', 'junction-with-deps.bst'])
-    assert result.exit_code != 0
-    assert result.exception
-    assert isinstance(result.exception, ElementError)
-    assert result.exception.reason == 'element-forbidden-depends'
+    result.assert_main_error(ErrorDomain.ELEMENT, 'element-forbidden-depends')
 
 
 @pytest.mark.datafiles(DATA_DIR)
@@ -170,10 +161,7 @@ def test_invalid_junction_dep(cli, dataf
     copy_subprojects(project, datafiles, ['base'])
 
     result = cli.run(project=project, args=['build', 'junction-dep.bst'])
-    assert result.exit_code != 0
-    assert result.exception
-    assert isinstance(result.exception, LoadError)
-    assert result.exception.reason == LoadErrorReason.INVALID_DATA
+    result.assert_main_error(ErrorDomain.LOAD, LoadErrorReason.INVALID_DATA)
 
 
 @pytest.mark.datafiles(DATA_DIR)
@@ -248,10 +236,7 @@ def test_git_show(cli, tmpdir, datafiles
 
     # Verify that bst show does not implicitly fetch subproject
     result = cli.run(project=project, args=['show', 'target.bst'])
-    assert result.exit_code != 0
-    assert result.exception
-    assert isinstance(result.exception, LoadError)
-    assert result.exception.reason == LoadErrorReason.SUBPROJECT_FETCH_NEEDED
+    result.assert_main_error(ErrorDomain.LOAD, LoadErrorReason.SUBPROJECT_FETCH_NEEDED)
 
     # Explicitly fetch subproject
     result = cli.run(project=project, args=['fetch', 'base.bst'])
diff -pruN 1.6.8-2/tests/sources/tar.py 1.6.9-1/tests/sources/tar.py
--- 1.6.8-2/tests/sources/tar.py	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/tests/sources/tar.py	2023-02-21 14:31:28.000000000 +0000
@@ -348,6 +348,7 @@ def test_netrc_already_specified_user(cl
 
 # Test that BuildStream doesnt crash if HOME is unset while
 # the netrc module is trying to find it's ~/.netrc file.
+@pytest.mark.xfail(reason="Cannot set environment variable to None when running tests in subprocesses")
 @pytest.mark.datafiles(os.path.join(DATA_DIR, 'fetch'))
 def test_homeless_environment(cli, tmpdir, datafiles):
     project = os.path.join(datafiles.dirname, datafiles.basename)
diff -pruN 1.6.8-2/tests/testutils/runcli.py 1.6.9-1/tests/testutils/runcli.py
--- 1.6.8-2/tests/testutils/runcli.py	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/tests/testutils/runcli.py	2023-02-21 14:31:28.000000000 +0000
@@ -7,6 +7,8 @@ import itertools
 import traceback
 import subprocess
 from contextlib import contextmanager, ExitStack
+from enum import Enum
+import ujson
 import pytest
 
 # XXX Using pytest private internals here
@@ -22,50 +24,21 @@ from _pytest.capture import MultiCapture
 from buildstream._frontend import cli as bst_cli
 from buildstream import _yaml
 
-# Special private exception accessor, for test case purposes
-from buildstream._exceptions import BstError, get_last_exception, get_last_task_error
-
 
 # Wrapper for the click.testing result
 class Result():
 
     def __init__(self,
                  exit_code=None,
-                 exception=None,
-                 exc_info=None,
                  output=None,
                  stderr=None):
         self.exit_code = exit_code
-        self.exc = exception
-        self.exc_info = exc_info
         self.output = output
         self.stderr = stderr
-        self.unhandled_exception = False
-
-        # The last exception/error state is stored at exception
-        # creation time in BstError(), but this breaks down with
-        # recoverable errors where code blocks ignore some errors
-        # and fallback to alternative branches.
-        #
-        # For this reason, we just ignore the exception and errors
-        # in the case that the exit code reported is 0 (success).
-        #
-        if self.exit_code != 0:
-
-            # Check if buildstream failed to handle an
-            # exception, topevel CLI exit should always
-            # be a SystemExit exception.
-            #
-            if not isinstance(exception, SystemExit):
-                self.unhandled_exception = True
-
-            self.exception = get_last_exception()
-            self.task_error_domain, \
-                self.task_error_reason = get_last_task_error()
-        else:
-            self.exception = None
-            self.task_error_domain = None
-            self.task_error_reason = None
+        self.main_error_domain = None
+        self.main_error_reason = None
+        self.task_error_domain = None
+        self.task_error_reason = None
 
     # assert_success()
     #
@@ -79,9 +52,6 @@ class Result():
     #
     def assert_success(self, fail_message=''):
         assert self.exit_code == 0, fail_message
-        assert self.exc is None, fail_message
-        assert self.exception is None, fail_message
-        assert self.unhandled_exception is False
 
     # assert_main_error()
     #
@@ -106,23 +76,20 @@ class Result():
             print(
                 """
                 Exit code: {}
-                Exception: {}
                 Domain:    {}
                 Reason:    {}
                 """.format(
                     self.exit_code,
-                    self.exception,
-                    self.exception.domain,
-                    self.exception.reason
+                    self.main_error_domain,
+                    self.main_error_reason
                 ))
-        assert self.exit_code == -1, fail_message
-        assert self.exc is not None, fail_message
-        assert self.exception is not None, fail_message
-        assert isinstance(self.exception, BstError), fail_message
-        assert self.unhandled_exception is False
+        assert self.exit_code != 0, fail_message
+
+        test_domain = error_domain.value if isinstance (error_domain, Enum) else error_domain
+        test_reason = error_reason.value if isinstance (error_reason, Enum) else error_reason
 
-        assert self.exception.domain == error_domain, fail_message
-        assert self.exception.reason == error_reason, fail_message
+        assert self.main_error_domain == test_domain, fail_message
+        assert self.main_error_reason == test_reason, fail_message
 
     # assert_task_error()
     #
@@ -143,14 +110,12 @@ class Result():
                           error_reason,
                           fail_message=''):
 
-        assert self.exit_code == -1, fail_message
-        assert self.exc is not None, fail_message
-        assert self.exception is not None, fail_message
-        assert isinstance(self.exception, BstError), fail_message
-        assert self.unhandled_exception is False
+        test_domain = error_domain.value if isinstance (error_domain, Enum) else error_domain
+        test_reason = error_reason.value if isinstance (error_reason, Enum) else error_reason
 
-        assert self.task_error_domain == error_domain, fail_message
-        assert self.task_error_reason == error_reason, fail_message
+        assert self.exit_code != 0, fail_message
+        assert self.task_error_domain == test_domain, fail_message
+        assert self.task_error_reason == test_reason, fail_message
 
     # get_tracked_elements()
     #
@@ -241,9 +206,21 @@ class Cli():
         if options is None:
             options = []
 
+        if env is None:
+            env = os.environ.copy()
+        else:
+            orig_env = os.environ.copy()
+            orig_env.update (env)
+            env = orig_env
+
         options = self.default_options + options
 
         with ExitStack() as stack:
+
+            # Prepare a tempfile for buildstream to record machine readable error codes
+            error_codes = stack.enter_context (tempfile.NamedTemporaryFile())
+            env['BST_TEST_ERROR_CODES'] = error_codes.name
+
             bst_args = ['--no-colors']
 
             if silent:
@@ -263,21 +240,34 @@ class Cli():
 
             bst_args += args
 
-            if cwd is not None:
-                stack.enter_context(chdir(cwd))
-
-            if env is not None:
-                stack.enter_context(environment(env))
-
-            # Ensure we have a working stdout - required to work
-            # around a bug that appears to cause AIX to close
-            # sys.__stdout__ after setup.py
-            try:
-                sys.__stdout__.fileno()
-            except ValueError:
-                sys.__stdout__ = open('/dev/stdout', 'w')
+            cmd = ["bst"] + bst_args
+            process = subprocess.Popen(
+                cmd,
+                env=env,
+                cwd=cwd,
+                stdin=subprocess.DEVNULL,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.PIPE,
+            )
+            out, err = process.communicate()
+            result = Result(
+                exit_code=process.poll(),
+                output=out.decode('utf-8'),
+                stderr=err.decode('utf-8')
+            )
 
-            result = self.invoke(bst_cli, bst_args)
+            #
+            # Collect machine readable error codes from the tmpfile
+            #
+            result_error_codes_string = error_codes.read()
+            result_error_codes = {}
+            if result_error_codes_string:
+                result_error_codes = ujson.loads (result_error_codes_string)
+            if result_error_codes:
+                result.main_error_domain = result_error_codes['main_error_domain']
+                result.main_error_reason = result_error_codes['main_error_reason']
+                result.task_error_domain = result_error_codes['task_error_domain']
+                result.task_error_reason = result_error_codes['task_error_reason']
 
         # Some informative stdout we can observe when anything fails
         if self.verbose:
@@ -289,54 +279,8 @@ class Cli():
             if result.stderr:
                 print("Program stderr was:\n{}".format(result.stderr))
 
-            if result.exc_info and result.exc_info[0] != SystemExit:
-                traceback.print_exception(*result.exc_info)
-
         return result
 
-    def invoke(self, cli, args=None, color=False, **extra):
-        exc_info = None
-        exception = None
-        exit_code = 0
-
-        # Temporarily redirect sys.stdin to /dev/null to ensure that
-        # Popen doesn't attempt to read pytest's dummy stdin.
-        old_stdin = sys.stdin
-        with open(os.devnull) as devnull:
-            sys.stdin = devnull
-            capture = MultiCapture(out=FDCapture(1), err=FDCapture(2), in_=None)
-            capture.start_capturing()
-
-            try:
-                cli.main(args=args or (), prog_name=cli.name, **extra)
-            except SystemExit as e:
-                if e.code != 0:
-                    exception = e
-
-                exc_info = sys.exc_info()
-
-                exit_code = e.code
-                if not isinstance(exit_code, int):
-                    sys.stdout.write('Program exit code was not an integer: ')
-                    sys.stdout.write(str(exit_code))
-                    sys.stdout.write('\n')
-                    exit_code = 1
-            except Exception as e:
-                exception = e
-                exit_code = -1
-                exc_info = sys.exc_info()
-            finally:
-                sys.stdout.flush()
-
-        sys.stdin = old_stdin
-        out, err = capture.readouterr()
-        capture.stop_capturing()
-
-        return Result(exit_code=exit_code,
-                      exception=exception,
-                      exc_info=exc_info,
-                      output=out,
-                      stderr=err)
 
     # Fetch an element state by name by
     # invoking bst show on the project with the CLI
diff -pruN 1.6.8-2/tox.ini 1.6.9-1/tox.ini
--- 1.6.8-2/tox.ini	2022-10-12 15:13:38.000000000 +0000
+++ 1.6.9-1/tox.ini	2023-02-21 14:31:28.000000000 +0000
@@ -2,7 +2,7 @@
 # Tox global configuration
 #
 [tox]
-envlist = py36-nocover,py37-nocover,py38-nocover,py39-nocover,py310-nocover
+envlist = py36-nocover,py37-nocover,py38-nocover,py39-nocover,py310-nocover,py311-nocover
 skip_missing_interpreters = true
 
 #
@@ -13,16 +13,16 @@ skip_missing_interpreters = true
 [testenv]
 commands =
     # Running with coverage reporting enabled
-    py{36,37,38,39,310}-!nocover: pytest --basetemp {envtmpdir} --cov=buildstream --cov-config .coveragerc {posargs}
-    py{36,37,38,39,310}-!nocover: mkdir -p .coverage-reports
-    py{36,37,38,39,310}-!nocover: mv {envtmpdir}/.coverage {toxinidir}/.coverage-reports/.coverage.{env:COVERAGE_PREFIX:}{envname}
+    py{36,37,38,39,310,311}-!nocover: pytest --basetemp {envtmpdir} --cov=buildstream --cov-config .coveragerc {posargs}
+    py{36,37,38,39,310,311}-!nocover: mkdir -p .coverage-reports
+    py{36,37,38,39,310,311}-!nocover: mv {envtmpdir}/.coverage {toxinidir}/.coverage-reports/.coverage.{env:COVERAGE_PREFIX:}{envname}
 
     # Running with coverage reporting disabled
-    py{36,37,38,39,310}-nocover: pytest --basetemp {envtmpdir} {posargs}
+    py{36,37,38,39,310,311}-nocover: pytest --basetemp {envtmpdir} {posargs}
 deps =
-    py{36,37,38,39,310}: -rrequirements/requirements.txt
-    py{36,37,38,39,310}: -rrequirements/dev-requirements.txt
-    py{36,37,38,39,310}: -rrequirements/plugin-requirements.txt
+    py{36,37,38,39,310,311}: -rrequirements/requirements.txt
+    py{36,37,38,39,310,311}: -rrequirements/dev-requirements.txt
+    py{36,37,38,39,310,311}: -rrequirements/plugin-requirements.txt
 
     # Only require coverage and pytest-cov when using it
     !nocover: -rrequirements/cov-requirements.txt
@@ -35,9 +35,9 @@ passenv =
 # These keys are not inherited by any other sections
 #
 setenv =
-    py{36,37,38,39,310}: COVERAGE_FILE = {envtmpdir}/.coverage
+    py{36,37,38,39,310,311}: COVERAGE_FILE = {envtmpdir}/.coverage
 whitelist_externals =
-    py{36,37,38,39,310}:
+    py{36,37,38,39,310,311}:
         mv
         mkdir
 
