diff --git a/src/utils/logging/process-logger.ts b/src/utils/logging/process-logger.ts
new file mode 100644
index 0000000..d5f134e
--- /dev/null
+++ b/src/utils/logging/process-logger.ts
@@ -0,0 +1,131 @@
+import { DEFAULT_NEWLINE } from "@/utils/environment";
+import { Logger } from "./logger";
+
+/**
+ * Represents a delegate type that consumes log messages.
+ */
+interface LogConsumer {
+    /**
+     * Processes a given log message.
+     *
+     * @param message - A log message to process.
+     */
+    (message: string): void;
+}
+
+/**
+ * The `process` object provides information about, and control over, the current Node.js process.
+ */
+interface Process {
+    /**
+     * A stream connected to `stdout` (fd `1`).
+     */
+    stdout?: {
+        /**
+         * Sends data on the socket.
+         */
+        write: LogConsumer;
+    };
+}
+
+/**
+ * A logger implementation that dumps formatted log messages to `stdout`.
+ *
+ * Compatible with GitHub Actions.
+ *
+ * @remarks
+ *
+ * https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-a-debug-message
+ */
+export class ProcessLogger implements Logger {
+    /**
+     * A function to consume produced log messages.
+     */
+    private readonly _logConsumer: LogConsumer;
+
+    /**
+     * The newline sequence to use when writing logs.
+     */
+    private readonly _newline: string;
+
+    /**
+     * Constructs a new {@link ProcessLogger} instance.
+     *
+     * @param process - The process this logger is attached to. Defaults to `globalThis.process`.
+     * @param newline - The newline sequence to use when writing logs. Defaults to `os.EOL`.
+     */
+    constructor(process?: Process, newline?: string);
+
+    /**
+     * Constructs a new {@link ProcessLogger} instance.
+     *
+     * @param logConsumer - The function to consume log messages.
+     * @param newline - The newline sequence to use when writing logs. Defaults to `os.EOL`.
+     */
+    constructor(logConsumer: LogConsumer, newline?: string);
+
+    /**
+     * Constructs a new {@link ProcessLogger} instance.
+     *
+     * @param processOrLogConsumer - A process this logger is attached to, or a function to consume log messages.
+     * @param newline - The newline sequence to use when writing logs. Defaults to `os.EOL`.
+     */
+    constructor(processOrLogConsumer: Process | LogConsumer, newline?: string) {
+        if (typeof processOrLogConsumer === "function") {
+            this._logConsumer = processOrLogConsumer;
+        } else {
+            const process = processOrLogConsumer ?? globalThis.process;
+            this._logConsumer =
+                typeof process.stdout?.write === "function"
+                    ? msg => process.stdout.write(msg)
+                    : (() => {});
+        }
+        this._newline = newline ?? DEFAULT_NEWLINE;
+    }
+
+    /**
+     * @inheritdoc
+     */
+    fatal(message: string | Error): void {
+        this.error(message);
+    }
+
+    /**
+     * @inheritdoc
+     */
+    error(message: string | Error): void {
+        this.log(message, "error")
+    }
+
+    /**
+     * @inheritdoc
+     */
+    warn(message: string | Error): void {
+        this.log(message, "warning")
+    }
+
+    /**
+     * @inheritdoc
+     */
+    info(message: string | Error): void {
+        this.log(message);
+    }
+
+    /**
+     * @inheritdoc
+     */
+    debug(message: string | Error): void {
+        this.log(message, "debug");
+    }
+
+    /**
+     * Logs a message with an optional log level.
+     *
+     * @param message - The message to log.
+     * @param level - Optional log level string.
+     */
+    private log(message: string | Error, level?: string): void {
+        const cmd = level ? `::${level}::` : "";
+        this._logConsumer(`${cmd}${message}${this._newline}`);
+    }
+}
diff --git a/tests/unit/utils/logging/process-logger.spec.ts b/tests/unit/utils/logging/process-logger.spec.ts
new file mode 100644
index 0000000..4745232
--- /dev/null
+++ b/tests/unit/utils/logging/process-logger.spec.ts
@@ -0,0 +1,129 @@
+import { ProcessLogger } from "@/utils/logging/process-logger";
+
+interface MockProcess {
+    stdout: {
+        write: jest.Mock;
+    };
+}
+
+function createMockProcess(): MockProcess {
+    return {
+        stdout: {
+            write: jest.fn(),
+        },
+    };
+}
+
+describe("ProcessLogger", () => {
+    describe("constructor", () => {
+        test("constructs a new instance with the provided process", () => {
+            const process = createMockProcess();
+            const logger = new ProcessLogger(process, "\n");
+
+            logger.info("Info");
+
+            expect(process.stdout.write).toHaveBeenCalledTimes(1);
+            expect(process.stdout.write).toHaveBeenCalledWith("Info\n");
+        });
+
+        test("constructor uses provided newline sequence", () => {
+            const process = createMockProcess();
+            const logger = new ProcessLogger(process, "\n\n");
+
+            logger.info("Info");
+
+            expect(process.stdout.write).toHaveBeenCalledTimes(1);
+            expect(process.stdout.write).toHaveBeenCalledWith("Info\n\n");
+        });
+
+        test("constructor uses provided log consumer", () => {
+            const logConsumer = jest.fn();
+            const logger = new ProcessLogger(logConsumer, "\n");
+
+            logger.info("Info");
+
+            expect(logConsumer).toHaveBeenCalledTimes(1);
+            expect(logConsumer).toHaveBeenCalledWith("Info\n");
+        });
+
+        test("constructor uses provided log consumer and newline sequence", () => {
+            const logConsumer = jest.fn();
+            const logger = new ProcessLogger(logConsumer, "\n\n");
+
+            logger.info("Info");
+
+            expect(logConsumer).toHaveBeenCalledTimes(1);
+            expect(logConsumer).toHaveBeenCalledWith("Info\n\n");
+        });
+    });
+
+    describe("fatal", () => {
+        test("redirects the call to process.stdout.write", () => {
+            const process = createMockProcess();
+            const logger = new ProcessLogger(process, "\n");
+
+            logger.fatal("Fatal Error");
+            logger.fatal(new Error("Fatal Error"));
+
+            expect(process.stdout.write).toHaveBeenCalledTimes(2);
+            expect(process.stdout.write).toHaveBeenNthCalledWith(1, "::error::Fatal Error\n");
+            expect(process.stdout.write).toHaveBeenNthCalledWith(2, "::error::Error: Fatal Error\n");
+        });
+    });
+
+    describe("error", () => {
+        test("redirects the call to process.stdout.write", () => {
+            const process = createMockProcess();
+            const logger = new ProcessLogger(process, "\n");
+
+            logger.error("Error");
+            logger.error(new Error("Error"));
+
+            expect(process.stdout.write).toHaveBeenCalledTimes(2);
+            expect(process.stdout.write).toHaveBeenNthCalledWith(1, "::error::Error\n");
+            expect(process.stdout.write).toHaveBeenNthCalledWith(2, "::error::Error: Error\n");
+        });
+    });
+
+    describe("warn", () => {
+        test("redirects the call to process.stdout.write", () => {
+            const process = createMockProcess();
+            const logger = new ProcessLogger(process, "\n");
+
+            logger.warn("Warning");
+            logger.warn(new Error("Warning"));
+
+            expect(process.stdout.write).toHaveBeenCalledTimes(2);
+            expect(process.stdout.write).toHaveBeenNthCalledWith(1, "::warning::Warning\n");
+            expect(process.stdout.write).toHaveBeenNthCalledWith(2, "::warning::Error: Warning\n");
+        });
+    });
+
+    describe("info", () => {
+        test("redirects the call to process.stdout.write", () => {
+            const process = createMockProcess();
+            const logger = new ProcessLogger(process, "\n");
+
+            logger.info("Info");
+            logger.info(new Error("Info"));
+
+            expect(process.stdout.write).toHaveBeenCalledTimes(2);
+            expect(process.stdout.write).toHaveBeenNthCalledWith(1, "Info\n");
+            expect(process.stdout.write).toHaveBeenNthCalledWith(2, "Error: Info\n");
+        });
+    });
+
+    describe("debug", () => {
+        test("redirects the call to process.stdout.write", () => {
+            const process = createMockProcess();
+            const logger = new ProcessLogger(process, "\n");
+
+            logger.debug("Debug Info");
+            logger.debug(new Error("Debug Info"));
+
+            expect(process.stdout.write).toHaveBeenCalledTimes(2);
+            expect(process.stdout.write).toHaveBeenNthCalledWith(1, "::debug::Debug Info\n");
+            expect(process.stdout.write).toHaveBeenNthCalledWith(2, "::debug::Error: Debug Info\n");
+        });
+    });
+});