Repository: containerd/containerd
Pull request #11693: use lazyregexp to compile regexes on first use
Author: login: thaJeztah
Labels: name: size/L
Created: 2025-04-12T12:59:10Z | Merged: 2025-04-24T01:19:39Z

- similar to https://github.com/moby/moby/pull/48166

### implement lazyregexp package

Based on the "lazyregexp" package in golang.org/x/mod;
https://cs.opensource.google/go/x/mod/+/refs/tags/v0.19.0:internal/lazyregexp/lazyre.go;l=66-78

This package allows defining regular expressions that should not be
compiled until used, but still providing validation to prevent
invalid regular expressions from producing a panic at runtime.

The lazyregexp package provides a subset of the methods provided
by "regexp" and only implements the methods used in the codebase.
Additional methods can be added when needed.



Diff:
diff --git a/.golangci.yml b/.golangci.yml
index c145b842c52bb..3bd723a8e8771 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -61,6 +61,10 @@ issues:
     - linters:
         - revive
       text: "redefines-builtin-id"
+    - linters:
+        - forbidigo
+      text: 'use of `regexp.MustCompile` forbidden'
+      path: _test\.go
 
 linters-settings:
   depguard:
@@ -69,6 +73,11 @@ linters-settings:
         deny:
           - pkg: github.com/opencontainers/runc
             desc: We don't want to depend on runc (libcontainer), unless there is no other option; see https://github.com/opencontainers/runc/issues/3028.
+  forbidigo:
+    forbid:
+      - pkg: ^regexp$
+        p: ^regexp\.MustCompile
+        msg: Use internal/lazyregexp.New instead.
 
   gosec:
     # The following issues surfaced when `gosec` linter
diff --git a/internal/cri/bandwidth/linux.go b/internal/cri/bandwidth/linux.go
index 3818777db0649..1011b8c71413d 100644
--- a/internal/cri/bandwidth/linux.go
+++ b/internal/cri/bandwidth/linux.go
@@ -40,19 +40,19 @@ import (
 	"encoding/hex"
 	"fmt"
 	"net"
-	"regexp"
 	"strings"
 
 	"k8s.io/apimachinery/pkg/api/resource"
 	"k8s.io/apimachinery/pkg/util/sets"
+	"k8s.io/klog/v2"
 	"k8s.io/utils/exec"
 
-	"k8s.io/klog/v2"
+	"github.com/containerd/containerd/v2/internal/lazyregexp"
 )
 
 var (
-	classShowMatcher      = regexp.MustCompile(`class htb (1:\d+)`)
-	classAndHandleMatcher = regexp.MustCompile(`filter parent 1:.*fh (\d+::\d+).*flowid (\d+:\d+)`)
+	classShowMatcher      = lazyregexp.New(`class htb (1:\d+)`)
+	classAndHandleMatcher = lazyregexp.New(`filter parent 1:.*fh (\d+::\d+).*flowid (\d+:\d+)`)
 )
 
 // tcShaper provides an implementation of the Shaper interface on Linux using the 'tc' tool.
