This tutorial builds one complete GitHub Action with @savvy-web/github-action-effects. By the end you have a working action that validates its inputs, logs through Effect, writes a step summary and sets typed outputs.
npm install @savvy-web/github-action-effects effect \
@effect/platform @effect/platform-nodeIf you use @savvy-web/github-action-builder for bundling, it compiles your TypeScript source into the single dist/index.js that GitHub Actions expects.
Declare inputs, outputs and the entry point:
name: 'Package Checker'
description: 'Check a package and report results'
inputs:
package-name:
description: 'Package to check'
required: true
threshold:
description: 'Pass threshold (number)'
required: false
default: '80'
dry-run:
description: 'Skip writes'
required: false
default: 'false'
outputs:
status:
description: 'Check result status'
report:
description: 'JSON report'
runs:
using: 'node24'
main: 'dist/index.js'Below is the whole action. The numbered comments map to the explanations after the code.
import { Config, Effect, Schema } from "effect"
import {
Action,
ActionLogger,
ActionOutputs,
GithubMarkdown,
} from "@savvy-web/github-action-effects"
// -- Schemas for structured outputs --
const ReportSchema = Schema.Struct({
total: Schema.Number,
passed: Schema.Number,
failed: Schema.Number,
})
// -- The program --
const program = Effect.gen(function* () {
const logger = yield* ActionLogger
const outputs = yield* ActionOutputs
// 1. Read inputs via Config API (backed by ActionsConfigProvider)
const packageName = yield* Config.string("package-name")
const threshold = yield* Config.integer("threshold").pipe(Config.withDefault(80))
const dryRun = yield* Config.boolean("dry-run").pipe(Config.withDefault(false))
yield* Effect.log(`Checking ${packageName} (dry-run: ${dryRun})`)
// 2. Do work inside a collapsible log group
const results = yield* logger.group("Run checks", Effect.gen(function* () {
yield* Effect.log("Resolving dependencies")
yield* Effect.log("Running validation")
return [
{ name: packageName, passed: true, version: "1.0.0" },
]
}))
// 3. Use buffer-on-failure for noisy operations
yield* logger.withBuffer("detailed-analysis", Effect.gen(function* () {
for (const r of results) {
yield* Effect.log(`Analyzing ${r.name}...`) // buffered
}
// If this fails, all buffered lines flush to the log automatically
}))
// 4. Set typed outputs
yield* outputs.set("status", "success")
yield* outputs.setJson("report", {
total: results.length,
passed: results.filter((r) => r.passed).length,
failed: results.filter((r) => !r.passed).length,
}, ReportSchema)
// 5. Write a step summary with GFM builders
const summaryTable = GithubMarkdown.table(
["Package", "Status", "Version"],
results.map((r) => [
r.name,
GithubMarkdown.statusIcon(r.passed ? "pass" : "fail"),
r.version,
])
)
yield* outputs.summary([
GithubMarkdown.heading("Check Results"),
summaryTable,
].join("\n\n"))
// 6. Emit annotations for PR inline feedback
yield* Effect.logWarning("Deprecated API usage").pipe(
Effect.annotateLogs({ file: "src/helpers.ts", line: "42" }),
)
yield* Effect.log("Action completed")
})
// 7. Run it -- provides ActionsRuntime.Default and catches errors automatically
Action.run(program)- Inputs —
Config.string("package-name")readsINPUT_PACKAGE-NAMEfrom the environment.Config.integerandConfig.booleanparse and validate automatically.Config.withDefaultprovides fallback values. - Groups —
logger.groupwraps the effect in a collapsible section in the Actions UI. The return value passes through. - Buffer-on-failure —
logger.withBuffercaptures verbose output. On success the buffer is silently discarded. On failure it flushes before the error propagates, giving full context. - Outputs —
setfor strings,setJsonfor schema-validated JSON. Values appear in${{ steps.id.outputs.status }}in downstream workflow steps. - Step summary —
outputs.summarywrites markdown to the job summary.GithubMarkdown.*helpers build tables, headings, details blocks and so on. - Annotations — log through Effect at warning or error level and the
ActionsLoggermaps it to a::warning::or::error::workflow command. Log annotations likefileandlinebecome command properties, so the message appears inline on the PR diff at that location. - Action.run — Provides
ActionsRuntime.Default(ConfigProvider, Logger, ActionOutputs, ActionState, ActionLogger, ActionEnvironment and Node.js FileSystem), wraps inwithBufferand catches all errors with::error::workflow commands.
Some actions run in multiple phases — pre, main and post. The ActionState service moves typed data between them, encoding and decoding each value through a Schema.
runs:
using: 'node24'
pre: 'dist/pre.js'
main: 'dist/main.js'
post: 'dist/post.js'import { Effect, Schema } from "effect"
import { Action, ActionState } from "@savvy-web/github-action-effects"
const TimingSchema = Schema.Struct({ startedAt: Schema.Number })
const program = Effect.gen(function* () {
const state = yield* ActionState
yield* state.save("timing", { startedAt: Date.now() }, TimingSchema)
})
Action.run(program)import { Effect, Schema } from "effect"
import { Action, ActionState, ActionOutputs } from "@savvy-web/github-action-effects"
const TimingSchema = Schema.Struct({ startedAt: Schema.Number })
const program = Effect.gen(function* () {
const state = yield* ActionState
const outputs = yield* ActionOutputs
const timing = yield* state.get("timing", TimingSchema)
const elapsed = Date.now() - timing.startedAt
yield* outputs.set("duration-ms", String(elapsed))
})
Action.run(program)ActionState is included in ActionsRuntime.Default (provided by Action.run), so no additional layers are needed.
Action.run catches all errors and emits ::error:: workflow commands automatically. For granular control, use Effect.catchTag:
const result = yield* someEffect.pipe(
Effect.catchTag("GitHubClientError", (error) =>
Effect.gen(function* () {
yield* Effect.logError(`API call failed: ${error.reason}`)
return yield* Effect.fail(error)
})
)
)The error types — ActionOutputError, ActionStateError, GitHubClientError and so on — are all Data.TaggedError instances. See architecture for their fields.
For custom error handlers that extract a human-readable message from an Effect Cause, use Action.formatCause(cause). It returns a [Tag] message string that is parseable by both humans and AI. See error handling for details.
Test your action's program function using test layers from the /testing subpath. No mocks, no real runner calls — just in-memory service implementations.
// src/main.test.ts
import { Effect, Layer, Schema } from "effect"
import { describe, expect, it } from "vitest"
import {
ActionLogger,
ActionLoggerTest,
ActionOutputs,
ActionOutputsTest,
ActionState,
ActionStateTest,
} from "@savvy-web/github-action-effects/testing"
const program = Effect.gen(function* () {
const outputs = yield* ActionOutputs
yield* outputs.set("status", "success")
})
describe("package checker action", () => {
it("sets status output", async () => {
const outputState = ActionOutputsTest.empty()
const logState = ActionLoggerTest.empty()
const stateState = ActionStateTest.empty()
const layer = Layer.mergeAll(
ActionOutputsTest.layer(outputState),
ActionLoggerTest.layer(logState),
ActionStateTest.layer(stateState),
)
await program.pipe(Effect.provide(layer), Effect.runPromise)
expect(outputState.outputs).toContainEqual({ name: "status", value: "success" })
})
})See Testing GitHub Actions for the full API of each test layer and patterns for testing logging, state and annotations.
- Services guide — detailed guide for every service
- Testing GitHub Actions — test every service with in-memory layers
- Architecture — runtime layer, layer composition and the logging pipeline
- Common patterns — dry-run mode, error accumulation, permission checking and more