diff --git a/.changeset/ws5-toolkit-parity-context.md b/.changeset/ws5-toolkit-parity-context.md
new file mode 100644
index 0000000..86dc318
--- /dev/null
+++ b/.changeset/ws5-toolkit-parity-context.md
@@ -0,0 +1,26 @@
+---
+"@savvy-web/github-action-effects": minor
+---
+
+## Features
+
+### Toolkit parity II — context, inputs, and core conveniences
+
+- `ActionInput.boolean` / `ActionInput.multiline`: GitHub-faithful input `Config`
+ combinators. `boolean` follows the YAML 1.2 "Core Schema" exactly
+ (`true|True|TRUE` / `false|False|FALSE`), failing on anything else — unlike
+ `Config.boolean`, which silently accepts `yes`/`on`/`1`/`no`/`off`/`0`.
+- `ActionEnvironment.payload`: parses `GITHUB_EVENT_PATH` into a schema-validated
+ `WebhookPayload` (tolerant of unknown keys; empty when unset/missing).
+ `ActionEnvironment.repo` / `.issue` mirror `@actions/github` `context.repo` /
+ `context.issue`; `ActionEnvironment.isDebug` mirrors `core.isDebug()`.
+- `WorkflowCommand.notice` + `ActionLogger.notice` for `::notice::` annotations,
+ with an `AnnotationProperties` → command-properties mapper matching the toolkit.
+- `WorkflowCommand.stopCommands` / `resumeCommands` / `setCommandEcho` for
+ untrusted-output handling.
+- `GithubMarkdown.image` / `GithubMarkdown.quote` (exact `@actions/core` summary
+ HTML).
+- `PathUtils.toPosixPath` / `toWin32Path` / `toPlatformPath`.
+- `OidcTokenIssuer.getToken(audience?)` — `audience` is now optional, matching
+ `core.getIDToken(audience?)` for cloud-provider OIDC federation. Backward
+ compatible; Sigstore callers are unaffected.
diff --git a/src/index.ts b/src/index.ts
index 8e95869..d694e04 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -166,10 +166,12 @@ export { WorkspaceDetectorLive } from "./layers/WorkspaceDetectorLive.js";
export type { WorkspaceDetectorTestState } from "./layers/WorkspaceDetectorTest.js";
export { WorkspaceDetectorTest } from "./layers/WorkspaceDetectorTest.js";
// -- Runtime --
+export { ActionInput } from "./runtime/ActionInput.js";
export { ActionsConfigProvider } from "./runtime/ActionsConfigProvider.js";
export { ActionsLogger } from "./runtime/ActionsLogger.js";
export { ActionsRuntime } from "./runtime/ActionsRuntime.js";
export * as Step from "./runtime/Step.js";
+export type { AnnotationProperties } from "./runtime/WorkflowCommand.js";
export type { AttestInput, AttestationRecord } from "./schemas/Attestation.js";
export {
CYCLONEDX_BOM,
@@ -190,6 +192,8 @@ export type {
export { BumpType, Changeset, ChangesetFile } from "./schemas/Changeset.js";
export type { GitHubContext as GitHubContextType, RunnerContext as RunnerContextType } from "./schemas/Environment.js";
export { GitHubContext, RunnerContext } from "./schemas/Environment.js";
+export type { WebhookPayload as WebhookPayloadType } from "./schemas/EventPayload.js";
+export { WebhookPayload } from "./schemas/EventPayload.js";
export { CapturedOutput, ChecklistItem, Status } from "./schemas/GithubMarkdown.js";
export type { FileChange as FileChangeType, TreeEntry as TreeEntryType } from "./schemas/GitTree.js";
export {
@@ -313,6 +317,7 @@ export { ErrorAccumulator } from "./utils/ErrorAccumulator.js";
export { GithubMarkdown } from "./utils/GithubMarkdown.js";
export { IoUtil } from "./utils/IoUtil.js";
export { buildStatement, npmPurl, serializeStatement, subject } from "./utils/intoto.js";
+export { PathUtils } from "./utils/PathUtils.js";
export type { RegistryType } from "./utils/RegistryClassifier.js";
export {
generatePackageViewUrl,
diff --git a/src/layers/ActionEnvironmentLive.test.ts b/src/layers/ActionEnvironmentLive.test.ts
index 060a5f4..527a3e2 100644
--- a/src/layers/ActionEnvironmentLive.test.ts
+++ b/src/layers/ActionEnvironmentLive.test.ts
@@ -1,4 +1,9 @@
-import { Effect, Option } from "effect";
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import type { FileSystem } from "@effect/platform";
+import { NodeFileSystem } from "@effect/platform-node";
+import { Effect, Layer, Option } from "effect";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ActionEnvironment } from "../services/ActionEnvironment.js";
import { ActionEnvironmentLive } from "./ActionEnvironmentLive.js";
@@ -9,6 +14,15 @@ const run = (effect: Effect.Effect) =>
const runExit = (effect: Effect.Effect) =>
Effect.runPromise(Effect.exit(Effect.provide(effect, ActionEnvironmentLive)));
+// `payload` / `repo` / `issue` require a FileSystem in their R channel.
+const fsLayer = Layer.merge(ActionEnvironmentLive, NodeFileSystem.layer);
+
+const runFs = (effect: Effect.Effect) =>
+ Effect.runPromise(Effect.provide(effect, fsLayer));
+
+const runFsExit = (effect: Effect.Effect) =>
+ Effect.runPromise(Effect.exit(Effect.provide(effect, fsLayer)));
+
const savedEnv: Record = {};
const setEnv = (vars: Record) => {
@@ -109,4 +123,136 @@ describe("ActionEnvironmentLive", () => {
expect(result.debug).toBe(true);
});
});
+
+ describe("isDebug", () => {
+ it("is true when RUNNER_DEBUG=1", async () => {
+ process.env.RUNNER_DEBUG = "1";
+ const result = await run(Effect.flatMap(ActionEnvironment, (svc) => svc.isDebug));
+ expect(result).toBe(true);
+ });
+
+ it("is false when RUNNER_DEBUG is unset", async () => {
+ delete process.env.RUNNER_DEBUG;
+ const result = await run(Effect.flatMap(ActionEnvironment, (svc) => svc.isDebug));
+ expect(result).toBe(false);
+ });
+
+ it("is false when RUNNER_DEBUG=0", async () => {
+ process.env.RUNNER_DEBUG = "0";
+ const result = await run(Effect.flatMap(ActionEnvironment, (svc) => svc.isDebug));
+ expect(result).toBe(false);
+ });
+ });
+
+ describe("payload / repo / issue", () => {
+ let tmp: string;
+ const restore: Record = {};
+
+ const overrideEnv = (vars: Record) => {
+ for (const [key, value] of Object.entries(vars)) {
+ if (!(key in restore)) restore[key] = process.env[key];
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ }
+ };
+
+ beforeEach(() => {
+ tmp = mkdtempSync(join(tmpdir(), "action-env-"));
+ });
+
+ afterEach(() => {
+ for (const [key, value] of Object.entries(restore)) {
+ if (value === undefined) delete process.env[key];
+ else process.env[key] = value;
+ delete restore[key];
+ }
+ rmSync(tmp, { recursive: true, force: true });
+ });
+
+ it("payload parses GITHUB_EVENT_PATH JSON", async () => {
+ const eventFile = join(tmp, "event.json");
+ writeFileSync(eventFile, JSON.stringify({ pull_request: { number: 9 } }));
+ overrideEnv({ GITHUB_EVENT_PATH: eventFile });
+ const result = await runFs(Effect.flatMap(ActionEnvironment, (svc) => svc.payload));
+ expect(result.pull_request?.number).toBe(9);
+ });
+
+ it("payload is empty when GITHUB_EVENT_PATH is unset", async () => {
+ overrideEnv({ GITHUB_EVENT_PATH: undefined });
+ const result = await runFs(Effect.flatMap(ActionEnvironment, (svc) => svc.payload));
+ expect(result).toEqual({});
+ });
+
+ it("payload is empty (no throw) when the file is missing", async () => {
+ overrideEnv({ GITHUB_EVENT_PATH: join(tmp, "does-not-exist.json") });
+ const result = await runFs(Effect.flatMap(ActionEnvironment, (svc) => svc.payload));
+ expect(result).toEqual({});
+ });
+
+ it("payload fails ActionEnvironmentError on malformed JSON", async () => {
+ const eventFile = join(tmp, "bad.json");
+ writeFileSync(eventFile, "{ not json");
+ overrideEnv({ GITHUB_EVENT_PATH: eventFile });
+ const exit = await runFsExit(Effect.flatMap(ActionEnvironment, (svc) => svc.payload));
+ expect(exit._tag).toBe("Failure");
+ if (exit._tag === "Failure") {
+ const cause = JSON.stringify(exit.cause);
+ expect(cause).toContain("ActionEnvironmentError");
+ expect(cause).toContain("GITHUB_EVENT_PATH");
+ }
+ });
+
+ it("repo reads GITHUB_REPOSITORY first", async () => {
+ overrideEnv({ GITHUB_REPOSITORY: "o/r", GITHUB_EVENT_PATH: undefined });
+ const result = await runFs(Effect.flatMap(ActionEnvironment, (svc) => svc.repo));
+ expect(result).toEqual({ owner: "o", repo: "r" });
+ });
+
+ it("repo falls back to payload.repository", async () => {
+ const eventFile = join(tmp, "event.json");
+ writeFileSync(eventFile, JSON.stringify({ repository: { name: "rn", owner: { login: "ol" } } }));
+ overrideEnv({ GITHUB_REPOSITORY: undefined, GITHUB_EVENT_PATH: eventFile });
+ const result = await runFs(Effect.flatMap(ActionEnvironment, (svc) => svc.repo));
+ expect(result).toEqual({ owner: "ol", repo: "rn" });
+ });
+
+ it("repo fails when neither GITHUB_REPOSITORY nor payload.repository is available", async () => {
+ overrideEnv({ GITHUB_REPOSITORY: undefined, GITHUB_EVENT_PATH: undefined });
+ const exit = await runFsExit(Effect.flatMap(ActionEnvironment, (svc) => svc.repo));
+ expect(exit._tag).toBe("Failure");
+ if (exit._tag === "Failure") {
+ expect(JSON.stringify(exit.cause)).toContain("GITHUB_REPOSITORY");
+ }
+ });
+
+ it("issue resolves number from issue", async () => {
+ const eventFile = join(tmp, "event.json");
+ writeFileSync(eventFile, JSON.stringify({ issue: { number: 1 } }));
+ overrideEnv({ GITHUB_REPOSITORY: "o/r", GITHUB_EVENT_PATH: eventFile });
+ const result = await runFs(Effect.flatMap(ActionEnvironment, (svc) => svc.issue));
+ expect(result).toEqual({ owner: "o", repo: "r", number: 1 });
+ });
+
+ it("issue resolves number from pull_request", async () => {
+ const eventFile = join(tmp, "event.json");
+ writeFileSync(eventFile, JSON.stringify({ pull_request: { number: 2 } }));
+ overrideEnv({ GITHUB_REPOSITORY: "o/r", GITHUB_EVENT_PATH: eventFile });
+ const result = await runFs(Effect.flatMap(ActionEnvironment, (svc) => svc.issue));
+ expect(result.number).toBe(2);
+ });
+
+ it("issue resolves number from top-level payload.number", async () => {
+ const eventFile = join(tmp, "event.json");
+ writeFileSync(eventFile, JSON.stringify({ number: 3 }));
+ overrideEnv({ GITHUB_REPOSITORY: "o/r", GITHUB_EVENT_PATH: eventFile });
+ const result = await runFs(Effect.flatMap(ActionEnvironment, (svc) => svc.issue));
+ expect(result.number).toBe(3);
+ });
+
+ it("issue fails when no number is present", async () => {
+ overrideEnv({ GITHUB_REPOSITORY: "o/r", GITHUB_EVENT_PATH: undefined });
+ const exit = await runFsExit(Effect.flatMap(ActionEnvironment, (svc) => svc.issue));
+ expect(exit._tag).toBe("Failure");
+ });
+ });
});
diff --git a/src/layers/ActionEnvironmentLive.ts b/src/layers/ActionEnvironmentLive.ts
index 9a96a05..0a10644 100644
--- a/src/layers/ActionEnvironmentLive.ts
+++ b/src/layers/ActionEnvironmentLive.ts
@@ -1,6 +1,8 @@
-import { Effect, Layer, Option } from "effect";
+import { FileSystem } from "@effect/platform";
+import { Effect, Layer, Option, Schema } from "effect";
import { ActionEnvironmentError } from "../errors/ActionEnvironmentError.js";
import type { GitHubContext, RunnerContext } from "../schemas/Environment.js";
+import { WebhookPayload } from "../schemas/EventPayload.js";
import { ActionEnvironment } from "../services/ActionEnvironment.js";
import { GITHUB_ENV_MAP } from "./internal/environmentMaps.js";
@@ -38,9 +40,121 @@ const buildRunnerContext: Effect.Effect =
debug: Effect.sync(() => process.env.RUNNER_DEBUG === "1"),
});
+const EMPTY_PAYLOAD: WebhookPayload = {};
+
+const decodePayload = (raw: string): Effect.Effect =>
+ Effect.try({
+ try: () => JSON.parse(raw) as unknown,
+ catch: (error) =>
+ new ActionEnvironmentError({
+ variable: "GITHUB_EVENT_PATH",
+ reason: `Failed to parse event payload JSON: ${error instanceof Error ? error.message : String(error)}`,
+ }),
+ }).pipe(
+ Effect.flatMap((data) =>
+ Schema.decodeUnknown(WebhookPayload, { onExcessProperty: "preserve" })(data).pipe(
+ Effect.mapError(
+ (error) =>
+ new ActionEnvironmentError({
+ variable: "GITHUB_EVENT_PATH",
+ reason: `Event payload did not match the expected shape: ${error.message}`,
+ }),
+ ),
+ ),
+ ),
+ );
+
+/**
+ * Read and decode the `GITHUB_EVENT_PATH` payload. Degrades to an empty payload
+ * (no failure) when the env var is unset or the file is missing — matching
+ * `@actions/github`, which warns and uses `{}`.
+ */
+const readPayload: Effect.Effect = Effect.gen(
+ function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = process.env.GITHUB_EVENT_PATH;
+ if (path === undefined || path === "") {
+ return EMPTY_PAYLOAD;
+ }
+ const exists = yield* fs.exists(path).pipe(Effect.orElseSucceed(() => false));
+ if (!exists) {
+ yield* Effect.logDebug(`GITHUB_EVENT_PATH ${path} does not exist; using an empty payload`);
+ return EMPTY_PAYLOAD;
+ }
+ const raw = yield* fs.readFileString(path).pipe(
+ Effect.mapError(
+ (error) =>
+ new ActionEnvironmentError({
+ variable: "GITHUB_EVENT_PATH",
+ reason: `Failed to read event payload file: ${error.message}`,
+ }),
+ ),
+ );
+ return yield* decodePayload(raw);
+ },
+);
+
+const repoFromEnvOrPayload = (
+ payload: WebhookPayload,
+): Effect.Effect<{ owner: string; repo: string }, ActionEnvironmentError> => {
+ const repository = process.env.GITHUB_REPOSITORY;
+ if (repository !== undefined && repository !== "") {
+ const [owner, repo] = repository.split("/");
+ if (owner !== undefined && repo !== undefined) {
+ return Effect.succeed({ owner, repo });
+ }
+ }
+ if (payload.repository !== undefined) {
+ return Effect.succeed({
+ owner: payload.repository.owner.login,
+ repo: payload.repository.name,
+ });
+ }
+ return Effect.fail(
+ new ActionEnvironmentError({
+ variable: "GITHUB_REPOSITORY",
+ reason:
+ "context.repo requires a GITHUB_REPOSITORY environment variable like 'owner/repo' or a repository in the event payload",
+ }),
+ );
+};
+
+const issueNumber = (payload: WebhookPayload): number | undefined =>
+ payload.issue?.number ?? payload.pull_request?.number ?? payload.number;
+
+const buildRepo: Effect.Effect<{ owner: string; repo: string }, ActionEnvironmentError, FileSystem.FileSystem> =
+ readPayload.pipe(Effect.flatMap(repoFromEnvOrPayload));
+
+const buildIssue: Effect.Effect<
+ { owner: string; repo: string; number: number },
+ ActionEnvironmentError,
+ FileSystem.FileSystem
+> = readPayload.pipe(
+ Effect.flatMap((payload) =>
+ repoFromEnvOrPayload(payload).pipe(
+ Effect.flatMap((repo) => {
+ const number = issueNumber(payload);
+ if (number === undefined) {
+ return Effect.fail(
+ new ActionEnvironmentError({
+ variable: "GITHUB_EVENT_PATH",
+ reason: "context.issue requires an issue, pull_request, or top-level number in the event payload",
+ }),
+ );
+ }
+ return Effect.succeed({ ...repo, number });
+ }),
+ ),
+ ),
+);
+
export const ActionEnvironmentLive: Layer.Layer = Layer.succeed(ActionEnvironment, {
get: (name) => readEnv(name),
getOptional: (name) => readOptionalEnv(name),
github: buildGitHubContext,
runner: buildRunnerContext,
+ isDebug: Effect.sync(() => process.env.RUNNER_DEBUG === "1"),
+ payload: readPayload,
+ repo: buildRepo,
+ issue: buildIssue,
});
diff --git a/src/layers/ActionEnvironmentTest.test.ts b/src/layers/ActionEnvironmentTest.test.ts
new file mode 100644
index 0000000..e5b777b
--- /dev/null
+++ b/src/layers/ActionEnvironmentTest.test.ts
@@ -0,0 +1,109 @@
+import type { FileSystem } from "@effect/platform";
+import { NodeFileSystem } from "@effect/platform-node";
+import { Effect, Layer } from "effect";
+import { describe, expect, it } from "vitest";
+import { ActionEnvironment } from "../services/ActionEnvironment.js";
+import { ActionEnvironmentTest } from "./ActionEnvironmentTest.js";
+
+// payload/repo/issue carry a FileSystem requirement in their interface type
+// (even though the Test layer does not use it), so tests provide one.
+type Deps = ActionEnvironment | FileSystem.FileSystem;
+
+const runEmpty = (effect: Effect.Effect) =>
+ Effect.runPromise(Effect.provide(effect, Layer.merge(ActionEnvironmentTest.empty(), NodeFileSystem.layer)));
+
+const runLayer = (
+ env: Record,
+ payload: Parameters[1],
+ effect: Effect.Effect,
+) =>
+ Effect.runPromise(
+ Effect.provide(effect, Layer.merge(ActionEnvironmentTest.layer(env, payload), NodeFileSystem.layer)),
+ );
+
+const exitLayer = (
+ env: Record,
+ payload: Parameters[1],
+ effect: Effect.Effect,
+) =>
+ Effect.runPromise(
+ Effect.exit(Effect.provide(effect, Layer.merge(ActionEnvironmentTest.layer(env, payload), NodeFileSystem.layer))),
+ );
+
+describe("ActionEnvironmentTest", () => {
+ describe("empty", () => {
+ it("payload is {}", async () => {
+ const result = await runEmpty(Effect.flatMap(ActionEnvironment, (svc) => svc.payload));
+ expect(result).toEqual({});
+ });
+
+ it("isDebug is false", async () => {
+ const result = await runEmpty(Effect.flatMap(ActionEnvironment, (svc) => svc.isDebug));
+ expect(result).toBe(false);
+ });
+
+ it("repo derives from the default owner/repo", async () => {
+ const result = await runEmpty(Effect.flatMap(ActionEnvironment, (svc) => svc.repo));
+ expect(result).toEqual({ owner: "owner", repo: "repo" });
+ });
+
+ it("issue fails (no number seeded)", async () => {
+ const exit = await Effect.runPromise(
+ Effect.exit(
+ Effect.provide(
+ Effect.flatMap(ActionEnvironment, (svc) => svc.issue),
+ Layer.merge(ActionEnvironmentTest.empty(), NodeFileSystem.layer),
+ ),
+ ),
+ );
+ expect(exit._tag).toBe("Failure");
+ });
+ });
+
+ describe("layer", () => {
+ it("isDebug reads RUNNER_DEBUG", async () => {
+ const result = await runLayer(
+ { RUNNER_DEBUG: "1" },
+ {},
+ Effect.flatMap(ActionEnvironment, (svc) => svc.isDebug),
+ );
+ expect(result).toBe(true);
+ });
+
+ it("accepts an injected payload", async () => {
+ const result = await runLayer(
+ {},
+ { pull_request: { number: 5 } },
+ Effect.flatMap(ActionEnvironment, (svc) => svc.payload),
+ );
+ expect(result.pull_request?.number).toBe(5);
+ });
+
+ it("repo reads GITHUB_REPOSITORY from the env record", async () => {
+ const result = await runLayer(
+ { GITHUB_REPOSITORY: "acme/widgets" },
+ {},
+ Effect.flatMap(ActionEnvironment, (svc) => svc.repo),
+ );
+ expect(result).toEqual({ owner: "acme", repo: "widgets" });
+ });
+
+ it("issue cascade resolves from the injected payload", async () => {
+ const result = await runLayer(
+ { GITHUB_REPOSITORY: "acme/widgets" },
+ { issue: { number: 11 } },
+ Effect.flatMap(ActionEnvironment, (svc) => svc.issue),
+ );
+ expect(result).toEqual({ owner: "acme", repo: "widgets", number: 11 });
+ });
+
+ it("issue fails when neither env nor payload supply a number", async () => {
+ const exit = await exitLayer(
+ {},
+ {},
+ Effect.flatMap(ActionEnvironment, (svc) => svc.issue),
+ );
+ expect(exit._tag).toBe("Failure");
+ });
+ });
+});
diff --git a/src/layers/ActionEnvironmentTest.ts b/src/layers/ActionEnvironmentTest.ts
index 506da14..220cfa7 100644
--- a/src/layers/ActionEnvironmentTest.ts
+++ b/src/layers/ActionEnvironmentTest.ts
@@ -1,9 +1,54 @@
import { Effect, Layer, Option } from "effect";
import { ActionEnvironmentError } from "../errors/ActionEnvironmentError.js";
import type { GitHubContext, RunnerContext } from "../schemas/Environment.js";
+import type { WebhookPayload } from "../schemas/EventPayload.js";
import { ActionEnvironment } from "../services/ActionEnvironment.js";
import { GITHUB_ENV_MAP, RUNNER_ENV_MAP } from "./internal/environmentMaps.js";
+const repoFromEnvOrPayload = (
+ repository: string | undefined,
+ payload: WebhookPayload,
+): Effect.Effect<{ owner: string; repo: string }, ActionEnvironmentError> => {
+ if (repository !== undefined && repository !== "") {
+ const [owner, repo] = repository.split("/");
+ if (owner !== undefined && repo !== undefined) {
+ return Effect.succeed({ owner, repo });
+ }
+ }
+ if (payload.repository !== undefined) {
+ return Effect.succeed({
+ owner: payload.repository.owner.login,
+ repo: payload.repository.name,
+ });
+ }
+ return Effect.fail(
+ new ActionEnvironmentError({
+ variable: "GITHUB_REPOSITORY",
+ reason:
+ "context.repo requires a GITHUB_REPOSITORY environment variable like 'owner/repo' or a repository in the event payload",
+ }),
+ );
+};
+
+const issueFromPayload = (
+ repository: string | undefined,
+ payload: WebhookPayload,
+): Effect.Effect<{ owner: string; repo: string; number: number }, ActionEnvironmentError> =>
+ repoFromEnvOrPayload(repository, payload).pipe(
+ Effect.flatMap((repo) => {
+ const number = payload.issue?.number ?? payload.pull_request?.number ?? payload.number;
+ if (number === undefined) {
+ return Effect.fail(
+ new ActionEnvironmentError({
+ variable: "GITHUB_EVENT_PATH",
+ reason: "context.issue requires an issue, pull_request, or top-level number in the event payload",
+ }),
+ );
+ }
+ return Effect.succeed({ ...repo, number });
+ }),
+ );
+
const defaultGitHub: GitHubContext = {
sha: "abc1234567890def",
ref: "refs/heads/main",
@@ -38,8 +83,12 @@ const defaultRunner: RunnerContext = {
* @public
*/
export const ActionEnvironmentTest = {
- /** Create test layer from env record. Builds contexts from GITHUB_* /RUNNER_* keys. */
- layer: (env: Record): Layer.Layer =>
+ /**
+ * Create test layer from an env record. Builds contexts from
+ * GITHUB_* / RUNNER_* keys. An optional `payload` seeds `payload` / `repo`
+ * / `issue` without a real event file.
+ */
+ layer: (env: Record, payload: WebhookPayload = {}): Layer.Layer =>
Layer.succeed(ActionEnvironment, {
get: (name: string) => {
const value = env[name];
@@ -77,6 +126,11 @@ export const ActionEnvironmentTest = {
),
debug: env.RUNNER_DEBUG === "1",
} as RunnerContext),
+
+ isDebug: Effect.succeed(env.RUNNER_DEBUG === "1"),
+ payload: Effect.succeed(payload),
+ repo: repoFromEnvOrPayload(env.GITHUB_REPOSITORY, payload),
+ issue: issueFromPayload(env.GITHUB_REPOSITORY, payload),
}),
/** Create test layer with default GitHub Actions environment. */
@@ -92,5 +146,11 @@ export const ActionEnvironmentTest = {
getOptional: (_name: string) => Effect.succeed(Option.none()),
github: Effect.succeed(defaultGitHub),
runner: Effect.succeed(defaultRunner),
+ isDebug: Effect.succeed(false),
+ payload: Effect.succeed({}),
+ // `.empty()` derives repo from the default `owner/repo`; issue fails
+ // (no number seeded).
+ repo: repoFromEnvOrPayload(defaultGitHub.repository, {}),
+ issue: issueFromPayload(defaultGitHub.repository, {}),
}),
} as const;
diff --git a/src/layers/ActionLoggerLive.test.ts b/src/layers/ActionLoggerLive.test.ts
index d7d99a4..3e03791 100644
--- a/src/layers/ActionLoggerLive.test.ts
+++ b/src/layers/ActionLoggerLive.test.ts
@@ -91,6 +91,36 @@ describe("ActionLoggerLive", () => {
});
});
+ describe("notice", () => {
+ let writeSpy: ReturnType;
+
+ beforeEach(() => {
+ writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
+ });
+
+ afterEach(() => {
+ writeSpy.mockRestore();
+ });
+
+ it("issues a ::notice:: command", async () => {
+ await run(Effect.flatMap(ActionLogger, (svc) => svc.notice("heads up")));
+ const written = writeSpy.mock.calls.map((c: unknown[]) => String(c[0]));
+ expect(written.some((s: string) => s.includes("::notice::heads up"))).toBe(true);
+ });
+
+ it("forwards annotation properties (startLine→line, startColumn→col)", async () => {
+ await run(Effect.flatMap(ActionLogger, (svc) => svc.notice("x", { file: "a.ts", startLine: 3, startColumn: 5 })));
+ const written = writeSpy.mock.calls.map((c: unknown[]) => String(c[0]));
+ expect(written.some((s: string) => s.includes("::notice file=a.ts,line=3,col=5::x"))).toBe(true);
+ });
+
+ it("logInfo still emits plain stdout, not ::notice:: (no level remap)", async () => {
+ await run(Effect.logInfo("routine info"));
+ const written = writeSpy.mock.calls.map((c: unknown[]) => String(c[0]));
+ expect(written.some((s: string) => s.includes("::notice"))).toBe(false);
+ });
+ });
+
describe("per-group buffer flush", () => {
let writeSpy: ReturnType;
diff --git a/src/layers/ActionLoggerLive.ts b/src/layers/ActionLoggerLive.ts
index 6920a40..51df2f9 100644
--- a/src/layers/ActionLoggerLive.ts
+++ b/src/layers/ActionLoggerLive.ts
@@ -87,4 +87,6 @@ export const ActionLoggerLive: Layer.Layer = Layer.succeed(ActionL
Effect.tapErrorCause(() => Effect.sync(() => flushBuffer(state))),
);
}) as Effect.Effect>,
+
+ notice: (message, properties) => Effect.sync(() => WorkflowCommand.notice(properties ?? {}, message)),
});
diff --git a/src/layers/ActionLoggerTest.test.ts b/src/layers/ActionLoggerTest.test.ts
index cd15003..34b72a7 100644
--- a/src/layers/ActionLoggerTest.test.ts
+++ b/src/layers/ActionLoggerTest.test.ts
@@ -13,6 +13,28 @@ describe("ActionLoggerTest", () => {
expect(state.entries).toEqual([]);
expect(state.groups).toEqual([]);
expect(state.flushedBuffers).toEqual([]);
+ expect(state.notices).toEqual([]);
+ });
+ });
+
+ describe("notice", () => {
+ it("records a notice into captured state", async () => {
+ const state = ActionLoggerTest.empty();
+ await run(
+ state,
+ Effect.flatMap(ActionLogger, (svc) => svc.notice("heads up")),
+ );
+ expect(state.notices).toHaveLength(1);
+ expect(state.notices[0]?.message).toBe("heads up");
+ });
+
+ it("forwards annotation properties", async () => {
+ const state = ActionLoggerTest.empty();
+ await run(
+ state,
+ Effect.flatMap(ActionLogger, (svc) => svc.notice("at line", { file: "a.ts", startLine: 3 })),
+ );
+ expect(state.notices[0]?.properties).toEqual({ file: "a.ts", startLine: 3 });
});
});
diff --git a/src/layers/ActionLoggerTest.ts b/src/layers/ActionLoggerTest.ts
index a33245c..e24a675 100644
--- a/src/layers/ActionLoggerTest.ts
+++ b/src/layers/ActionLoggerTest.ts
@@ -1,5 +1,6 @@
import { Effect, FiberRef, Layer, LogLevel } from "effect";
import type { Scope } from "effect/Scope";
+import type { AnnotationProperties } from "../runtime/WorkflowCommand.js";
import { ActionLogger } from "../services/ActionLogger.js";
/**
@@ -15,6 +16,10 @@ export interface ActionLoggerTestState {
readonly label: string;
readonly entries: Array;
}>;
+ readonly notices: Array<{
+ readonly message: string;
+ readonly properties: AnnotationProperties | undefined;
+ }>;
}
/**
@@ -34,6 +39,7 @@ export const ActionLoggerTest = {
entries: [],
groups: [],
flushedBuffers: [],
+ notices: [],
}),
/**
@@ -64,5 +70,10 @@ export const ActionLoggerTest = {
),
);
}) as Effect.Effect>,
+
+ notice: (message, properties) =>
+ Effect.sync(() => {
+ state.notices.push({ message, properties });
+ }),
}),
} as const;
diff --git a/src/layers/OidcTokenIssuerLive.ts b/src/layers/OidcTokenIssuerLive.ts
index 37d2ec8..39a37a3 100644
--- a/src/layers/OidcTokenIssuerLive.ts
+++ b/src/layers/OidcTokenIssuerLive.ts
@@ -9,10 +9,12 @@
* - `ACTIONS_ID_TOKEN_REQUEST_TOKEN` — bearer token authorizing the request
* - `ACTIONS_ID_TOKEN_REQUEST_URL` — token issuance endpoint
*
- * Callers pass an audience (e.g. `"sigstore"` for Fulcio cert issuance) and
- * receive a {@link Redacted} JWT. The redacted wrapper keeps the JWT out of
- * default `toString` / log paths; the value is unwrapped only at the point
- * where it crosses the wire to Fulcio or Rekor.
+ * Callers may pass an optional audience (e.g. `"sigstore"` for Fulcio cert
+ * issuance) and receive a {@link Redacted} JWT. When the audience is omitted,
+ * no `audience` query param is sent (matching `@actions/core.getIDToken`). The
+ * redacted wrapper keeps the JWT out of default `toString` / log paths; the
+ * value is unwrapped only at the point where it crosses the wire to Fulcio or
+ * Rekor.
*
* The implementation depends on {@link HttpClient.HttpClient} so the service
* composes with `FetchHttpClient.layer` in production and an in-memory mock
@@ -62,15 +64,17 @@ export const OidcTokenIssuerLive = Layer.effect(
const http = yield* HttpClient.HttpClient;
return {
- getToken: (audience: string) =>
+ getToken: (audience?: string) =>
Effect.gen(function* () {
const bearer = yield* readEnv(ACTIONS_ID_TOKEN_REQUEST_TOKEN);
const baseUrl = yield* readEnv(ACTIONS_ID_TOKEN_REQUEST_URL);
- const url = new URL(baseUrl);
- url.searchParams.set("audience", audience);
+ // Match @actions/core.getIDToken: when an audience is provided,
+ // append `&audience=` to the URL;
+ // otherwise leave the URL unchanged (no audience param at all).
+ const url = audience !== undefined ? `${baseUrl}&audience=${encodeURIComponent(audience)}` : baseUrl;
- const request = HttpClientRequest.get(url.toString()).pipe(
+ const request = HttpClientRequest.get(url).pipe(
HttpClientRequest.bearerToken(bearer),
HttpClientRequest.acceptJson,
);
diff --git a/src/layers/OidcTokenIssuerTest.ts b/src/layers/OidcTokenIssuerTest.ts
index 0ecab67..c78db54 100644
--- a/src/layers/OidcTokenIssuerTest.ts
+++ b/src/layers/OidcTokenIssuerTest.ts
@@ -7,5 +7,5 @@ import { OidcTokenIssuer } from "../services/OidcTokenIssuer.js";
* @public
*/
export const OidcTokenIssuerTest: Layer.Layer = Layer.succeed(OidcTokenIssuer, {
- getToken: () => Effect.succeed(Redacted.make("test-oidc-token")),
+ getToken: (_audience?: string) => Effect.succeed(Redacted.make("test-oidc-token")),
});
diff --git a/src/runtime/ActionInput.test.ts b/src/runtime/ActionInput.test.ts
new file mode 100644
index 0000000..df5470b
--- /dev/null
+++ b/src/runtime/ActionInput.test.ts
@@ -0,0 +1,143 @@
+import { Config, ConfigError, Effect } from "effect";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { ActionInput } from "./ActionInput.js";
+import { ActionsConfigProvider } from "./ActionsConfigProvider.js";
+
+const run = (config: Config.Config) =>
+ Effect.runPromise(Effect.withConfigProvider(ActionsConfigProvider)(config));
+
+const runExit = (config: Config.Config) =>
+ Effect.runPromiseExit(Effect.withConfigProvider(ActionsConfigProvider)(config));
+
+let envKeysSet: string[] = [];
+
+const setEnv = (key: string, value: string) => {
+ process.env[key] = value;
+ envKeysSet.push(key);
+};
+
+beforeEach(() => {
+ envKeysSet = [];
+});
+
+afterEach(() => {
+ for (const key of envKeysSet) {
+ delete process.env[key];
+ }
+ envKeysSet = [];
+});
+
+describe("ActionInput.boolean (YAML 1.2 Core Schema)", () => {
+ it("accepts 'true' → true", async () => {
+ setEnv("INPUT_FLAG", "true");
+ expect(await run(ActionInput.boolean("flag"))).toBe(true);
+ });
+
+ it("accepts 'True' → true", async () => {
+ setEnv("INPUT_FLAG", "True");
+ expect(await run(ActionInput.boolean("flag"))).toBe(true);
+ });
+
+ it("accepts 'TRUE' → true", async () => {
+ setEnv("INPUT_FLAG", "TRUE");
+ expect(await run(ActionInput.boolean("flag"))).toBe(true);
+ });
+
+ it("accepts 'false' → false", async () => {
+ setEnv("INPUT_FLAG", "false");
+ expect(await run(ActionInput.boolean("flag"))).toBe(false);
+ });
+
+ it("accepts 'False' → false", async () => {
+ setEnv("INPUT_FLAG", "False");
+ expect(await run(ActionInput.boolean("flag"))).toBe(false);
+ });
+
+ it("accepts 'FALSE' → false", async () => {
+ setEnv("INPUT_FLAG", "FALSE");
+ expect(await run(ActionInput.boolean("flag"))).toBe(false);
+ });
+
+ it("tolerates surrounding whitespace (toolkit trims before the check)", async () => {
+ setEnv("INPUT_FLAG", " true ");
+ expect(await run(ActionInput.boolean("flag"))).toBe(true);
+ });
+
+ const expectInvalidData = async (value: string) => {
+ setEnv("INPUT_FLAG", value);
+ const exit = await runExit(ActionInput.boolean("flag"));
+ expect(exit._tag).toBe("Failure");
+ if (exit._tag === "Failure" && exit.cause._tag === "Fail") {
+ expect(ConfigError.isInvalidData(exit.cause.error)).toBe(true);
+ } else {
+ throw new Error(`expected a Fail cause, got ${JSON.stringify(exit)}`);
+ }
+ };
+
+ it("rejects 'yes' with ConfigError.InvalidData", () => expectInvalidData("yes"));
+ it("rejects 'on' with ConfigError.InvalidData", () => expectInvalidData("on"));
+ it("rejects '1' with ConfigError.InvalidData", () => expectInvalidData("1"));
+ it("rejects '0' with ConfigError.InvalidData", () => expectInvalidData("0"));
+ it("rejects 'no' with ConfigError.InvalidData", () => expectInvalidData("no"));
+ it("rejects 'off' with ConfigError.InvalidData", () => expectInvalidData("off"));
+ it("rejects 'tRue' (mixed case) with ConfigError.InvalidData", () => expectInvalidData("tRue"));
+
+ it("error message cites the YAML 1.2 Core Schema list", async () => {
+ setEnv("INPUT_FLAG", "yes");
+ const exit = await runExit(ActionInput.boolean("flag"));
+ expect(exit._tag).toBe("Failure");
+ if (exit._tag === "Failure" && exit.cause._tag === "Fail") {
+ const message = JSON.stringify(exit.cause.error);
+ expect(message).toContain("Input does not meet YAML 1.2");
+ expect(message).toContain("Core Schema");
+ expect(message).toContain("true | True | TRUE | false | False | FALSE");
+ } else {
+ throw new Error("expected a Fail cause");
+ }
+ });
+
+ it("composes with Config.withDefault when input is unset", async () => {
+ delete process.env.INPUT_FLAG;
+ const result = await run(Config.withDefault(ActionInput.boolean("flag"), false));
+ expect(result).toBe(false);
+ });
+});
+
+describe("ActionInput.multiline", () => {
+ it("splits on newlines", async () => {
+ setEnv("INPUT_PATHS", "a\nb\nc");
+ expect(await run(ActionInput.multiline("paths"))).toEqual(["a", "b", "c"]);
+ });
+
+ it("drops empty lines", async () => {
+ setEnv("INPUT_PATHS", "a\n\nb\n");
+ expect(await run(ActionInput.multiline("paths"))).toEqual(["a", "b"]);
+ });
+
+ it("trims each line", async () => {
+ setEnv("INPUT_PATHS", " a \n b ");
+ expect(await run(ActionInput.multiline("paths"))).toEqual(["a", "b"]);
+ });
+
+ it("returns single-element array for single line", async () => {
+ setEnv("INPUT_PATHS", "solo");
+ expect(await run(ActionInput.multiline("paths"))).toEqual(["solo"]);
+ });
+
+ it("missing input is a ConfigError (not [])", async () => {
+ delete process.env.INPUT_PATHS;
+ const exit = await runExit(ActionInput.multiline("paths"));
+ expect(exit._tag).toBe("Failure");
+ if (exit._tag === "Failure" && exit.cause._tag === "Fail") {
+ expect(ConfigError.isMissingData(exit.cause.error)).toBe(true);
+ } else {
+ throw new Error("expected a Fail cause");
+ }
+ });
+
+ it("composes with Config.withDefault to produce []", async () => {
+ delete process.env.INPUT_PATHS;
+ const result = await run(Config.withDefault(ActionInput.multiline("paths"), [] as ReadonlyArray));
+ expect(result).toEqual([]);
+ });
+});
diff --git a/src/runtime/ActionInput.ts b/src/runtime/ActionInput.ts
new file mode 100644
index 0000000..21021bf
--- /dev/null
+++ b/src/runtime/ActionInput.ts
@@ -0,0 +1,81 @@
+import { Config, ConfigError, Either } from "effect";
+
+/**
+ * GitHub-faithful action input helpers expressed as Effect `Config` combinators.
+ *
+ * @remarks
+ * These read through the same {@link ActionsConfigProvider} as `Config.string`,
+ * so they compose with `Config.withDefault`, `Config.option`, etc. The key
+ * difference from Effect's built-in `Config.boolean` is the accepted truth set:
+ * GitHub Actions follows the YAML 1.2 "Core Schema" exactly, whereas
+ * `Config.boolean` also accepts JS-flavored values like `yes`/`on`/`1`.
+ *
+ * @example
+ * ```ts
+ * import { ActionInput } from "@savvy-web/github-action-effects"
+ *
+ * const dryRun = yield* ActionInput.boolean("dry-run")
+ * const paths = yield* ActionInput.multiline("paths")
+ * ```
+ *
+ * @public
+ */
+export const ActionInput = {
+ /**
+ * YAML 1.2 "Core Schema" boolean input, matching `@actions/core.getBooleanInput`.
+ *
+ * @remarks
+ * Accepts ONLY `true | True | TRUE` (→ `true`) and `false | False | FALSE`
+ * (→ `false`). Surrounding whitespace is trimmed before the comparison
+ * (matching the toolkit, which trims by default). Everything else — including
+ * Effect's `Config.boolean` extras `yes`/`on`/`1`/`no`/`off`/`0` and
+ * mixed-case variants like `tRue` — fails with `ConfigError.InvalidData`.
+ *
+ * Prefer this over `Config.boolean` for GitHub-faithful semantics:
+ * `Config.boolean("dry-run")` would silently accept `dry-run: yes` (which
+ * GitHub's own composite-action runtime rejects) and reject `dry-run: True`
+ * (which GitHub accepts).
+ *
+ * @param name - The input name (read as `INPUT_`).
+ */
+ boolean: (name: string): Config.Config =>
+ Config.string(name).pipe(
+ Config.mapOrFail((raw) => {
+ const val = raw.trim();
+ if (val === "true" || val === "True" || val === "TRUE") {
+ return Either.right(true);
+ }
+ if (val === "false" || val === "False" || val === "FALSE") {
+ return Either.right(false);
+ }
+ return Either.left(
+ ConfigError.InvalidData(
+ [name],
+ `Input does not meet YAML 1.2 "Core Schema" specification: ${name}\n` +
+ "Support boolean input list: `true | True | TRUE | false | False | FALSE`",
+ ),
+ );
+ }),
+ ),
+
+ /**
+ * Multiline input, matching `@actions/core.getMultilineInput`.
+ *
+ * @remarks
+ * Splits the raw value on `\n`, drops empty lines (after the split, before
+ * trimming), and trims each remaining line. A missing input is a
+ * `ConfigError.MissingData` (matching `Config.string`); combine with
+ * `Config.withDefault([])` for an empty-when-absent array.
+ *
+ * @param name - The input name (read as `INPUT_`).
+ */
+ multiline: (name: string): Config.Config> =>
+ Config.string(name).pipe(
+ Config.map((raw) =>
+ raw
+ .split("\n")
+ .filter((line) => line !== "")
+ .map((line) => line.trim()),
+ ),
+ ),
+} as const;
diff --git a/src/runtime/WorkflowCommand.test.ts b/src/runtime/WorkflowCommand.test.ts
index def1cdb..42a0b29 100644
--- a/src/runtime/WorkflowCommand.test.ts
+++ b/src/runtime/WorkflowCommand.test.ts
@@ -1,5 +1,15 @@
import { describe, expect, it, vi } from "vitest";
-import { escapeData, escapeProperty, format, issue } from "./WorkflowCommand.js";
+import {
+ annotationProperties,
+ escapeData,
+ escapeProperty,
+ format,
+ issue,
+ notice,
+ resumeCommands,
+ setCommandEcho,
+ stopCommands,
+} from "./WorkflowCommand.js";
describe("format", () => {
it("formats a command with no properties and a message", () => {
@@ -63,3 +73,82 @@ describe("issue", () => {
writeSpy.mockRestore();
});
});
+
+describe("annotationProperties", () => {
+ it("drops an empty object to {}", () => {
+ expect(annotationProperties({})).toEqual({});
+ });
+
+ it("maps startLine→line and startColumn→col", () => {
+ expect(annotationProperties({ startLine: 3, startColumn: 5 })).toEqual({ line: "3", col: "5" });
+ });
+
+ it("maps endLine/endColumn/title/file", () => {
+ expect(
+ annotationProperties({
+ title: "T",
+ file: "a.ts",
+ startLine: 1,
+ endLine: 2,
+ startColumn: 3,
+ endColumn: 4,
+ }),
+ ).toEqual({ title: "T", file: "a.ts", line: "1", endLine: "2", col: "3", endColumn: "4" });
+ });
+});
+
+describe("notice", () => {
+ it("issues ::notice:: with a message", () => {
+ const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
+ notice({}, "hi");
+ expect(writeSpy).toHaveBeenCalledWith("::notice::hi\n");
+ writeSpy.mockRestore();
+ });
+
+ it("maps startLine→line and startColumn→col", () => {
+ const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
+ notice({ file: "a.ts", startLine: 3, startColumn: 5 }, "x");
+ expect(writeSpy).toHaveBeenCalledWith("::notice file=a.ts,line=3,col=5::x\n");
+ writeSpy.mockRestore();
+ });
+
+ it("includes endLine/endColumn/title", () => {
+ const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
+ notice({ title: "T", endLine: 9, endColumn: 11 }, "x");
+ const written = String(writeSpy.mock.calls[0]?.[0]);
+ expect(written).toContain("title=T");
+ expect(written).toContain("endLine=9");
+ expect(written).toContain("endColumn=11");
+ writeSpy.mockRestore();
+ });
+});
+
+describe("stopCommands / resumeCommands / setCommandEcho", () => {
+ it("stopCommands emits ::stop-commands::{token}", () => {
+ const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
+ stopCommands("XYZ");
+ expect(writeSpy).toHaveBeenCalledWith("::stop-commands::XYZ\n");
+ writeSpy.mockRestore();
+ });
+
+ it("resumeCommands emits ::{token}::", () => {
+ const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
+ resumeCommands("XYZ");
+ expect(writeSpy).toHaveBeenCalledWith("::XYZ::\n");
+ writeSpy.mockRestore();
+ });
+
+ it("setCommandEcho(true) emits ::echo::on", () => {
+ const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
+ setCommandEcho(true);
+ expect(writeSpy).toHaveBeenCalledWith("::echo::on\n");
+ writeSpy.mockRestore();
+ });
+
+ it("setCommandEcho(false) emits ::echo::off", () => {
+ const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
+ setCommandEcho(false);
+ expect(writeSpy).toHaveBeenCalledWith("::echo::off\n");
+ writeSpy.mockRestore();
+ });
+});
diff --git a/src/runtime/WorkflowCommand.ts b/src/runtime/WorkflowCommand.ts
index fa502ef..b31a18a 100644
--- a/src/runtime/WorkflowCommand.ts
+++ b/src/runtime/WorkflowCommand.ts
@@ -47,3 +47,79 @@ export function format(command: string, properties: Record, mess
export function issue(command: string, properties: Record, message: string): void {
process.stdout.write(`${format(command, properties, message)}\n`);
}
+
+/**
+ * GitHub annotation properties shared by `::notice::`, `::warning::`, and
+ * `::error::` commands. Matches `@actions/core` `AnnotationProperties`.
+ *
+ * @public
+ */
+export interface AnnotationProperties {
+ /** A title for the annotation. */
+ readonly title?: string;
+ /** The path of the file the annotation should be attached to. */
+ readonly file?: string;
+ /** The start line for the annotation. */
+ readonly startLine?: number;
+ /** The end line for the annotation. */
+ readonly endLine?: number;
+ /** The start column for the annotation. Cannot span multiple lines. */
+ readonly startColumn?: number;
+ /** The end column for the annotation. Cannot span multiple lines. */
+ readonly endColumn?: number;
+}
+
+/**
+ * Maps {@link AnnotationProperties} to workflow-command properties, matching
+ * `@actions/core` `toCommandProperties`: `startLine`→`line`,
+ * `startColumn`→`col`, `endLine`→`endLine`, `endColumn`→`endColumn`. Absent
+ * fields are omitted so an empty input yields an empty object.
+ */
+export function annotationProperties(properties: AnnotationProperties): Record {
+ const result: Record = {};
+ if (properties.title !== undefined) result.title = properties.title;
+ if (properties.file !== undefined) result.file = properties.file;
+ if (properties.startLine !== undefined) result.line = String(properties.startLine);
+ if (properties.endLine !== undefined) result.endLine = String(properties.endLine);
+ if (properties.startColumn !== undefined) result.col = String(properties.startColumn);
+ if (properties.endColumn !== undefined) result.endColumn = String(properties.endColumn);
+ return result;
+}
+
+/**
+ * Issues a `::notice::` annotation. Matches `@actions/core.notice`.
+ *
+ * @param properties - Annotation properties (title/file/line/col, etc.)
+ * @param message - The notice message
+ */
+export function notice(properties: AnnotationProperties, message: string): void {
+ issue("notice", annotationProperties(properties), message);
+}
+
+/**
+ * Suspends processing of workflow commands until {@link resumeCommands} is
+ * called with the same token. Emits `::stop-commands::{token}`.
+ *
+ * @param token - A randomly generated, run-unique token.
+ */
+export function stopCommands(token: string): void {
+ issue("stop-commands", {}, token);
+}
+
+/**
+ * Resumes processing of workflow commands suspended by {@link stopCommands}.
+ * Emits `::{token}::`.
+ *
+ * @param token - The token previously passed to {@link stopCommands}.
+ */
+export function resumeCommands(token: string): void {
+ issue(token, {}, "");
+}
+
+/**
+ * Enables or disables echoing of workflow commands. Emits `::echo::on` or
+ * `::echo::off`. Matches `@actions/core.setCommandEcho`.
+ */
+export function setCommandEcho(enabled: boolean): void {
+ issue("echo", {}, enabled ? "on" : "off");
+}
diff --git a/src/schemas/EventPayload.test.ts b/src/schemas/EventPayload.test.ts
new file mode 100644
index 0000000..6bca976
--- /dev/null
+++ b/src/schemas/EventPayload.test.ts
@@ -0,0 +1,56 @@
+import { Schema } from "effect";
+import { describe, expect, it } from "vitest";
+import { WebhookPayload } from "./EventPayload.js";
+
+const decode = (input: unknown) => Schema.decodeUnknownSync(WebhookPayload)(input, { onExcessProperty: "preserve" });
+
+describe("WebhookPayload", () => {
+ it("decodes a pull_request payload", () => {
+ const result = decode({
+ pull_request: { number: 7, html_url: "https://x/pull/7" },
+ repository: { name: "repo", owner: { login: "owner" } },
+ });
+ expect(result.pull_request?.number).toBe(7);
+ expect(result.repository?.owner.login).toBe("owner");
+ });
+
+ it("decodes an issues payload", () => {
+ const result = decode({ issue: { number: 12 } });
+ expect(result.issue?.number).toBe(12);
+ });
+
+ it("decodes push refs", () => {
+ const result = decode({ ref: "refs/heads/main", before: "aaa", after: "bbb" });
+ expect(result.ref).toBe("refs/heads/main");
+ expect(result.before).toBe("aaa");
+ expect(result.after).toBe("bbb");
+ });
+
+ it("decodes a top-level number", () => {
+ const result = decode({ number: 99 });
+ expect(result.number).toBe(99);
+ });
+
+ it("preserves unknown keys", () => {
+ const result = decode({ deployment: { id: 42 } }) as Record;
+ expect(result.deployment).toEqual({ id: 42 });
+ });
+
+ it("tolerates an empty payload", () => {
+ const result = decode({});
+ expect(result.issue).toBeUndefined();
+ expect(result.pull_request).toBeUndefined();
+ expect(result.repository).toBeUndefined();
+ });
+
+ it("decodes sender, action, comment", () => {
+ const result = decode({ sender: { type: "User" }, action: "opened", comment: { id: 5 } });
+ expect(result.sender?.type).toBe("User");
+ expect(result.action).toBe("opened");
+ expect(result.comment?.id).toBe(5);
+ });
+
+ it("fails on a non-object", () => {
+ expect(() => decode("not an object")).toThrow();
+ });
+});
diff --git a/src/schemas/EventPayload.ts b/src/schemas/EventPayload.ts
new file mode 100644
index 0000000..eb173bc
--- /dev/null
+++ b/src/schemas/EventPayload.ts
@@ -0,0 +1,56 @@
+import { Schema } from "effect";
+
+/**
+ * The `repository` object on a webhook payload (typed subset).
+ *
+ * @internal
+ */
+const Repository = Schema.Struct({
+ name: Schema.String,
+ full_name: Schema.optional(Schema.String),
+ owner: Schema.Struct({ login: Schema.String }),
+ html_url: Schema.optional(Schema.String),
+});
+
+/**
+ * An issue / pull-request reference on a webhook payload (typed subset).
+ *
+ * @internal
+ */
+const IssueRef = Schema.Struct({
+ number: Schema.Number,
+ html_url: Schema.optional(Schema.String),
+ body: Schema.optional(Schema.String),
+});
+
+/**
+ * Common GitHub webhook event payload fields.
+ *
+ * @remarks
+ * Mirrors the typed subset of `@actions/github` `WebhookPayload`. Every field
+ * is optional, and the struct is intended to be decoded with
+ * `{ onExcessProperty: "preserve" }` so unknown keys (the bulk of any real
+ * webhook body) survive — matching the toolkit's `[key: string]: any` index
+ * signature. Decoding a non-object value fails.
+ *
+ * @public
+ */
+export const WebhookPayload = Schema.Struct({
+ repository: Schema.optional(Repository),
+ issue: Schema.optional(IssueRef),
+ pull_request: Schema.optional(IssueRef),
+ sender: Schema.optional(Schema.Struct({ type: Schema.String })),
+ action: Schema.optional(Schema.String),
+ comment: Schema.optional(Schema.Struct({ id: Schema.Number })),
+ installation: Schema.optional(Schema.Struct({ id: Schema.Number })),
+ /** Top-level number (some events carry the issue/PR number at the root). */
+ number: Schema.optional(Schema.Number),
+ // Push event fields (not in @actions/github's WebhookPayload interface, but
+ // present on the open payload for push events).
+ ref: Schema.optional(Schema.String),
+ before: Schema.optional(Schema.String),
+ after: Schema.optional(Schema.String),
+}).annotations({ identifier: "WebhookPayload" });
+
+/** Inferred type for {@link WebhookPayload}. */
+export type WebhookPayload = typeof WebhookPayload.Type;
diff --git a/src/services/ActionEnvironment.ts b/src/services/ActionEnvironment.ts
index 17a29eb..2e186d8 100644
--- a/src/services/ActionEnvironment.ts
+++ b/src/services/ActionEnvironment.ts
@@ -1,7 +1,9 @@
+import type { FileSystem } from "@effect/platform";
import type { Effect, Option } from "effect";
import { Context } from "effect";
import type { ActionEnvironmentError } from "../errors/ActionEnvironmentError.js";
import type { GitHubContext, RunnerContext } from "../schemas/Environment.js";
+import type { WebhookPayload } from "../schemas/EventPayload.js";
/**
* Service for reading GitHub Actions environment variables.
@@ -22,5 +24,39 @@ export class ActionEnvironment extends Context.Tag("github-action-effects/Action
/** Get the runner context (RUNNER_* variables). Lazily validated. */
readonly runner: Effect.Effect;
+
+ /**
+ * True when `RUNNER_DEBUG === "1"`. Mirrors `@actions/core.isDebug()`.
+ */
+ readonly isDebug: Effect.Effect;
+
+ /**
+ * Parsed `GITHUB_EVENT_PATH` payload, schema-decoded into a
+ * {@link WebhookPayload} (common fields typed, unknown keys preserved).
+ *
+ * @remarks
+ * Succeeds with an empty payload when `GITHUB_EVENT_PATH` is unset or the
+ * file is missing (matching `@actions/github`, which warns and uses `{}`).
+ * Fails {@link ActionEnvironmentError} only on malformed JSON.
+ */
+ readonly payload: Effect.Effect;
+
+ /**
+ * `{ owner, repo }` from `GITHUB_REPOSITORY`, else from
+ * `payload.repository`. Mirrors `@actions/github` `context.repo`. Fails
+ * {@link ActionEnvironmentError} when neither source is available.
+ */
+ readonly repo: Effect.Effect<{ owner: string; repo: string }, ActionEnvironmentError, FileSystem.FileSystem>;
+
+ /**
+ * `{ owner, repo, number }` where `number` is resolved from
+ * `issue ?? pull_request ?? payload` (top-level number). Mirrors
+ * `@actions/github` `context.issue`.
+ */
+ readonly issue: Effect.Effect<
+ { owner: string; repo: string; number: number },
+ ActionEnvironmentError,
+ FileSystem.FileSystem
+ >;
}
>() {}
diff --git a/src/services/ActionLogger.ts b/src/services/ActionLogger.ts
index 201b746..c7bc414 100644
--- a/src/services/ActionLogger.ts
+++ b/src/services/ActionLogger.ts
@@ -1,5 +1,6 @@
import type { Effect } from "effect";
import { Context } from "effect";
+import type { AnnotationProperties } from "../runtime/WorkflowCommand.js";
/**
* Service for action-specific logging operations beyond the Effect Logger.
@@ -25,5 +26,15 @@ export class ActionLogger extends Context.Tag("github-action-effects/ActionLogge
* the buffer is flushed before the error is reported.
*/
readonly withBuffer: (label: string, effect: Effect.Effect) => Effect.Effect;
+
+ /**
+ * Emit a `::notice::` annotation. Mirrors `@actions/core.notice`.
+ *
+ * @remarks
+ * Distinct from `Effect.logInfo`, which writes plain stdout. No standard
+ * log level is remapped to `::notice::` (Effect has no level between
+ * `Info` and `Warning`), so this is the dedicated path for notices.
+ */
+ readonly notice: (message: string, properties?: AnnotationProperties) => Effect.Effect;
}
>() {}
diff --git a/src/services/OidcTokenIssuer.test.ts b/src/services/OidcTokenIssuer.test.ts
index 95f1477..816d33b 100644
--- a/src/services/OidcTokenIssuer.test.ts
+++ b/src/services/OidcTokenIssuer.test.ts
@@ -107,6 +107,56 @@ describe("OidcTokenIssuer — step 2: token issuance", () => {
expect(capturedAuth).toBe("Bearer runner-bearer-token");
});
+ it("omits the audience query param when not provided", async () => {
+ let capturedUrl = "";
+ const http = mockHttpClient((url) => {
+ capturedUrl = url;
+ return { status: 200, body: JSON.stringify({ value: "header.payload.signature" }) };
+ });
+
+ const program = Effect.gen(function* () {
+ const issuer = yield* OidcTokenIssuer;
+ return yield* issuer.getToken();
+ });
+
+ const token = await Effect.runPromise(
+ withEnv(
+ {
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: "runner-bearer-token",
+ ACTIONS_ID_TOKEN_REQUEST_URL: "https://token.actions.githubusercontent.com/foo?api-version=2.0",
+ },
+ program.pipe(Effect.provide(Layer.provide(OidcTokenIssuerLive, http))),
+ ),
+ );
+
+ expect(Redacted.value(token)).toBe("header.payload.signature");
+ expect(capturedUrl).toBe("https://token.actions.githubusercontent.com/foo?api-version=2.0");
+ expect(capturedUrl).not.toContain("audience");
+ });
+
+ it("url-encodes the audience", async () => {
+ let capturedUrl = "";
+ const http = mockHttpClient((url) => {
+ capturedUrl = url;
+ return { status: 200, body: JSON.stringify({ value: "x" }) };
+ });
+
+ await Effect.runPromise(
+ withEnv(
+ {
+ ACTIONS_ID_TOKEN_REQUEST_TOKEN: "tok",
+ ACTIONS_ID_TOKEN_REQUEST_URL: "https://token.example/",
+ },
+ Effect.gen(function* () {
+ const issuer = yield* OidcTokenIssuer;
+ return yield* issuer.getToken("a b/c");
+ }).pipe(Effect.provide(Layer.provide(OidcTokenIssuerLive, http))),
+ ),
+ );
+
+ expect(capturedUrl).toContain("audience=a%20b%2Fc");
+ });
+
it("fails with `reason: env` when ACTIONS_ID_TOKEN_REQUEST_TOKEN is missing", async () => {
const http = mockHttpClient(() => ({ status: 200, body: JSON.stringify({ value: "x" }) }));
diff --git a/src/services/OidcTokenIssuer.ts b/src/services/OidcTokenIssuer.ts
index a96bc1d..6faf73a 100644
--- a/src/services/OidcTokenIssuer.ts
+++ b/src/services/OidcTokenIssuer.ts
@@ -18,9 +18,12 @@ export class OidcTokenIssuer extends Context.Tag("github-action-effects/OidcToke
/**
* Request an OIDC ID token from the GitHub Actions token service.
*
- * @param audience - The `aud` claim to encode in the JWT
- * (e.g. `"sigstore"` for Fulcio cert issuance).
+ * @param audience - Optional `aud` claim to encode in the JWT
+ * (e.g. `"sigstore"` for Fulcio cert issuance). When omitted, no
+ * `audience` query param is sent — matching
+ * `@actions/core.getIDToken(audience?)`, which cloud-provider OIDC
+ * federation (AWS/GCP/Azure) routinely relies on.
*/
- readonly getToken: (audience: string) => Effect.Effect, OidcTokenError>;
+ readonly getToken: (audience?: string) => Effect.Effect, OidcTokenError>;
}
>() {}
diff --git a/src/testing.ts b/src/testing.ts
index 948a064..1f2e737 100644
--- a/src/testing.ts
+++ b/src/testing.ts
@@ -160,9 +160,11 @@ export { WorkspaceDetectorLive } from "./layers/WorkspaceDetectorLive.js";
export type { WorkspaceDetectorTestState } from "./layers/WorkspaceDetectorTest.js";
export { WorkspaceDetectorTest } from "./layers/WorkspaceDetectorTest.js";
// -- Runtime --
+export { ActionInput } from "./runtime/ActionInput.js";
export { ActionsConfigProvider } from "./runtime/ActionsConfigProvider.js";
export { ActionsLogger } from "./runtime/ActionsLogger.js";
export { ActionsRuntime } from "./runtime/ActionsRuntime.js";
+export type { AnnotationProperties } from "./runtime/WorkflowCommand.js";
export type { AttestInput, AttestationRecord } from "./schemas/Attestation.js";
export {
CYCLONEDX_BOM,
@@ -183,6 +185,8 @@ export type {
export { BumpType, Changeset, ChangesetFile } from "./schemas/Changeset.js";
export type { GitHubContext as GitHubContextType, RunnerContext as RunnerContextType } from "./schemas/Environment.js";
export { GitHubContext, RunnerContext } from "./schemas/Environment.js";
+export type { WebhookPayload as WebhookPayloadType } from "./schemas/EventPayload.js";
+export { WebhookPayload } from "./schemas/EventPayload.js";
export { CapturedOutput, ChecklistItem, Status } from "./schemas/GithubMarkdown.js";
export type { FileChange as FileChangeType, TreeEntry as TreeEntryType } from "./schemas/GitTree.js";
export {
@@ -305,6 +309,7 @@ export { ErrorAccumulator } from "./utils/ErrorAccumulator.js";
export { GithubMarkdown } from "./utils/GithubMarkdown.js";
export { IoUtil } from "./utils/IoUtil.js";
export { buildStatement, npmPurl, serializeStatement, subject } from "./utils/intoto.js";
+export { PathUtils } from "./utils/PathUtils.js";
export type { Report } from "./utils/ReportBuilder.js";
export { ReportBuilder } from "./utils/ReportBuilder.js";
export { SemverResolver } from "./utils/SemverResolver.js";
diff --git a/src/utils/GithubMarkdown.test.ts b/src/utils/GithubMarkdown.test.ts
index 3e979c5..a793a91 100644
--- a/src/utils/GithubMarkdown.test.ts
+++ b/src/utils/GithubMarkdown.test.ts
@@ -97,4 +97,36 @@ describe("GithubMarkdown", () => {
expect(GithubMarkdown.codeBlock("hello")).toBe("```\nhello\n```");
});
});
+
+ describe("image", () => {
+ it("emits an img tag with src and alt", () => {
+ expect(GithubMarkdown.image("u", "a")).toBe('
');
+ });
+
+ it("includes width and height when given", () => {
+ expect(GithubMarkdown.image("u", "a", { width: "100", height: "50" })).toBe(
+ '
',
+ );
+ });
+
+ it("omits width/height when absent", () => {
+ const result = GithubMarkdown.image("u", "a");
+ expect(result).not.toContain("width=");
+ expect(result).not.toContain("height=");
+ });
+
+ it("includes width only", () => {
+ expect(GithubMarkdown.image("u", "a", { width: "100" })).toBe('
');
+ });
+ });
+
+ describe("quote", () => {
+ it("emits a blockquote", () => {
+ expect(GithubMarkdown.quote("hi")).toBe("hi
");
+ });
+
+ it("includes cite when given", () => {
+ expect(GithubMarkdown.quote("hi", "http://x")).toBe('hi
');
+ });
+ });
});
diff --git a/src/utils/GithubMarkdown.ts b/src/utils/GithubMarkdown.ts
index 1cb31c1..ce09937 100644
--- a/src/utils/GithubMarkdown.ts
+++ b/src/utils/GithubMarkdown.ts
@@ -88,4 +88,34 @@ export const GithubMarkdown = {
* Fenced code block.
*/
codeBlock: (content: string, language = ""): string => `\`\`\`${language}\n${content}\n\`\`\``,
+
+ /**
+ * Build an `
` tag, matching `@actions/core` summary `addImage`.
+ *
+ * @remarks Attribute values are interpolated raw, not HTML-escaped — exactly
+ * as `@actions/core` does (its `summary.wrap` performs no escaping). GitHub
+ * sanitizes step-summary HTML server-side; callers embedding the output
+ * elsewhere with untrusted input should escape it themselves.
+ *
+ * @param src - The image source URL.
+ * @param alt - Alt text for the image.
+ * @param options - Optional `width` / `height` attributes.
+ */
+ image: (src: string, alt: string, options?: { readonly width?: string; readonly height?: string }): string => {
+ const width = options?.width !== undefined ? ` width="${options.width}"` : "";
+ const height = options?.height !== undefined ? ` height="${options.height}"` : "";
+ return `
`;
+ },
+
+ /**
+ * Build a ``, matching `@actions/core` summary `addQuote`.
+ *
+ * @remarks `text` and `cite` are interpolated raw, not HTML-escaped — exactly
+ * as `@actions/core` does. See {@link GithubMarkdown.image} for the rationale.
+ *
+ * @param text - The quoted text.
+ * @param cite - Optional `cite` attribute (a source URL).
+ */
+ quote: (text: string, cite?: string): string =>
+ `${text}
`,
} as const;
diff --git a/src/utils/PathUtils.test.ts b/src/utils/PathUtils.test.ts
new file mode 100644
index 0000000..2dae44a
--- /dev/null
+++ b/src/utils/PathUtils.test.ts
@@ -0,0 +1,31 @@
+import { sep } from "node:path";
+import { describe, expect, it } from "vitest";
+import { PathUtils } from "./PathUtils.js";
+
+describe("PathUtils", () => {
+ describe("toPosixPath", () => {
+ it("converts backslashes to slashes", () => {
+ expect(PathUtils.toPosixPath("a\\b\\c")).toBe("a/b/c");
+ });
+
+ it("leaves forward slashes unchanged", () => {
+ expect(PathUtils.toPosixPath("a/b/c")).toBe("a/b/c");
+ });
+ });
+
+ describe("toWin32Path", () => {
+ it("converts slashes to backslashes", () => {
+ expect(PathUtils.toWin32Path("a/b/c")).toBe("a\\b\\c");
+ });
+
+ it("leaves backslashes unchanged", () => {
+ expect(PathUtils.toWin32Path("a\\b\\c")).toBe("a\\b\\c");
+ });
+ });
+
+ describe("toPlatformPath", () => {
+ it("normalizes both separators to the platform separator", () => {
+ expect(PathUtils.toPlatformPath("a/b\\c")).toBe(["a", "b", "c"].join(sep));
+ });
+ });
+});
diff --git a/src/utils/PathUtils.ts b/src/utils/PathUtils.ts
new file mode 100644
index 0000000..3361368
--- /dev/null
+++ b/src/utils/PathUtils.ts
@@ -0,0 +1,24 @@
+import { sep } from "node:path";
+
+/**
+ * Pure path-normalization helpers, matching `@actions/core` path utilities.
+ *
+ * @example
+ * ```ts
+ * import { PathUtils } from "@savvy-web/github-action-effects"
+ *
+ * PathUtils.toPosixPath("a\\b") // "a/b"
+ * ```
+ *
+ * @public
+ */
+export const PathUtils = {
+ /** Normalize backslashes to forward slashes. Matches `@actions/core.toPosixPath`. */
+ toPosixPath: (pth: string): string => pth.replace(/\\/g, "/"),
+
+ /** Normalize forward slashes to backslashes. Matches `@actions/core.toWin32Path`. */
+ toWin32Path: (pth: string): string => pth.replace(/\//g, "\\"),
+
+ /** Normalize both separators to the platform separator. Matches `@actions/core.toPlatformPath`. */
+ toPlatformPath: (pth: string): string => pth.replace(/[/\\]/g, sep),
+} as const;