diff --git a/internal/lazyregexp/lazyregexp.go b/internal/lazyregexp/lazyregexp.go
new file mode 100644
index 0000000000000..08d890f313646
--- /dev/null
+++ b/internal/lazyregexp/lazyregexp.go
@@ -0,0 +1,86 @@
+/*
+   Copyright The containerd 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
+
+       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.
+*/
+
+// Copyright 2018 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Code below was largely copied from golang.org/x/mod@v0.22;
+// https://github.com/golang/mod/blob/v0.22.0/internal/lazyregexp/lazyre.go
+// with some additional methods added.
+
+// Package lazyregexp is a thin wrapper over regexp, allowing the use of global
+// regexp variables without forcing them to be compiled at init.
+package lazyregexp
+
+import (
+	"os"
+	"regexp"
+	"strings"
+	"sync"
+)
+
+// Regexp is a wrapper around [regexp.Regexp], where the underlying regexp will be
+// compiled the first time it is needed.
+type Regexp struct {
+	str  string
+	once sync.Once
+	rx   *regexp.Regexp
+}
+
+func (re *Regexp) re() *regexp.Regexp {
+	re.once.Do(re.build)
+	return re.rx
+}
+
+func (re *Regexp) build() {
+	re.rx = regexp.MustCompile(re.str)
+	re.str = ""
+}
+
+func (re *Regexp) FindStringIndex(s string) (loc []int) {
+	return re.re().FindStringIndex(s)
+}
+
+func (re *Regexp) FindStringSubmatch(s string) []string {
+	return re.re().FindStringSubmatch(s)
+}
+
+func (re *Regexp) MatchString(s string) bool {
+	return re.re().MatchString(s)
+}
+
+func (re *Regexp) ReplaceAll(src, repl []byte) []byte {
+	return re.re().ReplaceAll(src, repl)
+}
+
+func (re *Regexp) String() string {
+	return re.re().String()
+}
+
+var inTest = len(os.Args) > 0 && strings.HasSuffix(strings.TrimSuffix(os.Args[0], ".exe"), ".test")
+
+// New creates a new lazy regexp, delaying the compiling work until it is first
+// needed. If the code is being run as part of tests, the regexp compiling will
+// happen immediately.
+func New(str string) *Regexp {
+	lr := &Regexp{str: str}
+	if inTest {
+		// In tests, always compile the regexps early.
+		lr.re()
+	}
+	return lr
+}
diff --git a/internal/lazyregexp/lazyregexp_test.go b/internal/lazyregexp/lazyregexp_test.go
new file mode 100644
index 0000000000000..39256d4055708
--- /dev/null
+++ b/internal/lazyregexp/lazyregexp_test.go
@@ -0,0 +1,39 @@
+/*
+   Copyright The containerd 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
+
+       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.
+*/
+
+package lazyregexp
+
+import (
+	"testing"
+)
+
+func TestCompileOnce(t *testing.T) {
+	t.Run("invalid regexp", func(t *testing.T) {
+		defer func() {
+			if r := recover(); r == nil {
+				t.Errorf("expected a panic")
+			}
+		}()
+		_ = New("[")
+	})
+	t.Run("valid regexp", func(t *testing.T) {
+		re := New("[a-z]")
+		ok := re.MatchString("hello")
+		if !ok {
+			t.Errorf("expected a match")
+		}
+	})
+}
diff --git a/pkg/identifiers/validate.go b/pkg/identifiers/validate.go
index 0acbf3fc4bb40..c1c7a336a4c6d 100644
--- a/pkg/identifiers/validate.go
+++ b/pkg/identifiers/validate.go
@@ -26,8 +26,8 @@ package identifiers
 
 import (
 	"fmt"
-	"regexp"
 
+	"github.com/containerd/containerd/v2/internal/lazyregexp"
 	"github.com/containerd/errdefs"
 )
 
