diff --git a/Library/Homebrew/dev-cmd/audit.rb b/Library/Homebrew/dev-cmd/audit.rb
index c03574b4a7141aa4d01e0fa176d1cf6befdcdc8d..ebc4773f91bca73e6d3431b60e387976724dd0fe 100644
--- a/Library/Homebrew/dev-cmd/audit.rb
+++ b/Library/Homebrew/dev-cmd/audit.rb
@@ -117,7 +117,7 @@ module Homebrew
     end
 
     # Check style in a single batch run up front for performance
-    style_results = Style.check_style_json(style_files, options) if style_files
+    style_offenses = Style.check_style_json(style_files, options) if style_files
     # load licenses
     spdx_license_data = SPDX.license_data
     spdx_exception_data = SPDX.exception_data
@@ -134,7 +134,7 @@ module Homebrew
         spdx_license_data:   spdx_license_data,
         spdx_exception_data: spdx_exception_data,
       }
-      options[:style_offenses] = style_results.file_offenses(f.path) if style_results
+      options[:style_offenses] = style_offenses.for_path(f.path) if style_offenses
       options[:display_cop_names] = args.display_cop_names?
       options[:build_stable] = args.build_stable?
 
diff --git a/Library/Homebrew/style.rb b/Library/Homebrew/style.rb
index 6eadd257537cc8c6d6342d5d7b7e7831e03636c1..9d31a8f7245f6b7eb8cea1c602551527943641aa 100644
--- a/Library/Homebrew/style.rb
+++ b/Library/Homebrew/style.rb
@@ -12,10 +12,17 @@ module Homebrew
     # Checks style for a list of files, printing simple RuboCop output.
     # Returns true if violations were found, false otherwise.
     def check_style_and_print(files, **options)
-      check_style_impl(files, :print, **options)
+      success = check_style_impl(files, :print, **options)
+
+      if ENV["GITHUB_ACTIONS"] && !success
+        offenses = check_style_json(files, **options)
+        puts offenses.to_github_annotations
+      end
+
+      success
     end
 
-    # Checks style for a list of files, returning results as a RubocopResults
+    # Checks style for a list of files, returning results as `Offenses`
     # object parsed from its JSON output.
     def check_style_json(files, **options)
       check_style_impl(files, :json, **options)
@@ -49,7 +56,7 @@ module Homebrew
       end
 
       if output_type == :json
-        RubocopResults.new(rubocop_result + shellcheck_result)
+        Offenses.new(rubocop_result + shellcheck_result)
       else
         rubocop_result && shellcheck_result
       end
@@ -161,12 +168,12 @@ module Homebrew
         system shellcheck, "--format=tty", *args
         $CHILD_STATUS.success?
       when :json
-        result = system_command shellcheck, args: ["--format=json1", *args]
+        result = system_command shellcheck, args: ["--format=json", *args]
         json = json_result!(result)
 
         # Convert to same format as RuboCop offenses.
