From 5afde0a730264e2ce4790cf46c235457f688db43 Mon Sep 17 00:00:00 2001 From: "C. Spencer Beggs" Date: Thu, 21 May 2026 03:54:18 -0400 Subject: [PATCH 1/2] feat: toolkit parity II for context, inputs and core conveniences Add GitHub-faithful input combinators ActionInput.boolean (YAML 1.2 Core Schema, unlike Config.boolean which accepts yes/on/1) and ActionInput.multiline. Parse GITHUB_EVENT_PATH into a schema-validated WebhookPayload exposed as ActionEnvironment.payload, with repo, issue and isDebug accessors mirroring @actions/github and @actions/core. Add WorkflowCommand.notice and ActionLogger.notice for notice annotations, plus stopCommands, resumeCommands and setCommandEcho. Add GithubMarkdown.image and quote, and a PathUtils namespace. Make OidcTokenIssuer.getToken audience optional for cloud-provider federation. Signed-off-by: C. Spencer Beggs --- .changeset/ws5-toolkit-parity-context.md | 26 ++++ src/index.ts | 5 + src/layers/ActionEnvironmentLive.test.ts | 148 ++++++++++++++++++++++- src/layers/ActionEnvironmentLive.ts | 118 +++++++++++++++++- src/layers/ActionEnvironmentTest.test.ts | 109 +++++++++++++++++ src/layers/ActionEnvironmentTest.ts | 64 +++++++++- src/layers/ActionLoggerLive.test.ts | 30 +++++ src/layers/ActionLoggerLive.ts | 2 + src/layers/ActionLoggerTest.test.ts | 22 ++++ src/layers/ActionLoggerTest.ts | 11 ++ src/layers/OidcTokenIssuerLive.ts | 20 +-- src/layers/OidcTokenIssuerTest.ts | 2 +- src/runtime/ActionInput.test.ts | 143 ++++++++++++++++++++++ src/runtime/ActionInput.ts | 81 +++++++++++++ src/runtime/WorkflowCommand.test.ts | 91 +++++++++++++- src/runtime/WorkflowCommand.ts | 76 ++++++++++++ src/schemas/EventPayload.test.ts | 56 +++++++++ src/schemas/EventPayload.ts | 56 +++++++++ src/services/ActionEnvironment.ts | 36 ++++++ src/services/ActionLogger.ts | 11 ++ src/services/OidcTokenIssuer.test.ts | 50 ++++++++ src/services/OidcTokenIssuer.ts | 9 +- src/testing.ts | 5 + src/utils/GithubMarkdown.test.ts | 32 +++++ src/utils/GithubMarkdown.ts | 22 ++++ src/utils/PathUtils.test.ts | 31 +++++ src/utils/PathUtils.ts | 24 ++++ 27 files changed, 1263 insertions(+), 17 deletions(-) create mode 100644 .changeset/ws5-toolkit-parity-context.md create mode 100644 src/layers/ActionEnvironmentTest.test.ts create mode 100644 src/runtime/ActionInput.test.ts create mode 100644 src/runtime/ActionInput.ts create mode 100644 src/schemas/EventPayload.test.ts create mode 100644 src/schemas/EventPayload.ts create mode 100644 src/utils/PathUtils.test.ts create mode 100644 src/utils/PathUtils.ts 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 ec5fd08..df81110 100644 --- a/src/index.ts +++ b/src/index.ts @@ -155,10 +155,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, @@ -179,6 +181,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 { @@ -291,6 +295,7 @@ export type { AccumulateResult } from "./utils/ErrorAccumulator.js"; export { ErrorAccumulator } from "./utils/ErrorAccumulator.js"; export { GithubMarkdown } from "./utils/GithubMarkdown.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..01bc4d9 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,123 @@ 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 buildPayload: Effect.Effect = readPayload; + +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: buildPayload, + 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 3590525..a149cc1 100644 --- a/src/testing.ts +++ b/src/testing.ts @@ -149,9 +149,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, @@ -172,6 +174,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 { @@ -283,6 +287,7 @@ export type { AccumulateResult } from "./utils/ErrorAccumulator.js"; export { ErrorAccumulator } from "./utils/ErrorAccumulator.js"; export { GithubMarkdown } from "./utils/GithubMarkdown.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('a'); + }); + + it("includes width and height when given", () => { + expect(GithubMarkdown.image("u", "a", { width: "100", height: "50" })).toBe( + 'a', + ); + }); + + 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('a'); + }); + }); + + 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..2262f98 100644 --- a/src/utils/GithubMarkdown.ts +++ b/src/utils/GithubMarkdown.ts @@ -88,4 +88,26 @@ export const GithubMarkdown = { * Fenced code block. */ codeBlock: (content: string, language = ""): string => `\`\`\`${language}\n${content}\n\`\`\``, + + /** + * Build an `` tag, matching `@actions/core` summary `addImage`. + * + * @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 `${alt}`; + }, + + /** + * Build a `
`, matching `@actions/core` summary `addQuote`. + * + * @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; From 687774e12790382c1cd9a521659f8e767598e640 Mon Sep 17 00:00:00 2001 From: "C. Spencer Beggs" Date: Thu, 21 May 2026 17:25:46 -0400 Subject: [PATCH 2/2] fix: address PR review feedback - document that GithubMarkdown.image/quote interpolate values raw (not HTML-escaped), matching @actions/core summary which performs no escaping - inline the no-op buildPayload alias in ActionEnvironmentLive Signed-off-by: C. Spencer Beggs --- src/layers/ActionEnvironmentLive.ts | 4 +--- src/utils/GithubMarkdown.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/layers/ActionEnvironmentLive.ts b/src/layers/ActionEnvironmentLive.ts index 01bc4d9..0a10644 100644 --- a/src/layers/ActionEnvironmentLive.ts +++ b/src/layers/ActionEnvironmentLive.ts @@ -122,8 +122,6 @@ const repoFromEnvOrPayload = ( const issueNumber = (payload: WebhookPayload): number | undefined => payload.issue?.number ?? payload.pull_request?.number ?? payload.number; -const buildPayload: Effect.Effect = readPayload; - const buildRepo: Effect.Effect<{ owner: string; repo: string }, ActionEnvironmentError, FileSystem.FileSystem> = readPayload.pipe(Effect.flatMap(repoFromEnvOrPayload)); @@ -156,7 +154,7 @@ export const ActionEnvironmentLive: Layer.Layer = Layer.succe github: buildGitHubContext, runner: buildRunnerContext, isDebug: Effect.sync(() => process.env.RUNNER_DEBUG === "1"), - payload: buildPayload, + payload: readPayload, repo: buildRepo, issue: buildIssue, }); diff --git a/src/utils/GithubMarkdown.ts b/src/utils/GithubMarkdown.ts index 2262f98..ce09937 100644 --- a/src/utils/GithubMarkdown.ts +++ b/src/utils/GithubMarkdown.ts @@ -92,6 +92,11 @@ export const GithubMarkdown = { /** * 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. @@ -105,6 +110,9 @@ export const GithubMarkdown = { /** * 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). */