Skip to content
This repository was archived by the owner on May 30, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/ws5-toolkit-parity-context.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
148 changes: 147 additions & 1 deletion src/layers/ActionEnvironmentLive.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -9,6 +14,15 @@ const run = <A, E>(effect: Effect.Effect<A, E, ActionEnvironment>) =>
const runExit = <A, E>(effect: Effect.Effect<A, E, ActionEnvironment>) =>
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 = <A, E>(effect: Effect.Effect<A, E, ActionEnvironment | FileSystem.FileSystem>) =>
Effect.runPromise(Effect.provide(effect, fsLayer));

const runFsExit = <A, E>(effect: Effect.Effect<A, E, ActionEnvironment | FileSystem.FileSystem>) =>
Effect.runPromise(Effect.exit(Effect.provide(effect, fsLayer)));

const savedEnv: Record<string, string | undefined> = {};

const setEnv = (vars: Record<string, string>) => {
Expand Down Expand Up @@ -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<string, string | undefined> = {};

const overrideEnv = (vars: Record<string, string | undefined>) => {
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");
});
});
});
116 changes: 115 additions & 1 deletion src/layers/ActionEnvironmentLive.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -38,9 +40,121 @@ const buildRunnerContext: Effect.Effect<RunnerContext, ActionEnvironmentError> =
debug: Effect.sync(() => process.env.RUNNER_DEBUG === "1"),
});

const EMPTY_PAYLOAD: WebhookPayload = {};

const decodePayload = (raw: string): Effect.Effect<WebhookPayload, ActionEnvironmentError> =>
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<WebhookPayload, ActionEnvironmentError, FileSystem.FileSystem> = 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;
Comment thread
savvy-web-bot[bot] marked this conversation as resolved.
Resolved
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<ActionEnvironment> = 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,
});
Loading
Loading