@savvy-web/github-action-effects 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +20 -42
  2. package/index.d.ts +256 -73
  3. package/index.js +255 -102
  4. package/package.json +3 -3
package/README.md CHANGED
@@ -2,71 +2,49 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@savvy-web/github-action-effects)](https://www.npmjs.com/package/@savvy-web/github-action-effects)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue)](https://www.typescriptlang.org/)
5
6
 
6
- Build GitHub Actions with [Effect](https://effect.website) -- schema-validated
7
- inputs, structured logging with buffered output, and type-safe outputs through
8
- composable service layers.
7
+ Composable [Effect](https://effect.website) services for building Node.js 24 GitHub Actions with schema-validated inputs, structured logging, typed outputs and multi-phase state management without the boilerplate.
9
8
 
10
9
  ## Features
11
10
 
12
- - **Schema-validated inputs and outputs** using Effect Schema
13
- - **Three-tier action logger** (info/verbose/debug) with automatic log buffering
14
- - **GitHub Flavored Markdown builders** for step summaries
15
- - **Test layers for every service** -- no mocking `@actions/core` required
16
- - **Full TypeScript** with strict mode and ESM
11
+ - **Schema-validated inputs** — read, parse, and validate action inputs with Effect Schema
12
+ - **Structured logging** — three-tier logger (info/verbose/debug) with buffer-on-failure
13
+ - **Typed outputs** set outputs, export variables, and write GFM job summaries
14
+ - **Multi-phase state** transfer schema-serialized state across pre/main/post phases
15
+ - **In-memory test layers** test every service without mocking `@actions/core`
17
16
 
18
17
  ## Installation
19
18
 
20
19
  ```bash
21
- npm install @savvy-web/github-action-effects effect @actions/core
20
+ npm install @savvy-web/github-action-effects effect @actions/core @effect/platform @effect/platform-node
22
21
  ```
23
22
 
24
23
  ## Quick Start
25
24
 
26
25
  ```typescript
27
- import { Effect, Layer, Schema } from "effect";
28
- import {
29
- ActionInputs,
30
- ActionInputsLive,
31
- ActionOutputs,
32
- ActionOutputsLive,
33
- ActionLoggerLive,
34
- ActionLoggerLayer,
35
- LogLevelInput,
36
- resolveLogLevel,
37
- setLogLevel,
38
- table,
39
- } from "@savvy-web/github-action-effects";
26
+ import { Effect, Schema } from "effect";
27
+ import { Action, ActionInputs, ActionOutputs } from "@savvy-web/github-action-effects";
40
28
 
41
29
  const program = Effect.gen(function* () {
42
30
  const inputs = yield* ActionInputs;
43
31
  const outputs = yield* ActionOutputs;
44
-
45
- const level = yield* inputs.get("log-level", LogLevelInput);
46
- yield* setLogLevel(resolveLogLevel(level));
47
-
48
- const name = yield* inputs.get("name", Schema.String);
49
- yield* outputs.set("greeting", `Hello, ${name}!`);
50
- yield* outputs.summary(table(["Input", "Value"], [["name", name]]));
32
+ const name = yield* inputs.get("package-name", Schema.String);
33
+ yield* outputs.set("result", `checked ${name}`);
51
34
  });
52
35
 
53
- const MainLive = Layer.mergeAll(
54
- ActionInputsLive,
55
- ActionOutputsLive,
56
- ActionLoggerLive,
57
- );
58
-
59
- program.pipe(
60
- Effect.provide(ActionLoggerLayer),
61
- Effect.provide(MainLive),
62
- Effect.runPromise,
63
- );
36
+ Action.run(program);
64
37
  ```
65
38
 
39
+ `Action.run` provides all core service layers (including `NodeContext.layer` for `FileSystem`, `Path`, `Terminal`, `CommandExecutor`, and `WorkerManager` from `@effect/platform`), installs the Effect logger, and catches errors with `core.setFailed` automatically.
40
+
41
+ See the [full walkthrough](./docs/example-action.md) for log level configuration, batch input reading, GFM summaries, multi-phase state, and error handling.
42
+
66
43
  ## Documentation
67
44
 
68
- For architecture, API reference, testing guides, and advanced usage, see
69
- [docs](./docs/).
45
+ - [Example Action](./docs/example-action.md) end-to-end tutorial
46
+ - [Architecture](./docs/architecture.md) — API reference and layer composition
47
+ - [Testing](./docs/testing.md) — testing with in-memory layers
70
48
 
71
49
  ## License
72
50
 
package/index.d.ts CHANGED
@@ -13,11 +13,69 @@ import { Effect } from 'effect';
13
13
  import { Equals } from 'effect/Types';
14
14
  import { FiberRef } from 'effect';
15
15
  import { Layer } from 'effect';
16
- import { Logger } from 'effect';
16
+ import { Logger } from 'effect/Logger';
17
17
  import type { Option } from 'effect';
18
+ import * as PlatformNode from '@effect/platform-node';
18
19
  import { Schema } from 'effect';
19
20
  import { YieldableError } from 'effect/Cause';
20
21
 
22
+ /**
23
+ * Namespace for top-level GitHub Action helpers.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * import { Effect } from "effect"
28
+ * import { Action, ActionInputs, ActionLogger } from "@savvy-web/github-action-effects"
29
+ *
30
+ * const program = Effect.gen(function* () {
31
+ * const inputs = yield* ActionInputs
32
+ * const logger = yield* ActionLogger
33
+ * // ... your action logic
34
+ * })
35
+ *
36
+ * Action.run(program)
37
+ * ```
38
+ *
39
+ * @public
40
+ */
41
+ export declare const Action: {
42
+ /**
43
+ * Run a GitHub Action program with standard boilerplate handled.
44
+ *
45
+ * Handles:
46
+ * - Providing all standard Live layers (ActionInputs, ActionLogger, ActionOutputs)
47
+ * - Installing ActionLoggerLayer (routes Effect.log to core.info/debug)
48
+ * - Catching all errors and calling `core.setFailed`
49
+ * - Running with `Effect.runPromise`
50
+ *
51
+ * Returns a Promise that resolves when the action completes. In production
52
+ * the return value can be ignored (fire-and-forget). In tests, await it
53
+ * to avoid timing issues.
54
+ */
55
+ readonly run: {
56
+ <E>(program: Effect.Effect<void, E, CoreServices>): Promise<void>;
57
+ <E, R>(program: Effect.Effect<void, E, R | CoreServices>, layer: Layer.Layer<R, never, never>): Promise<void>;
58
+ };
59
+ /**
60
+ * Read and validate all inputs at once, with optional cross-validation.
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * const inputs = yield* Action.parseInputs({
65
+ * "app-id": { schema: Schema.NumberFromString, required: true },
66
+ * "branch": { schema: Schema.String, default: "main" },
67
+ * })
68
+ * ```
69
+ */
70
+ readonly parseInputs: <T extends Record<string, InputConfig<Schema.Schema.AnyNoContext>>>(config: T, crossValidate?: ((parsed: ParsedInputs<T>) => Effect.Effect<ParsedInputs<T>, ActionInputError, never>) | undefined) => Effect.Effect<ParsedInputs<T>, ActionInputError, ActionInputs>;
71
+ /** Create an Effect Logger that routes to GitHub Actions log functions. */
72
+ readonly makeLogger: () => Logger<unknown, void>;
73
+ /** Set the action log level for the current scope. */
74
+ readonly setLogLevel: (level: "debug" | "info" | "verbose") => Effect.Effect<void, never, never>;
75
+ /** Resolve a LogLevelInput to a concrete ActionLogLevel. */
76
+ readonly resolveLogLevel: (input: "auto" | "debug" | "info" | "verbose") => "debug" | "info" | "verbose";
77
+ };
78
+
21
79
  /**
22
80
  * Error when a GitHub Action input is missing or fails schema validation.
23
81
  */
@@ -62,6 +120,21 @@ export declare interface ActionInputs {
62
120
  * Read a required input as a JSON string, parse and validate it.
63
121
  */
64
122
  readonly getJson: <A, I>(name: string, schema: Schema.Schema<A, I, never>) => Effect.Effect<A, ActionInputError>;
123
+ /**
124
+ * Read a multiline input (newline-delimited list).
125
+ * Splits on newlines, trims each line, filters blank lines and comment lines (starting with #).
126
+ * Each remaining item is validated against the schema.
127
+ */
128
+ readonly getMultiline: <A, I>(name: string, itemSchema: Schema.Schema<A, I, never>) => Effect.Effect<Array<A>, ActionInputError>;
129
+ /**
130
+ * Read a boolean input. Accepts "true"/"false" (case-insensitive).
131
+ */
132
+ readonly getBoolean: (name: string) => Effect.Effect<boolean, ActionInputError>;
133
+ /**
134
+ * Read an optional boolean input with a default value.
135
+ * Returns the default if the input is not provided.
136
+ */
137
+ readonly getBooleanOptional: (name: string, defaultValue: boolean) => Effect.Effect<boolean, ActionInputError>;
65
138
  }
66
139
 
67
140
  /**
@@ -241,6 +314,16 @@ export declare interface ActionOutputs {
241
314
  * Add a directory to PATH for subsequent steps.
242
315
  */
243
316
  readonly addPath: (path: string) => Effect.Effect<void>;
317
+ /**
318
+ * Mark the action as failed with a message.
319
+ * This is the standard way to signal action failure.
320
+ */
321
+ readonly setFailed: (message: string) => Effect.Effect<void>;
322
+ /**
323
+ * Register a value as a secret so it is masked in logs.
324
+ * Use for values not read through ActionInputs (e.g., generated tokens).
325
+ */
326
+ readonly setSecret: (value: string) => Effect.Effect<void>;
244
327
  }
245
328
 
246
329
  /**
@@ -280,12 +363,93 @@ export declare interface ActionOutputsTestState {
280
363
  readonly summaries: Array<string>;
281
364
  readonly variables: Array<CapturedOutput>;
282
365
  readonly paths: Array<string>;
366
+ readonly secrets: Array<string>;
367
+ readonly failed: Array<string>;
283
368
  }
284
369
 
285
370
  /**
286
- * Bold text.
371
+ * Service interface for reading and writing GitHub Action state
372
+ * with schema-based serialization across action phases (pre/main/post).
373
+ *
374
+ * @public
287
375
  */
288
- export declare const bold: (text: string) => string;
376
+ export declare interface ActionState {
377
+ /**
378
+ * Save a value to action state. Uses Schema.encode to serialize
379
+ * complex objects to JSON strings for storage.
380
+ */
381
+ readonly save: <A, I>(key: string, value: A, schema: Schema.Schema<A, I, never>) => Effect.Effect<void, ActionStateError>;
382
+ /**
383
+ * Read a required state value. Uses Schema.decode to deserialize
384
+ * and validate the stored JSON string.
385
+ */
386
+ readonly get: <A, I>(key: string, schema: Schema.Schema<A, I, never>) => Effect.Effect<A, ActionStateError>;
387
+ /**
388
+ * Read an optional state value. Returns Option.none() if the key
389
+ * has no stored value.
390
+ */
391
+ readonly getOptional: <A, I>(key: string, schema: Schema.Schema<A, I, never>) => Effect.Effect<Option.Option<A>, ActionStateError>;
392
+ }
393
+
394
+ /**
395
+ * ActionState tag for dependency injection.
396
+ *
397
+ * @public
398
+ */
399
+ export declare const ActionState: Context.Tag<ActionState, ActionState>;
400
+
401
+ /**
402
+ * Error when GitHub Action state reading/writing fails.
403
+ */
404
+ export declare class ActionStateError extends ActionStateErrorBase<{
405
+ /** The state key name. */
406
+ readonly key: string;
407
+ /** Human-readable description of what went wrong. */
408
+ readonly reason: string;
409
+ /** The raw string value received, if any. */
410
+ readonly rawValue: string | undefined;
411
+ }> {
412
+ }
413
+
414
+ /**
415
+ * Base class for ActionStateError.
416
+ *
417
+ * @internal
418
+ */
419
+ export declare const ActionStateErrorBase: new <A extends Record<string, any> = {}>(args: Equals<A, {}> extends true ? void : { readonly [P in keyof A as P extends "_tag" ? never : P]: A[P]; }) => YieldableError & {
420
+ readonly _tag: "ActionStateError";
421
+ } & Readonly<A>;
422
+
423
+ export declare const ActionStateLive: Layer.Layer<ActionState>;
424
+
425
+ /**
426
+ * Test implementation that captures state in memory.
427
+ *
428
+ * @example
429
+ * ```ts
430
+ * const state = ActionStateTest.empty();
431
+ * const layer = ActionStateTest.layer(state);
432
+ * ```
433
+ */
434
+ export declare const ActionStateTest: {
435
+ /**
436
+ * Create a fresh empty test state container.
437
+ */
438
+ readonly empty: () => ActionStateTestState;
439
+ /**
440
+ * Create a test layer from the given state.
441
+ * Pre-populate entries to simulate state from a previous phase.
442
+ */
443
+ readonly layer: (state: ActionStateTestState) => Layer.Layer<ActionState, never, never>;
444
+ };
445
+
446
+ /**
447
+ * In-memory state captured by the test state layer.
448
+ */
449
+ export declare interface ActionStateTestState {
450
+ /** Stored state entries (key to JSON string). */
451
+ readonly entries: Map<string, string>;
452
+ }
289
453
 
290
454
  /**
291
455
  * A captured output entry.
@@ -297,14 +461,6 @@ export declare const CapturedOutput: Schema.Struct<{
297
461
 
298
462
  export declare type CapturedOutput = typeof CapturedOutput.Type;
299
463
 
300
- /**
301
- * Build a checkbox checklist.
302
- */
303
- export declare const checklist: (items: readonly {
304
- readonly label: string;
305
- readonly checked: boolean;
306
- }[]) => string;
307
-
308
464
  /**
309
465
  * A single item in a checklist.
310
466
  */
@@ -315,15 +471,8 @@ export declare const ChecklistItem: Schema.Struct<{
315
471
 
316
472
  export declare type ChecklistItem = typeof ChecklistItem.Type;
317
473
 
318
- /**
319
- * Inline code.
320
- */
321
- export declare const code: (text: string) => string;
322
-
323
- /**
324
- * Fenced code block.
325
- */
326
- export declare const codeBlock: (content: string, language?: string) => string;
474
+ /** Core services provided automatically by {@link Action.run}. */
475
+ export declare type CoreServices = ActionInputs | ActionLogger | ActionOutputs | PlatformNode.NodeContext.NodeContext;
327
476
 
328
477
  /**
329
478
  * FiberRef that holds the current action log level for the fiber.
@@ -331,26 +480,94 @@ export declare const codeBlock: (content: string, language?: string) => string;
331
480
  export declare const CurrentLogLevel: FiberRef.FiberRef<ActionLogLevel>;
332
481
 
333
482
  /**
334
- * Build a collapsible `<details>` block.
335
- */
336
- export declare const details: (summary: string, content: string) => string;
337
-
338
- /**
339
- * Build a markdown heading.
483
+ * Namespace for GitHub-Flavored Markdown builder functions.
340
484
  *
341
- * @param level - Heading level 1-6, defaults to 2.
342
- */
343
- export declare const heading: (text: string, level?: 1 | 2 | 3 | 4 | 5 | 6) => string;
344
-
345
- /**
346
- * Build a markdown link.
485
+ * @example
486
+ * ```ts
487
+ * import { GithubMarkdown } from "@savvy-web/github-action-effects"
488
+ *
489
+ * GithubMarkdown.table(["Name", "Status"], [["build", "pass"]])
490
+ * GithubMarkdown.bold("hello")
491
+ * ```
492
+ *
493
+ * @public
347
494
  */
348
- export declare const link: (text: string, url: string) => string;
495
+ export declare const GithubMarkdown: {
496
+ /**
497
+ * Build a GFM table from headers and rows.
498
+ */
499
+ readonly table: (headers: readonly string[], rows: readonly (readonly string[])[]) => string;
500
+ /**
501
+ * Build a markdown heading.
502
+ *
503
+ * @param level - Heading level 1-6, defaults to 2.
504
+ */
505
+ readonly heading: (text: string, level?: 1 | 2 | 3 | 4 | 5 | 6) => string;
506
+ /**
507
+ * Build a collapsible `<details>` block.
508
+ */
509
+ readonly details: (summary: string, content: string) => string;
510
+ /**
511
+ * Horizontal rule.
512
+ */
513
+ readonly rule: () => string;
514
+ /**
515
+ * Map a {@link Status} to its emoji indicator.
516
+ */
517
+ readonly statusIcon: (status: "fail" | "pass" | "skip" | "warn") => string;
518
+ /**
519
+ * Build a markdown link.
520
+ */
521
+ readonly link: (text: string, url: string) => string;
522
+ /**
523
+ * Build a bulleted list.
524
+ */
525
+ readonly list: (items: readonly string[]) => string;
526
+ /**
527
+ * Build a checkbox checklist.
528
+ */
529
+ readonly checklist: (items: readonly {
530
+ readonly label: string;
531
+ readonly checked: boolean;
532
+ }[]) => string;
533
+ /**
534
+ * Bold text.
535
+ */
536
+ readonly bold: (text: string) => string;
537
+ /**
538
+ * Inline code.
539
+ */
540
+ readonly code: (text: string) => string;
541
+ /**
542
+ * Fenced code block.
543
+ */
544
+ readonly codeBlock: (content: string, language?: string) => string;
545
+ };
349
546
 
350
547
  /**
351
- * Build a bulleted list.
548
+ * Configuration for a single input in {@link Action.parseInputs}.
549
+ *
550
+ * Precedence rules for how an input is read:
551
+ * 1. `json: true` — reads as JSON string, parses and validates (always required)
552
+ * 2. `multiline: true` — reads as newline-delimited list (always required)
553
+ * 3. `secret: true` — reads and masks the value (always required)
554
+ * 4. `default` is set — reads as optional, falls back to default if missing
555
+ * 5. `required: false` — reads as optional, returns undefined if missing
556
+ * 6. Otherwise — reads as required (default behavior)
557
+ *
558
+ * When `json`, `multiline`, or `secret` is set, the input is always
559
+ * treated as required regardless of `required` or `default` values.
560
+ *
561
+ * @public
352
562
  */
353
- export declare const list: (items: readonly string[]) => string;
563
+ export declare interface InputConfig<S extends Schema.Schema.AnyNoContext = Schema.Schema.AnyNoContext> {
564
+ readonly schema: S;
565
+ readonly required?: boolean;
566
+ readonly default?: Schema.Schema.Type<S>;
567
+ readonly multiline?: boolean;
568
+ readonly secret?: boolean;
569
+ readonly json?: boolean;
570
+ }
354
571
 
355
572
  /**
356
573
  * Log level input values accepted by the standardized `log-level` action input.
@@ -361,30 +578,11 @@ export declare const LogLevelInput: Schema.Literal<["info", "verbose", "debug",
361
578
  export declare type LogLevelInput = typeof LogLevelInput.Type;
362
579
 
363
580
  /**
364
- * Create an Effect Logger that routes to GitHub Actions log functions.
365
- *
366
- * - Always writes to `core.debug()` (GitHub-gated shadow channel).
367
- * - Writes to user-facing output based on the action log level.
368
- */
369
- export declare const makeActionLogger: () => Logger.Logger<unknown, void>;
370
-
371
- /**
372
- * Resolve a {@link LogLevelInput} to a concrete {@link ActionLogLevel}.
373
- *
374
- * `"auto"` resolves to `"info"` unless `RUNNER_DEBUG` is `"1"`,
375
- * in which case it resolves to `"debug"`.
581
+ * Infer the output type from an input config record.
376
582
  */
377
- export declare const resolveLogLevel: (input: "auto" | "debug" | "info" | "verbose") => "debug" | "info" | "verbose";
378
-
379
- /**
380
- * Horizontal rule.
381
- */
382
- export declare const rule: () => string;
383
-
384
- /**
385
- * Set the action log level for the current scope.
386
- */
387
- export declare const setLogLevel: (level: "debug" | "info" | "verbose") => Effect.Effect<void, never, never>;
583
+ export declare type ParsedInputs<T extends Record<string, InputConfig>> = {
584
+ readonly [K in keyof T]: T[K] extends InputConfig<infer S> ? Schema.Schema.Type<S> : never;
585
+ };
388
586
 
389
587
  /**
390
588
  * Status values for {@link statusIcon}.
@@ -393,21 +591,6 @@ export declare const Status: Schema.Literal<["pass", "fail", "skip", "warn"]>;
393
591
 
394
592
  export declare type Status = typeof Status.Type;
395
593
 
396
- /**
397
- * Map a {@link Status} to its emoji indicator.
398
- */
399
- export declare const statusIcon: (status: "fail" | "pass" | "skip" | "warn") => string;
400
-
401
- /**
402
- * Build a GFM table from headers and rows.
403
- *
404
- * @example
405
- * ```ts
406
- * table(["Name", "Status"], [["build", "pass"], ["test", "fail"]])
407
- * ```
408
- */
409
- export declare const table: (headers: readonly string[], rows: readonly (readonly string[])[]) => string;
410
-
411
594
  /**
412
595
  * Annotation type captured by the test layer.
413
596
  */
package/index.js CHANGED
@@ -1,11 +1,9 @@
1
- import { Context, Data, Effect, FiberRef, FiberRefs, Layer, LogLevel, Logger, Option, Schema } from "effect";
2
- import { addPath, debug, endGroup, error as core_error, exportVariable, getInput, info, notice, setOutput, setSecret, startGroup, summary as core_summary, warning } from "@actions/core";
1
+ import { addPath, debug, endGroup, error as core_error, exportVariable, getInput, getMultilineInput, getState, info, notice, saveState, setFailed, setOutput, setSecret, startGroup, summary as core_summary, warning } from "@actions/core";
2
+ import { NodeContext } from "@effect/platform-node";
3
+ import { Cause, Context, Data, Effect, FiberRef, FiberRefs, Layer, LogLevel, Logger, Option, Schema } from "effect";
3
4
  const ActionInputErrorBase = Data.TaggedError("ActionInputError");
4
5
  class ActionInputError extends ActionInputErrorBase {
5
6
  }
6
- const ActionOutputErrorBase = Data.TaggedError("ActionOutputError");
7
- class ActionOutputError extends ActionOutputErrorBase {
8
- }
9
7
  const ActionInputs = Context.GenericTag("ActionInputs");
10
8
  const decodeInput = (name, raw, schema)=>Schema.decode(schema)(raw).pipe(Effect.mapError((parseError)=>new ActionInputError({
11
9
  inputName: name,
@@ -24,6 +22,16 @@ const decodeJsonInput = (name, raw, schema)=>Effect["try"]({
24
22
  reason: `Input "${name}" JSON validation failed: ${parseError.message}`,
25
23
  rawValue: raw
26
24
  })))));
25
+ const parseBoolean = (name, raw)=>{
26
+ const lower = raw.toLowerCase().trim();
27
+ if ("true" === lower) return Effect.succeed(true);
28
+ if ("false" === lower) return Effect.succeed(false);
29
+ return Effect.fail(new ActionInputError({
30
+ inputName: name,
31
+ reason: `Input "${name}" is not a valid boolean: expected "true" or "false", got "${raw}"`,
32
+ rawValue: raw
33
+ }));
34
+ };
27
35
  const ActionInputsLive = Layer.succeed(ActionInputs, {
28
36
  get: (name, schema)=>Effect.sync(()=>getInput(name, {
29
37
  required: true
@@ -43,37 +51,20 @@ const ActionInputsLive = Layer.succeed(ActionInputs, {
43
51
  }).pipe(Effect.flatMap((raw)=>decodeInput(name, raw, schema))),
44
52
  getJson: (name, schema)=>Effect.sync(()=>getInput(name, {
45
53
  required: true
46
- })).pipe(Effect.flatMap((raw)=>decodeJsonInput(name, raw, schema)))
54
+ })).pipe(Effect.flatMap((raw)=>decodeJsonInput(name, raw, schema))),
55
+ getMultiline: (name, itemSchema)=>Effect.sync(()=>getMultilineInput(name, {
56
+ required: true
57
+ })).pipe(Effect.map((lines)=>lines.map((l)=>l.trim()).filter((l)=>l.length > 0 && !l.startsWith("#"))), Effect.flatMap((lines)=>Effect.forEach(lines, (line)=>decodeInput(name, line, itemSchema)))),
58
+ getBoolean: (name)=>Effect.sync(()=>getInput(name, {
59
+ required: true
60
+ })).pipe(Effect.flatMap((raw)=>parseBoolean(name, raw))),
61
+ getBooleanOptional: (name, defaultValue)=>Effect.sync(()=>getInput(name, {
62
+ required: false
63
+ })).pipe(Effect.flatMap((raw)=>{
64
+ if ("" === raw) return Effect.succeed(defaultValue);
65
+ return parseBoolean(name, raw);
66
+ }))
47
67
  });
48
- const missingInput = (name)=>new ActionInputError({
49
- inputName: name,
50
- reason: `Input "${name}" is required but not provided`,
51
- rawValue: void 0
52
- });
53
- const ActionInputsTest = {
54
- layer: (inputs)=>Layer.succeed(ActionInputs, {
55
- get: (name, schema)=>{
56
- const raw = inputs[name];
57
- if (void 0 === raw) return Effect.fail(missingInput(name));
58
- return decodeInput(name, raw, schema);
59
- },
60
- getOptional: (name, schema)=>{
61
- const raw = inputs[name];
62
- if (void 0 === raw || "" === raw) return Effect.succeed(Option.none());
63
- return decodeInput(name, raw, schema).pipe(Effect.map((a)=>Option.some(a)));
64
- },
65
- getSecret: (name, schema)=>{
66
- const raw = inputs[name];
67
- if (void 0 === raw) return Effect.fail(missingInput(name));
68
- return decodeInput(name, raw, schema);
69
- },
70
- getJson: (name, schema)=>{
71
- const raw = inputs[name];
72
- if (void 0 === raw) return Effect.fail(missingInput(name));
73
- return decodeJsonInput(name, raw, schema);
74
- }
75
- })
76
- };
77
68
  const ActionLogger = Context.GenericTag("ActionLogger");
78
69
  const CurrentLogLevel = FiberRef.unsafeMake("info");
79
70
  const setLogLevel = (level)=>FiberRef.set(CurrentLogLevel, level);
@@ -131,6 +122,136 @@ const ActionLoggerLive = Layer.succeed(ActionLogger, {
131
122
  void 0 !== properties ? notice(message, properties) : notice(message);
132
123
  })
133
124
  });
125
+ const ActionOutputErrorBase = Data.TaggedError("ActionOutputError");
126
+ class ActionOutputError extends ActionOutputErrorBase {
127
+ }
128
+ const ActionOutputs = Context.GenericTag("ActionOutputs");
129
+ const ActionOutputsLive = Layer.succeed(ActionOutputs, {
130
+ set: (name, value)=>Effect.sync(()=>setOutput(name, value)),
131
+ setJson: (name, value, schema)=>Schema.encode(schema)(value).pipe(Effect.tap((encoded)=>Effect.sync(()=>setOutput(name, JSON.stringify(encoded)))), Effect.asVoid, Effect.mapError((parseError)=>new ActionOutputError({
132
+ outputName: name,
133
+ reason: `Output "${name}" validation failed: ${parseError.message}`
134
+ }))),
135
+ summary: (content)=>Effect.tryPromise({
136
+ try: ()=>core_summary.addRaw(content).write(),
137
+ catch: (error)=>new ActionOutputError({
138
+ outputName: "summary",
139
+ reason: `Failed to write step summary: ${error instanceof Error ? error.message : String(error)}`
140
+ })
141
+ }).pipe(Effect.asVoid),
142
+ exportVariable: (name, value)=>Effect.sync(()=>exportVariable(name, value)),
143
+ addPath: (path)=>Effect.sync(()=>addPath(path)),
144
+ setFailed: (message)=>Effect.sync(()=>setFailed(message)),
145
+ setSecret: (value)=>Effect.sync(()=>setSecret(value))
146
+ });
147
+ const ActionLogLevel = Schema.Literal("info", "verbose", "debug").annotations({
148
+ identifier: "ActionLogLevel",
149
+ title: "Action Log Level",
150
+ description: "Logging verbosity for GitHub Action output"
151
+ });
152
+ const LogLevelInput = Schema.Literal("info", "verbose", "debug", "auto").annotations({
153
+ identifier: "LogLevelInput",
154
+ title: "Log Level Input",
155
+ description: "Logging verbosity: info, verbose, debug, or auto",
156
+ message: ()=>({
157
+ message: 'log-level must be one of: "info", "verbose", "debug", "auto"',
158
+ override: true
159
+ })
160
+ });
161
+ const resolveLogLevel = (input)=>{
162
+ if ("auto" !== input) return input;
163
+ return "1" === process.env.RUNNER_DEBUG ? "debug" : "info";
164
+ };
165
+ const CoreLive = Layer.mergeAll(ActionInputsLive, ActionLoggerLive, ActionOutputsLive, NodeContext.layer);
166
+ const Action = {
167
+ run: (program, layer)=>{
168
+ const fullLayer = layer ? Layer.mergeAll(CoreLive, layer) : CoreLive;
169
+ const runnable = program.pipe(Effect.provide(fullLayer), Effect.provide(ActionLoggerLayer), Effect.catchAllCause((cause)=>{
170
+ const message = Cause.pretty(cause);
171
+ return Effect.sync(()=>setFailed(`Action failed: ${message}`));
172
+ }));
173
+ return Effect.runPromise(runnable).catch(()=>{
174
+ process.exitCode = 1;
175
+ });
176
+ },
177
+ parseInputs: (config, crossValidate)=>Effect.flatMap(ActionInputs, (svc)=>{
178
+ const entries = Object.entries(config);
179
+ return Effect.forEach(entries, ([name, cfg])=>{
180
+ const { schema, json, multiline, secret } = cfg;
181
+ const isOptional = false === cfg.required || void 0 !== cfg.default;
182
+ let readEffect;
183
+ readEffect = json ? svc.getJson(name, schema) : multiline ? svc.getMultiline(name, schema) : secret ? svc.getSecret(name, schema) : isOptional ? svc.getOptional(name, schema).pipe(Effect.map((opt)=>{
184
+ if ("None" === opt._tag) return cfg.default;
185
+ return opt.value;
186
+ })) : svc.get(name, schema);
187
+ return Effect.map(readEffect, (value)=>[
188
+ name,
189
+ value
190
+ ]);
191
+ }).pipe(Effect.map((pairs)=>Object.fromEntries(pairs)), Effect.flatMap((parsed)=>crossValidate ? crossValidate(parsed) : Effect.succeed(parsed)));
192
+ }),
193
+ makeLogger: makeActionLogger,
194
+ setLogLevel: setLogLevel,
195
+ resolveLogLevel: resolveLogLevel
196
+ };
197
+ const ActionStateErrorBase = Data.TaggedError("ActionStateError");
198
+ class ActionStateError extends ActionStateErrorBase {
199
+ }
200
+ const missingInput = (name)=>new ActionInputError({
201
+ inputName: name,
202
+ reason: `Input "${name}" is required but not provided`,
203
+ rawValue: void 0
204
+ });
205
+ const ActionInputsTest_parseBoolean = (name, raw)=>{
206
+ const lower = raw.toLowerCase().trim();
207
+ if ("true" === lower) return Effect.succeed(true);
208
+ if ("false" === lower) return Effect.succeed(false);
209
+ return Effect.fail(new ActionInputError({
210
+ inputName: name,
211
+ reason: `Input "${name}" is not a valid boolean: expected "true" or "false", got "${raw}"`,
212
+ rawValue: raw
213
+ }));
214
+ };
215
+ const ActionInputsTest = {
216
+ layer: (inputs)=>Layer.succeed(ActionInputs, {
217
+ get: (name, schema)=>{
218
+ const raw = inputs[name];
219
+ if (void 0 === raw) return Effect.fail(missingInput(name));
220
+ return decodeInput(name, raw, schema);
221
+ },
222
+ getOptional: (name, schema)=>{
223
+ const raw = inputs[name];
224
+ if (void 0 === raw || "" === raw) return Effect.succeed(Option.none());
225
+ return decodeInput(name, raw, schema).pipe(Effect.map((a)=>Option.some(a)));
226
+ },
227
+ getSecret: (name, schema)=>{
228
+ const raw = inputs[name];
229
+ if (void 0 === raw) return Effect.fail(missingInput(name));
230
+ return decodeInput(name, raw, schema);
231
+ },
232
+ getJson: (name, schema)=>{
233
+ const raw = inputs[name];
234
+ if (void 0 === raw) return Effect.fail(missingInput(name));
235
+ return decodeJsonInput(name, raw, schema);
236
+ },
237
+ getMultiline: (name, itemSchema)=>{
238
+ const raw = inputs[name];
239
+ if (void 0 === raw) return Effect.fail(missingInput(name));
240
+ const lines = raw.split("\n").map((l)=>l.trim()).filter((l)=>l.length > 0 && !l.startsWith("#"));
241
+ return Effect.forEach(lines, (line)=>decodeInput(name, line, itemSchema));
242
+ },
243
+ getBoolean: (name)=>{
244
+ const raw = inputs[name];
245
+ if (void 0 === raw) return Effect.fail(missingInput(name));
246
+ return ActionInputsTest_parseBoolean(name, raw);
247
+ },
248
+ getBooleanOptional: (name, defaultValue)=>{
249
+ const raw = inputs[name];
250
+ if (void 0 === raw || "" === raw) return Effect.succeed(defaultValue);
251
+ return ActionInputsTest_parseBoolean(name, raw);
252
+ }
253
+ })
254
+ };
134
255
  const makeAnnotation = (state, type)=>(message, properties)=>Effect.sync(()=>{
135
256
  state.annotations.push({
136
257
  type,
@@ -170,29 +291,14 @@ const ActionLoggerTest = {
170
291
  annotationNotice: makeAnnotation(state, "notice")
171
292
  })
172
293
  };
173
- const ActionOutputs = Context.GenericTag("ActionOutputs");
174
- const ActionOutputsLive = Layer.succeed(ActionOutputs, {
175
- set: (name, value)=>Effect.sync(()=>setOutput(name, value)),
176
- setJson: (name, value, schema)=>Schema.encode(schema)(value).pipe(Effect.tap((encoded)=>Effect.sync(()=>setOutput(name, JSON.stringify(encoded)))), Effect.asVoid, Effect.mapError((parseError)=>new ActionOutputError({
177
- outputName: name,
178
- reason: `Output "${name}" validation failed: ${parseError.message}`
179
- }))),
180
- summary: (content)=>Effect.tryPromise({
181
- try: ()=>core_summary.addRaw(content).write(),
182
- catch: (error)=>new ActionOutputError({
183
- outputName: "summary",
184
- reason: `Failed to write step summary: ${error instanceof Error ? error.message : String(error)}`
185
- })
186
- }).pipe(Effect.asVoid),
187
- exportVariable: (name, value)=>Effect.sync(()=>exportVariable(name, value)),
188
- addPath: (path)=>Effect.sync(()=>addPath(path))
189
- });
190
294
  const ActionOutputsTest = {
191
295
  empty: ()=>({
192
296
  outputs: [],
193
297
  summaries: [],
194
298
  variables: [],
195
- paths: []
299
+ paths: [],
300
+ secrets: [],
301
+ failed: []
196
302
  }),
197
303
  layer: (state)=>Layer.succeed(ActionOutputs, {
198
304
  set: (name, value)=>Effect.sync(()=>{
@@ -221,9 +327,72 @@ const ActionOutputsTest = {
221
327
  }),
222
328
  addPath: (path)=>Effect.sync(()=>{
223
329
  state.paths.push(path);
330
+ }),
331
+ setFailed: (message)=>Effect.sync(()=>{
332
+ state.failed.push(message);
333
+ }),
334
+ setSecret: (value)=>Effect.sync(()=>{
335
+ state.secrets.push(value);
224
336
  })
225
337
  })
226
338
  };
339
+ const ActionState = Context.GenericTag("ActionState");
340
+ const encodeState = (key, value, schema)=>Schema.encode(schema)(value).pipe(Effect.map((encoded)=>JSON.stringify(encoded)), Effect.mapError((error)=>new ActionStateError({
341
+ key,
342
+ reason: `State "${key}" encode failed: ${error instanceof Error ? error.message : String(error)}`,
343
+ rawValue: void 0
344
+ })));
345
+ const decodeState = (key, raw, schema)=>Effect["try"]({
346
+ try: ()=>JSON.parse(raw),
347
+ catch: (error)=>new ActionStateError({
348
+ key,
349
+ reason: `State "${key}" is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
350
+ rawValue: raw
351
+ })
352
+ }).pipe(Effect.flatMap((parsed)=>Schema.decode(schema)(parsed).pipe(Effect.mapError((parseError)=>new ActionStateError({
353
+ key,
354
+ reason: `State "${key}" decode failed: ${parseError.message}`,
355
+ rawValue: raw
356
+ })))));
357
+ const ActionStateLive = Layer.succeed(ActionState, {
358
+ save: (key, value, schema)=>encodeState(key, value, schema).pipe(Effect.tap((json)=>Effect.sync(()=>saveState(key, json))), Effect.asVoid),
359
+ get: (key, schema)=>Effect.sync(()=>getState(key)).pipe(Effect.flatMap((raw)=>{
360
+ if ("" === raw) return Effect.fail(new ActionStateError({
361
+ key,
362
+ reason: `State "${key}" is not set (phase ordering issue?)`,
363
+ rawValue: void 0
364
+ }));
365
+ return decodeState(key, raw, schema);
366
+ })),
367
+ getOptional: (key, schema)=>Effect.sync(()=>getState(key)).pipe(Effect.flatMap((raw)=>{
368
+ if ("" === raw) return Effect.succeed(Option.none());
369
+ return decodeState(key, raw, schema).pipe(Effect.map((a)=>Option.some(a)));
370
+ }))
371
+ });
372
+ const ActionStateTest = {
373
+ empty: ()=>({
374
+ entries: new Map()
375
+ }),
376
+ layer: (state)=>Layer.succeed(ActionState, {
377
+ save: (key, value, schema)=>encodeState(key, value, schema).pipe(Effect.tap((json)=>Effect.sync(()=>{
378
+ state.entries.set(key, json);
379
+ })), Effect.asVoid),
380
+ get: (key, schema)=>{
381
+ const raw = state.entries.get(key);
382
+ if (void 0 === raw) return Effect.fail(new ActionStateError({
383
+ key,
384
+ reason: `State "${key}" is not set (phase ordering issue?)`,
385
+ rawValue: void 0
386
+ }));
387
+ return decodeState(key, raw, schema);
388
+ },
389
+ getOptional: (key, schema)=>{
390
+ const raw = state.entries.get(key);
391
+ if (void 0 === raw) return Effect.succeed(Option.none());
392
+ return decodeState(key, raw, schema).pipe(Effect.map((a)=>Option.some(a)));
393
+ }
394
+ })
395
+ };
227
396
  const Status = Schema.Literal("pass", "fail", "skip", "warn").annotations({
228
397
  identifier: "Status",
229
398
  title: "Check Status",
@@ -243,53 +412,37 @@ const CapturedOutput = Schema.Struct({
243
412
  identifier: "CapturedOutput",
244
413
  title: "Captured Output"
245
414
  });
246
- const ActionLogLevel = Schema.Literal("info", "verbose", "debug").annotations({
247
- identifier: "ActionLogLevel",
248
- title: "Action Log Level",
249
- description: "Logging verbosity for GitHub Action output"
250
- });
251
- const LogLevelInput = Schema.Literal("info", "verbose", "debug", "auto").annotations({
252
- identifier: "LogLevelInput",
253
- title: "Log Level Input",
254
- description: "Logging verbosity: info, verbose, debug, or auto",
255
- message: ()=>({
256
- message: 'log-level must be one of: "info", "verbose", "debug", "auto"',
257
- override: true
258
- })
259
- });
260
- const resolveLogLevel = (input)=>{
261
- if ("auto" !== input) return input;
262
- return "1" === process.env.RUNNER_DEBUG ? "debug" : "info";
263
- };
264
- const table = (headers, rows)=>{
265
- const headerRow = `| ${headers.join(" | ")} |`;
266
- const separator = `| ${headers.map(()=>"---").join(" | ")} |`;
267
- const dataRows = rows.map((row)=>`| ${row.join(" | ")} |`);
268
- return [
269
- headerRow,
270
- separator,
271
- ...dataRows
272
- ].join("\n");
273
- };
274
- const heading = (text, level = 2)=>`${"#".repeat(level)} ${text}`;
275
- const details = (summary, content)=>`<details>\n<summary>${summary}</summary>\n\n${content}\n\n</details>`;
276
- const rule = ()=>"---";
277
- const statusIcon = (status)=>{
278
- switch(status){
279
- case "pass":
280
- return "\u2705";
281
- case "fail":
282
- return "\u274C";
283
- case "skip":
284
- return "\uD83D\uDDC3\uFE0F";
285
- case "warn":
286
- return "\u26A0\uFE0F";
287
- }
415
+ const GithubMarkdown = {
416
+ table: (headers, rows)=>{
417
+ const headerRow = `| ${headers.join(" | ")} |`;
418
+ const separator = `| ${headers.map(()=>"---").join(" | ")} |`;
419
+ const dataRows = rows.map((row)=>`| ${row.join(" | ")} |`);
420
+ return [
421
+ headerRow,
422
+ separator,
423
+ ...dataRows
424
+ ].join("\n");
425
+ },
426
+ heading: (text, level = 2)=>`${"#".repeat(level)} ${text}`,
427
+ details: (summary, content)=>`<details>\n<summary>${summary}</summary>\n\n${content}\n\n</details>`,
428
+ rule: ()=>"---",
429
+ statusIcon: (status)=>{
430
+ switch(status){
431
+ case "pass":
432
+ return "\u2705";
433
+ case "fail":
434
+ return "\u274C";
435
+ case "skip":
436
+ return "\uD83D\uDDC3\uFE0F";
437
+ case "warn":
438
+ return "\u26A0\uFE0F";
439
+ }
440
+ },
441
+ link: (text, url)=>`[${text}](${url})`,
442
+ list: (items)=>items.map((item)=>`- ${item}`).join("\n"),
443
+ checklist: (items)=>items.map((item)=>`- [${item.checked ? "x" : " "}] ${item.label}`).join("\n"),
444
+ bold: (text)=>`**${text}**`,
445
+ code: (text)=>`\`${text}\``,
446
+ codeBlock: (content, language = "")=>`\`\`\`${language}\n${content}\n\`\`\``
288
447
  };
289
- const GithubMarkdown_link = (text, url)=>`[${text}](${url})`;
290
- const list = (items)=>items.map((item)=>`- ${item}`).join("\n");
291
- const checklist = (items)=>items.map((item)=>`- [${item.checked ? "x" : " "}] ${item.label}`).join("\n");
292
- const bold = (text)=>`**${text}**`;
293
- const code = (text)=>`\`${text}\``;
294
- const codeBlock = (content, language = "")=>`\`\`\`${language}\n${content}\n\`\`\``;
295
- export { ActionInputError, ActionInputErrorBase, ActionInputs, ActionInputsLive, ActionInputsTest, ActionLogLevel, ActionLogger, ActionLoggerLayer, ActionLoggerLive, ActionLoggerTest, ActionOutputError, ActionOutputErrorBase, ActionOutputs, ActionOutputsLive, ActionOutputsTest, CapturedOutput, ChecklistItem, CurrentLogLevel, GithubMarkdown_link as link, LogLevelInput, Status, bold, checklist, code, codeBlock, details, heading, list, makeActionLogger, resolveLogLevel, rule, setLogLevel, statusIcon, table };
448
+ export { Action, ActionInputError, ActionInputErrorBase, ActionInputs, ActionInputsLive, ActionInputsTest, ActionLogLevel, ActionLogger, ActionLoggerLayer, ActionLoggerLive, ActionLoggerTest, ActionOutputError, ActionOutputErrorBase, ActionOutputs, ActionOutputsLive, ActionOutputsTest, ActionState, ActionStateError, ActionStateErrorBase, ActionStateLive, ActionStateTest, CapturedOutput, ChecklistItem, CurrentLogLevel, GithubMarkdown, LogLevelInput, Status };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/github-action-effects",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "description": "Effect-based utility library for building robust, well-logged, and schema-validated GitHub Actions.",
6
6
  "homepage": "https://github.com/savvy-web/github-action-effects#readme",
@@ -43,10 +43,10 @@
43
43
  "optional": true
44
44
  },
45
45
  "@effect/platform": {
46
- "optional": true
46
+ "optional": false
47
47
  },
48
48
  "@effect/platform-node": {
49
- "optional": true
49
+ "optional": false
50
50
  },
51
51
  "effect": {
52
52
  "optional": false