@savvy-web/github-action-effects
v2.4.0
Published
Effect-based utility library for building robust, well-logged, and schema-validated GitHub Actions.
Maintainers
Readme
@savvy-web/github-action-effects
Effect services for building GitHub Actions. You get schema-validated inputs, structured logging that maps to workflow commands and typed wrappers around the GitHub API, with no @actions/* packages anywhere in the dependency tree.
Features
- Zero CJS dependencies — native ESM implementations of the GitHub Actions runtime protocol replace all
@actions/*packages - 39 composable services — action I/O, GitHub API calls, git operations, package publishing and software attestation, each with its own
Context.Tag - Schema-validated inputs — read action inputs via Effect's
ConfigAPI with built-in parsing and defaults - Structured logging — Effect Logger maps to workflow commands with collapsible groups; buffered verbose output flushes inside its group when a step fails
- Step-buffered execution —
Step.withStepbuffers debug output per logical step, emits one success line on pass and spills the full buffer prefixed with the step name on failure - Software attestation — sign and upload SLSA provenance and CycloneDX SBOMs to GitHub's attestation store via the
Attest,SigstoreSigner,OidcTokenIssuerandSbomservices - Pluggable blob storage — the
BlobStoreservice stores raw bytes under a key, with backends for the GitHub Actions cache (GitHubBlobStoreLive) and any S3-compatible endpoint (S3BlobStoreLive) - In-memory test layers — every service ships a test layer for fast, deterministic unit tests
Install
npm install @savvy-web/github-action-effects effect @effect/platform @effect/platform-nodeQuick start
// src/main.ts
import { Config, Effect } from "effect";
import { Action, ActionOutputs } from "@savvy-web/github-action-effects";
const program = Effect.gen(function* () {
const name = yield* Config.string("package-name");
const outputs = yield* ActionOutputs;
yield* outputs.set("result", `checked ${name}`);
});
Action.run(program);Action.run provides ActionsRuntime.Default (ConfigProvider, Logger, core services, and Node.js platform layers), catches errors, and sets the workflow exit status automatically.
GitHub API clients
GitHubClientLive builds a GitHubClient layer one of three ways:
GitHubClientLive.fromEnv()— reads the ambientprocess.env.GITHUB_TOKEN, the repo-scoped workflow token. It is a function; call it with no arguments.GitHubClientLive.fromToken(token)— an explicit token with noprocess.envdependency. The token is aRedacted<string>— wrap a bare string withRedacted.make(...).GitHubClientLive.fromApp({ clientId, privateKey, installationId? })— mints an installation token from GitHub App credentials, withprivateKeyas aRedacted<string>. It is a scoped layer that revokes the token on scope close and requiresHttpClient.HttpClient; wrap a bareEffect.provideinEffect.scoped.
import { Effect } from "effect";
import { Action, GitHubClient, GitHubClientLive } from "@savvy-web/github-action-effects";
const program = Effect.gen(function* () {
const client = yield* GitHubClient;
const { owner, repo } = yield* client.repo;
return yield* client.rest("issues.list", (octokit) =>
octokit.rest.issues.listForRepo({ owner, repo }),
);
}).pipe(Effect.provide(GitHubClientLive.fromEnv()));
Action.run(program);The repo-scoped token is often too weak for permission-sensitive work. When that happens, pass fromToken a token you constructed yourself, or use fromApp to act as a GitHub App installation.
GitHub App token lifecycle
A GitHub Action runs in three phases — pre, main and post. The GitHubToken namespace generates one installation token in pre, hands main a client built from it and revokes it in post.
GitHubToken.provision and GitHubToken.dispose require a GitHubApp layer. In production, compose GitHubAppLive with OctokitAuthAppLive and provide the result to those effects.
// pre.ts — generate and persist the installation token
import { Effect, Layer } from "effect";
import { Action, GitHubAppLive, GitHubToken, OctokitAuthAppLive } from "@savvy-web/github-action-effects";
const appLayer = Layer.provide(GitHubAppLive, OctokitAuthAppLive);
Action.run(
GitHubToken.provision({
permissions: { contents: "write", pull_requests: "write" },
}).pipe(Effect.provide(appLayer)),
);// main.ts — build a GitHubClient from the persisted token
import { Effect } from "effect";
import { Action, GitHubClient, GitHubToken } from "@savvy-web/github-action-effects";
const program = Effect.gen(function* () {
const client = yield* GitHubClient;
const { owner, repo } = yield* client.repo;
return yield* client.rest("repos.get", (octokit) =>
octokit.rest.repos.get({ owner, repo }),
);
}).pipe(Effect.provide(GitHubToken.client()));
Action.run(program);// post.ts — revoke the token
import { Effect, Layer } from "effect";
import { Action, GitHubAppLive, GitHubToken, OctokitAuthAppLive } from "@savvy-web/github-action-effects";
const appLayer = Layer.provide(GitHubAppLive, OctokitAuthAppLive);
Action.run(GitHubToken.dispose().pipe(Effect.provide(appLayer)));provision reads App credentials from its options object or, by default, from the app-client-id and app-private-key action inputs. Passing permissions verifies the generated token grants those scopes before it is persisted. It also resolves the App's public identity (slug, bot user ID, name) best-effort and stores it on the token, so later phases can call GitHubToken.botIdentity() without an extra API call.
Two additional accessors are available in any phase after provision:
GitHubToken.read()— anEffect<InstallationToken, ActionStateError, ActionState>that reads the full persisted token envelope, including the optionalappSlug,appUserIdandappNamefields resolved duringprovision.GitHubToken.botIdentity()— anEffect<BotIdentity, ActionStateError, ActionState>that derives a commit-attribution identity from the persisted token. When the App's slug and user ID were resolved, the returned email uses the<userId>+<slug>[bot]@users.noreply.github.comformat that GitHub recognises for verified attribution; otherwise it falls back to the well-knowngithub-actions[bot]identity.
Blob storage
BlobStore stores raw bytes under a string key. It is the building block for caching binary artifacts across workflow steps — a compiled bundle, a downloaded toolchain, a serialized state file. The interface is three operations:
get(key)— returnsOption<Uint8Array>,Option.none()on a missput(key, bytes)— stores the bytes, overwriting any existing valuehas(key)— reports whether the key exists without downloading its bytes
Two backends implement the service. GitHubBlobStoreLive rides the GitHub Actions cache protocol, the same transport ActionCache uses, with one cache entry per key. S3BlobStoreLive targets any S3-compatible endpoint (AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces) and signs requests with a built-in SigV4 signer, so there is no aws-sdk in the dependency tree. Both require HttpClient, which Action.run provides.
import { Effect, Option } from "effect";
import { Action, BlobStore, GitHubBlobStoreLive } from "@savvy-web/github-action-effects";
const program = Effect.gen(function* () {
const store = yield* BlobStore;
const cached = yield* store.get("bundle.tar");
if (Option.isNone(cached)) {
yield* store.put("bundle.tar", new Uint8Array([1, 2, 3]));
}
}).pipe(Effect.provide(GitHubBlobStoreLive));
Action.run(program);The S3 backend is a function — call it with an S3BlobStoreConfig. Secret material is held as Redacted, so wrap secretAccessKey and sessionToken with Redacted.make(...); bucket, region, accessKeyId, endpoint and prefix are plain strings.
import { Effect, Redacted } from "effect";
import { Action, BlobStore, S3BlobStoreLive } from "@savvy-web/github-action-effects";
const r2 = S3BlobStoreLive({
bucket: "my-cache",
region: "auto",
endpoint: "https://<account>.r2.cloudflarestorage.com",
accessKeyId: process.env.S3_ACCESS_KEY_ID ?? "",
secretAccessKey: Redacted.make(process.env.S3_SECRET_ACCESS_KEY ?? ""),
prefix: "actions/",
});
const program = Effect.gen(function* () {
const store = yield* BlobStore;
yield* store.put("bundle.tar", new Uint8Array([1, 2, 3]));
}).pipe(Effect.provide(r2));
Action.run(program);BlobStoreTest is an in-memory backend for unit tests. BlobStoreTest.empty() creates a fresh BlobStoreTestState whose entries map you can assert against, and BlobStoreTest.layer(state) builds the layer.
import { Effect } from "effect";
import { BlobStore, BlobStoreTest } from "@savvy-web/github-action-effects";
const state = BlobStoreTest.empty();
const program = Effect.gen(function* () {
const store = yield* BlobStore;
yield* store.put("k", new Uint8Array([42]));
return yield* store.get("k");
}).pipe(Effect.provide(BlobStoreTest.layer(state)));
const result = await Effect.runPromise(program);
// result is Option.some wrapping the stored bytes
// state.entries.has("k") is trueDocumentation
- Building a GitHub Action with Effect — An end-to-end walkthrough: validated inputs, logging, a step summary and typed outputs.
- Advanced action: three-stage app — A complete pre/main/post action with GitHub App auth, cross-phase state and buffered logging.
- Services guide — A usage example for every service in the library.
- Common patterns — Dry-run mode, error accumulation, permission checks and workspace detection.
- Building a robust action — Principles and pointers: wiring, the pre/main/post pattern, dry runs, permission checks, idempotency and secret handling.
- Coming from
@actions/*— The migration map from each@actions/*package to its native ESM replacement. - Logging and error handling — The log-level model, groups, buffered output, annotations, secret masking and the error-handling boundary.
- Resilient GitHub API calls — Default-on retry,
ResilienceOptions, theRateLimiterservice and streaming pagination. - Step-buffered logging patterns — Quiet-on-success, verbose-on-failure step logging with
withStep,collapseandgroupStep. - Generating SLSA attestations — Provenance and SBOM attestations, the layer stack and idempotent recovery.
- Publishing packages with the publish chain — Pack, probe and publish a tarball, plus registry classification.
- Peer dependencies — Which packages to install and why.
- Error handling — Tagged errors,
Action.formatCauseand the[Tag] messageformat. - Architecture — The runtime layer, layer composition and the logging pipeline.
- Filesystem I/O —
IoUtil(which/findInPath) and thecp/mv/rmRF/mkdirP→FileSystemrecipe. - Testing GitHub Actions — How to test an action with in-memory test layers.
