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