-        json["comments"].group_by { |v| v["file"] }
-                        .map do |k, v|
+        json.group_by { |v| v["file"] }
+            .map do |k, v|
           {
             "path"     => k,
             "offenses" => v.map do |o|
@@ -208,25 +215,51 @@ module Homebrew
       JSON.parse(result.stdout)
     end
 
-    # Result of a RuboCop run.
-    class RubocopResults
-      def initialize(files)
-        @file_offenses = {}
-        files.each do |f|
+    # Collection of style offenses.
+    class Offenses
+      include Enumerable
+
+      def initialize(paths)
+        @offenses = {}
+        paths.each do |f|
           next if f["offenses"].empty?
 
-          file = File.realpath(f["path"])
-          @file_offenses[file] = f["offenses"].map { |x| RubocopOffense.new(x) }
+          path = Pathname(f["path"]).realpath
+          @offenses[path] = f["offenses"].map { |x| Offense.new(x) }
         end
       end
 
-      def file_offenses(path)
-        @file_offenses.fetch(path.to_s, [])
+      def for_path(path)
+        @offenses.fetch(Pathname(path), [])
+      end
+
+      def each(*args, &block)
+        @offenses.each(*args, &block)
+      end
+
+      def to_github_annotations
+        workspace = ENV["GITHUB_WORKSPACE"]
+        return [] if workspace.blank?
+
+        workspace = Pathname(workspace).realpath
+
+        @offenses.flat_map do |path, offenses|
+          relative_path = path.relative_path_from(workspace)
+
+          # Only generate annotations for paths relative to the `GITHUB_WORKSPACE` directory.
+          next [] if relative_path.descend.next.to_s == ".."
+
+          offenses.map do |o|
+            line = o.location.line
+            column = o.location.line
+            GitHub::Actions::Annotation.new(:error, o.message, file: relative_path, line: line, column: column)
+          end
+        end
       end
     end
 
-    # A RuboCop offense.
-    class RubocopOffense
+    # A style offense.
+    class Offense
       attr_reader :severity, :message, :corrected, :location, :cop_name
 
       def initialize(json)
@@ -234,7 +267,7 @@ module Homebrew
         @message = json["message"]
         @cop_name = json["cop_name"]
         @corrected = json["corrected"]
-        @location = RubocopLineLocation.new(json["location"])
+        @location = LineLocation.new(json["location"])
       end
 
       def severity_code
@@ -259,8 +292,8 @@ module Homebrew
       end
     end
 
-    # Source location of a RuboCop offense.
-    class RubocopLineLocation
+    # Source location of a style offense.
+    class LineLocation
       attr_reader :line, :column, :length
 
       def initialize(json)
diff --git a/Library/Homebrew/test/style_spec.rb b/Library/Homebrew/test/style_spec.rb
index 36f8a035e0a9a6c198dd975c8225c48668799ff4..36f9af842f2ae1eba3d105e76daea4aa89ca13d5 100644
--- a/Library/Homebrew/test/style_spec.rb
+++ b/Library/Homebrew/test/style_spec.rb
@@ -20,7 +20,7 @@ describe Homebrew::Style do
   describe ".check_style_json" do
     let(:dir) { mktmpdir }
 
-    it "returns RubocopResults when RuboCop reports offenses" do
+    it "returns offenses when RuboCop reports offenses" do
       formula = dir/"my-formula.rb"
 
       formula.write <<~'EOS'
@@ -29,9 +29,9 @@ describe Homebrew::Style do
         end
       EOS
 
-      rubocop_result = described_class.check_style_json([formula])
+      style_offenses = described_class.check_style_json([formula])
 
-      expect(rubocop_result.file_offenses(formula.realpath.to_s).map(&:message))
+      expect(style_offenses.for_path(formula.realpath).map(&:message))
         .to include("Extra empty line detected at class body beginning.")
     end
 
@@ -53,11 +53,11 @@ describe Homebrew::Style do
           end
         end
       EOS
-      rubocop_result = described_class.check_style_json(
+      style_offenses = described_class.check_style_json(
         [formula],
         fix: true, only_cops: ["FormulaAudit/DependencyOrder"],
       )
-      offense_string = rubocop_result.file_offenses(formula.realpath).first.to_s
+      offense_string = style_offenses.for_path(formula.realpath).first.to_s
       expect(offense_string).to match(/\[Corrected\]/)
     end
   end
@@ -70,9 +70,9 @@ describe Homebrew::Style do
       # but not regular, cop violations
       target_file = HOMEBREW_LIBRARY_PATH/"utils.rb"
 
-      rubocop_result = described_class.check_style_and_print([target_file])
+      style_result = described_class.check_style_and_print([target_file])
 
-      expect(rubocop_result).to eq true
+      expect(style_result).to eq true
     end
   end
 end
diff --git a/Library/Homebrew/utils/github.rb b/Library/Homebrew/utils/github.rb
index afb40bac3eccd89562bf81db1f5913503b071c60..2361abcb8c694fa382a16cc9824c5ae3663eaac1 100644
--- a/Library/Homebrew/utils/github.rb
+++ b/Library/Homebrew/utils/github.rb
@@ -2,6 +2,7 @@
 
 require "tempfile"
 require "uri"
+require "utils/github/actions"
 
 # Helper functions for interacting with the GitHub API.
 #
diff --git a/Library/Homebrew/utils/github/actions.rb b/Library/Homebrew/utils/github/actions.rb
new file mode 100644
index 0000000000000000000000000000000000000000..646945b4288b748a4510c2f859c3b3a2103e7c10
--- /dev/null
+++ b/Library/Homebrew/utils/github/actions.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module GitHub
+  # Helper functions for interacting with GitHub Actions.
+  #
+  # @api private
+  module Actions
+    def self.escape(string)
+      string.gsub(/\r/, "%0D")
+            .gsub(/\n/, "%0A")
+            .gsub(/]/, "%5D")
+            .gsub(/;/, "%3B")
+    end
+
+    # Helper class for formatting annotations on GitHub Actions.
+    class Annotation
+      def initialize(type, message, file: nil, line: nil, column: nil)
+        raise ArgumentError, "Unsupported type: #{type.inspect}" unless [:warning, :error].include?(type)
+
+        @type = type
+        @message = String(message)
+        @file = Pathname(file) if file
+        @line = Integer(line) if line
+        @column = Integer(column) if column
+      end
+
+      def to_s
+        file = "file=#{Actions.escape(@file.to_s)}" if @file
+        line = "line=#{@line}" if @line
+        column = "col=#{@column}" if @column
+
+        metadata = [*file, *line, *column].join(",").presence&.prepend(" ")
+
+        "::#{@type}#{metadata}::#{Actions.escape(@message)}"
+      end
+    end
+  end
+end
diff --git a/bin/brew b/bin/brew
index 1e01a22ce581a557d338e3015d11264540c01c65..807f6ac235741df308f4957a13cdd78ccabfece2 100755
--- a/bin/brew
+++ b/bin/brew
@@ -94,7 +94,7 @@ then
   # Filter all but the specific variables.
   for VAR in HOME SHELL PATH TERM TERMINFO COLUMNS DISPLAY LOGNAME USER CI SSH_AUTH_SOCK SUDO_ASKPASS \
              http_proxy https_proxy ftp_proxy no_proxy all_proxy HTTPS_PROXY FTP_PROXY ALL_PROXY \
-             GITHUB_ACTIONS GITHUB_ACTIONS_HOMEBREW_SELF_HOSTED \
+             GITHUB_ACTIONS GITHUB_WORKSPACE GITHUB_ACTIONS_HOMEBREW_SELF_HOSTED \
              GITHUB_REPOSITORY GITHUB_RUN_ID GITHUB_SHA GITHUB_HEAD_REF GITHUB_BASE_REF GITHUB_REF \
              "${!HOMEBREW_@}"
   do