@@ -39,7 +39,7 @@ const (
 
 var (
 	// identifierRe defines the pattern for valid identifiers.
-	identifierRe = regexp.MustCompile(reAnchor(alphanum + reGroup(separators+reGroup(alphanum)) + "*"))
+	identifierRe = lazyregexp.New(reAnchor(alphanum + reGroup(separators+reGroup(alphanum)) + "*"))
 )
 
 // Validate returns nil if the string s is a valid identifier.
diff --git a/pkg/progress/writer.go b/pkg/progress/writer.go
index a805004906b6c..58141e5a0c5f6 100644
--- a/pkg/progress/writer.go
+++ b/pkg/progress/writer.go
@@ -21,14 +21,14 @@ import (
 	"fmt"
 	"io"
 	"os"
-	"regexp"
 	"strings"
 
 	"github.com/containerd/console"
+	"github.com/containerd/containerd/v2/internal/lazyregexp"
 )
 
 var (
-	regexCleanLine = regexp.MustCompile("\x1b\\[[0-9]+m[\x1b]?")
+	regexCleanLine = lazyregexp.New("\x1b\\[[0-9]+m[\x1b]?")
 )
 
 // Writer buffers writes until flush, at which time the last screen is cleared
diff --git a/pkg/reference/reference.go b/pkg/reference/reference.go
index d983c4e1356f3..55c7b6fb523f4 100644
--- a/pkg/reference/reference.go
+++ b/pkg/reference/reference.go
@@ -20,9 +20,9 @@ import (
 	"errors"
 	"net/url"
 	"path"
-	"regexp"
 	"strings"
 
+	"github.com/containerd/containerd/v2/internal/lazyregexp"
 	digest "github.com/opencontainers/go-digest"
 )
 
@@ -80,7 +80,7 @@ type Spec struct {
 	Object string
 }
 
-var splitRe = regexp.MustCompile(`[:@]`)
+var splitRe = lazyregexp.New(`[:@]`)
 
 // Parse parses the string into a structured ref.
 func Parse(s string) (Spec, error) {
diff --git a/pkg/sys/filesys_windows.go b/pkg/sys/filesys_windows.go
index 67fc4048c1d54..e06fbdbb8d39d 100644
--- a/pkg/sys/filesys_windows.go
+++ b/pkg/sys/filesys_windows.go
@@ -18,11 +18,12 @@ package sys
 
 import (
 	"os"
-	"regexp"
 	"syscall"
 	"unsafe"
 
 	"golang.org/x/sys/windows"
+
+	"github.com/containerd/containerd/v2/internal/lazyregexp"
 )
 
 // SddlAdministratorsLocalSystem is local administrators plus NT AUTHORITY\System.
@@ -31,7 +32,7 @@ const SddlAdministratorsLocalSystem = "D:P(A;OICI;GA;;;BA)(A;OICI;GA;;;SY)"
 // volumePath is a regular expression to check if a path is a Windows
 // volume path (e.g., "\\?\Volume{4c1b02c1-d990-11dc-99ae-806e6f6e6963}"
 // or "\\?\Volume{4c1b02c1-d990-11dc-99ae-806e6f6e6963}\").
-var volumePath = regexp.MustCompile(`^\\\\\?\\Volume{[a-z0-9-]+}\\?$`)
+var volumePath = lazyregexp.New(`^\\\\\?\\Volume{[a-z0-9-]+}\\?$`)
 
 // MkdirAllWithACL is a custom version of os.MkdirAll modified for use on Windows
 // so that it is both volume path aware, and to create a directory


Reviews:
user: login: thaJeztah
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-12T13:06:58Z
user: login: djdongjin
body: minor comments, LGTM overall
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-13T19:42:45Z
user: login: thaJeztah
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T13:50:46Z
user: login: thaJeztah
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-14T13:50:59Z
user: login: laurazard
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-23T13:41:49Z
user: login: estesp
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-23T14:23:58Z
user: login: dmcgowan
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-23T15:34:02Z

Review comments:
path: .golangci.yml
user: login: thaJeztah
body: Changes in this file will conflict with another PR currently pending;

- https://github.com/containerd/containerd/pull/11623
created_at: 2025-04-12T13:06:57Z
author_association: MEMBER
diff_hunk: @@ -40,9 +40,9 @@ import (
 	"encoding/hex"
 	"fmt"
 	"net"
-	"regexp"
 	"strings"
 
+	"github.com/containerd/containerd/v2/internal/lazyregexp"
path: internal/cri/bandwidth/linux.go
user: login: djdongjin
body: nit: put local `containerd/containerd` deps [after 3rd-party packages](https://cs.opensource.google/go/x/tools/+/refs/tags/v0.32.0:cmd/goimports/goimports.go;l=54)?
created_at: 2025-04-13T19:34:14Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,87 @@
+/*
+   Copyright The containerd 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
+
+       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.
+*/
+
+// Copyright 2018 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Code below was largely copied from golang.org/x/mod@v0.22;
+// https://github.com/golang/mod/blob/v0.22.0/internal/lazyregexp/lazyre.go
+// with some additional methods added.
+
+// Package lazyregexp is a thin wrapper over regexp, allowing the use of global
+// regexp variables without forcing them to be compiled at init.
+package lazyregexp
+
+import (
+	"os"
+	"regexp"
+	"strings"
+	"sync"
+)
+
+// Regexp is a wrapper around [regexp.Regexp], where the underlying regexp will be
+// compiled the first time it is needed.
+type Regexp struct {
+	str  string
+	once sync.Once
+	rx   *regexp.Regexp
+}
+
+func (re *Regexp) re() *regexp.Regexp {
+	re.once.Do(re.build)
+	return re.rx
+}
+
+func (re *Regexp) build() {
+	re.rx = regexp.MustCompile(re.str)
+	re.str = ""
+}
+
+func (re *Regexp) FindStringIndex(s string) (loc []int) {
+	return re.re().FindStringIndex(s)
+}
+
+func (re *Regexp) FindStringSubmatch(s string) []string {
+	return re.re().FindStringSubmatch(s)
+}
+
+func (re *Regexp) MatchString(s string) bool {
+	return re.re().MatchString(s)
+}
+
+func (re *Regexp) ReplaceAll(src, repl []byte) []byte {
+	return re.re().ReplaceAll(src, repl)
+}
+
+func (re *Regexp) String() string {
+
path: internal/lazyregexp/lazyregexp.go
user: login: djdongjin
body: unnecessary empty line
created_at: 2025-04-13T19:35:59Z
author_association: MEMBER
diff_hunk: @@ -40,9 +40,9 @@ import (
 	"encoding/hex"
 	"fmt"
 	"net"
-	"regexp"
 	"strings"
 
+	"github.com/containerd/containerd/v2/internal/lazyregexp"
path: internal/cri/bandwidth/linux.go
user: login: thaJeztah
body: Ah! Fixed 👍 
created_at: 2025-04-14T13:50:46Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,87 @@
+/*
+   Copyright The containerd 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
+
+       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.
+*/
+
+// Copyright 2018 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Code below was largely copied from golang.org/x/mod@v0.22;
+// https://github.com/golang/mod/blob/v0.22.0/internal/lazyregexp/lazyre.go
+// with some additional methods added.
+
+// Package lazyregexp is a thin wrapper over regexp, allowing the use of global
+// regexp variables without forcing them to be compiled at init.
+package lazyregexp
+
+import (
+	"os"
+	"regexp"
+	"strings"
+	"sync"
+)
+
+// Regexp is a wrapper around [regexp.Regexp], where the underlying regexp will be
+// compiled the first time it is needed.
+type Regexp struct {
+	str  string
+	once sync.Once
+	rx   *regexp.Regexp
+}
+
+func (re *Regexp) re() *regexp.Regexp {
+	re.once.Do(re.build)
+	return re.rx
+}
+
+func (re *Regexp) build() {
+	re.rx = regexp.MustCompile(re.str)
+	re.str = ""
+}
+
+func (re *Regexp) FindStringIndex(s string) (loc []int) {
+	return re.re().FindStringIndex(s)
+}
+
+func (re *Regexp) FindStringSubmatch(s string) []string {
+	return re.re().FindStringSubmatch(s)
+}
+
+func (re *Regexp) MatchString(s string) bool {
+	return re.re().MatchString(s)
+}
+
+func (re *Regexp) ReplaceAll(src, repl []byte) []byte {
+	return re.re().ReplaceAll(src, repl)
+}
+
+func (re *Regexp) String() string {
+
path: internal/lazyregexp/lazyregexp.go
user: login: thaJeztah
body: Also fixed 👍 
created_at: 2025-04-14T13:50:59Z
author_association: MEMBER

PR comments:
user: login: k8s-ci-robot
created_at: 2025-04-12T12:59:12Z
body: Skipping CI for Draft Pull Request.
If you want CI signal for your change, please convert it to an actual PR.
You can still manually trigger a test run with `/test all`
author_association: NONE
user: login: AkihiroSuda
created_at: 2025-04-12T13:15:54Z
body: Does this have a significant benefit?
Do you have a benchmark result?

author_association: MEMBER
user: login: thaJeztah
created_at: 2025-04-12T13:56:48Z
body: I don't have benchmarks for this case (and it may depend where these packages are used); I know both @kolyshkin and @tonistiigi have looked at use of `regexp.MustCompile` in library code in various projects, as they tended to bring additional overhead even when not used; some related discussions with some examples;

- https://github.com/cilium/ebpf/pull/642#issuecomment-1110497760
- https://github.com/docker/go-units/pull/40#issue-1218150701
- https://github.com/distribution/reference/pull/15#issue-2408524721
- https://github.com/distribution/reference/pull/15#issuecomment-2228694308

author_association: MEMBER
user: login: thaJeztah
created_at: 2025-04-13T10:41:12Z
body: @AkihiroSuda OK; did some rudimentary comparisons based on just the `github.com/containerd/containerd/v2` packages (I didn't compare (clock) time, as it fluctuated too much);

- `ctr`; -16576 bytes (-9.85%), -202 allocations (-34.47%)
- `containerd`; -18616 bytes (-10.98%), -154 allocations (-6.15%)
- `containerd-shim-runc-v2`; -5040 bytes (-77.68%), -70 allocations (-76.92%)


For those, I ran (e.g.);

Before:

```
GODEBUG=inittrace=1 containerd-shim-runc-v2 --version 2>&1 | grep github.com/containerd/containerd/v2
init github.com/containerd/containerd/v2/version @0.48 ms, 0.043 ms clock, 0 bytes, 0 allocs
init github.com/containerd/containerd/v2/pkg/identifiers @3.6 ms, 0.063 ms clock, 5184 bytes, 73 allocs
init github.com/containerd/containerd/v2/pkg/protobuf @4.2 ms, 0.007 ms clock, 128 bytes, 3 allocs
init github.com/containerd/containerd/v2/core/mount @4.7 ms, 0.001 ms clock, 0 bytes, 0 allocs
init github.com/containerd/containerd/v2/pkg/sys/reaper @4.7 ms, 0 ms clock, 48 bytes, 1 allocs
init github.com/containerd/containerd/v2/core/runtime @4.8 ms, 0.007 ms clock, 992 bytes, 11 allocs
init github.com/containerd/containerd/v2/cmd/containerd-shim-runc-v2/task/plugin @4.8 ms, 0.001 ms clock, 136 bytes, 3 allocs
```

After:

```
GODEBUG=inittrace=1 containerd-shim-runc-v2 --version 2>&1 | grep github.com/containerd/containerd/v2
init github.com/containerd/containerd/v2/version @0.30 ms, 0.004 ms clock, 0 bytes, 0 allocs
init github.com/containerd/containerd/v2/internal/lazyregexp @2.1 ms, 0 ms clock, 0 bytes, 0 allocs
init github.com/containerd/containerd/v2/pkg/identifiers @2.1 ms, 0.001 ms clock, 144 bytes, 3 allocs
init github.com/containerd/containerd/v2/pkg/protobuf @2.8 ms, 0.006 ms clock, 128 bytes, 3 allocs
init github.com/containerd/containerd/v2/core/mount @3.3 ms, 0.001 ms clock, 0 bytes, 0 allocs
init github.com/containerd/containerd/v2/pkg/sys/reaper @3.3 ms, 0 ms clock, 48 bytes, 1 allocs
init github.com/containerd/containerd/v2/core/runtime @3.4 ms, 0.006 ms clock, 992 bytes, 11 allocs
init github.com/containerd/containerd/v2/cmd/containerd-shim-runc-v2/task/plugin @3.5 ms, 0.001 ms clock, 136 bytes, 3 allocs
```
author_association: MEMBER
user: login: kolyshkin
created_at: 2025-04-14T19:32:30Z
body: It might make sense to drop the use of regexp altogether at least in some cases.

Regexp is good to have when the pattern can be different (for example, supplied from config). It can be useful when the parsing logic is very complex.
 
In other cases (relatively simple pattern which won't change) it's better to reimplement the logic. 

For example, here is Validate from `pkg/indentifiers` which don't use regexp, mostly written by Claude. It's faster, cleaner, and the logic is easy to follow:

```go
func Validate(s string) error {
	if len(s) == 0 {
		return fmt.Errorf("identifier must not be empty: %w", errdefs.ErrInvalidArgument)
	}

	if len(s) > maxLength {
		return fmt.Errorf("identifier %q greater than maximum length (%d characters): %w", s, maxLength, errdefs.ErrInvalidArgument)
	}

	// Must start with alphanumeric character.
	if !isAlphaNum(s[0]) {
		return fmt.Errorf("identifier %q must begin with alphanumeric character: %w", s, errdefs.ErrInvalidArgument)
	}

	// Now check the pattern: alphanumeric followed by optional (separator + alphanumeric).
	i := 0
	// Parse first alphanumeric segment.
	for i < len(s) && isAlphaNum(s[i]) {
		i++
	}

	// Now we should have alternating separator and alphanumeric segments.
	for i < len(s) {
		// Must be a separator.
		if !isSep(s[i]) {
			return fmt.Errorf("identifier %q must match pattern of alphanumeric segments separated by '.', '_', or '-': %w", s, errdefs.ErrInvalidArgument)
		}
		i++

		// Separator must be followed by at least one alphanumeric.
		if i >= len(s) || !isAlphaNum(s[i]) {
			return fmt.Errorf("identifier %q must have alphanumeric characters after each separator: %w", s, errdefs.ErrInvalidArgument)
		}

		// Parse alphanumeric segment.
		for i < len(s) && isAlphaNum(s[i]) {
			i++
		}
	}

	return nil
}

func isAlphaNum(c byte) bool {
	return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')
}

func isSep(c byte) bool {
	return c == '.' || c == '_' || c == '-'
}
```
author_association: CONTRIBUTOR
user: login: dmcgowan
created_at: 2025-04-17T23:52:12Z
body: Can you rebase to pick up the CI fixes
author_association: MEMBER
user: login: thaJeztah
created_at: 2025-04-18T00:13:56Z
body: rebased 👍 

@kolyshkin thanks! Yes, I think rewriting some of these could make sense. I did this one as I had done this approach in some of the other repositories, and it at least addresses the immediate issue, but yeah, sometimes regexes have been used a bit too early. I might spend some time on those on a rainy afternoon
author_association: MEMBER
user: login: dmcgowan
created_at: 2025-04-22T16:34:19Z
body: Looks good, can you squash the package updates into a single commit
author_association: MEMBER
user: login: thaJeztah
created_at: 2025-04-22T18:30:33Z
body: done; let me know if this is what you meant
author_association: MEMBER
user: login: dmcgowan
created_at: 2025-04-23T04:47:12Z
body: /retest
author_association: MEMBER
user: login: thaJeztah
created_at: 2025-04-23T13:55:33Z
body: all green again 👍 
author_association: MEMBER