@effected/github-actions

中文文档
0.33.2发布于 2026/11/13MITTypeScriptES Module构建来源已验证近 30 天下载 4221

The GitHub Actions runtime: env, inputs, outputs, state, logging, cache, artifacts, blob storage, OIDC and the token bridge.

原始使用文档
文档目录12 节
  1. Why @effected/github-actions
  2. Install
  3. Quick start
  4. Reading inputs
  5. The token bridge
  6. Secrets
  7. The runner's cache, artifacts and OIDC
  8. Reporting on GitHub surfaces
  9. Typed failures
  10. Testing
  11. Features
  12. License

@effected/github-actions

npm License: MIT Node.js %3E%3D24.11.0 TypeScript 7.0

The GitHub Actions runtime for Effect v4: the services an action needs to talk to the runner it is executing inside. Action.run composes the runtime, runs your program, renders a failure as an ::error:: annotation and sets the exit code. ActionInput reads workflow inputs as typed Config values — string, boolean, integer, redacted secret, multiline list, key=value pairs, or a schema-decoded JSON blob — with one absence rule shared by every accessor: unset and "" are both missing data. ActionOutputs, ActionState, ActionLogger and a fiber-local ActionEnvironment round out the runner surface, alongside ActionCache, Artifact, a metadata-carrying BlobStore (S3-compatible or the runner's own cache), OidcTokenIssuer and ToolInstaller for the heavier protocols. A reporting suite — GitHubMarkdown, ManagedDocument, CheckState and CheckDocument — covers the other direction: rendering what a run did onto a pull request comment, a check run or the job summary. No @actions/* dependency anywhere — the cache, artifact and tool-cache protocols are implemented directly against their HTTP APIs.

Pre-1.0.0. This package is part of the @effected/* kit, built on stable Effect v4 (effect ~4.1.0) and still in 0.x development. Stable Effect makes a kit 1.0.0 possible, not automatic. To keep your effect and @effect/* versions on the line the kit is built and tested against, install @effected/pnpm-plugin-effected.

Stability: unstable. This package's API surface is not yet considered complete and may change across 0.x releases. Pin an exact version — even a package marked stable before 1.0.0 can introduce a breaking change by accident, and an exact pin turns that into a type-check error rather than a runtime surprise. Full policy: release strategy.

Why @effected/github-actions

@actions/core and its siblings (@actions/cache, @actions/artifact, @actions/tool-cache) are the standard way to write an action, and each one owns its own ad hoc contract: inputs are read with getInput, which returns "" for both "unset" and "set to empty" with no way to tell them apart at the type level; outputs, masking and workflow commands are separate globals; and the cache and artifact clients bring their own HTTP stacks. This package answers the same questions as one coherent set of Effect services instead, with the absence rule stated once (Config.withDefault is the idiom for every optional input) and every mutation running through the same R-typed services a test can swap out.

It is also the one place in the kit where @effect/platform-node is a required peer, deliberately: a GitHub Action always compiles into a Node process on a GitHub-provided runner, so there is no second platform to abstract over. Everything that can go through a core contract still does — ToolInstaller downloads over HttpClient and extracts over ChildProcessSpawner, CacheKey reads over FileSystem — and the four sanctioned node: imports (node:crypto for digests, process.kill for reaping a bare pid, node:child_process for a detached spawn, and node:zlib/node:stream in the cache and artifact codecs) cover exactly what core cannot do yet.

The line against @effected/github is exact: that package talks to the GitHub API, this one talks to the runner. They meet at two seams — the token bridge (GitHubToken) and the Logger that maps Effect.log* onto workflow commands — and nothing here reads process.env.GITHUB_REPOSITORY on github's behalf.

Install

npm install @effected/github-actions effect @effect/platform-node
pnpm add @effected/github-actions effect @effect/platform-node

Requires Node.js >=24.11.0. effect v4 and @effect/platform-node are both peer dependencies.

All @effected/* packages are ESM-only: the exports maps publish only import conditions, so require() — including tools that resolve in CJS mode — fails with Node's ERR_PACKAGE_PATH_NOT_EXPORTED rather than loading a CJS build that does not exist. Import from an ES module.

Quick start

import { Action, ActionInput, ActionOutputs } from "@effected/github-actions";
import { Config, Effect } from "effect";

const program = Effect.gen(function* () {
  const name = yield* ActionInput.string("name").pipe(Config.withDefault("world"));
  const outputs = yield* ActionOutputs;
  yield* outputs.set("greeting", `Hello, ${name}!`);
});

await Action.run(program);
// exit code reflects success/failure; failures render as an ::error:: annotation

Action.run provides ActionServices (environment, logger, outputs, state, the Node platform, an HttpClient and the Digest, Mac, SecureRandom and Signature crypto services) by default. A capability with a heavier dependency — the cache, artifacts, the blob store — is one extra line: Action.run(program, { layer: ActionCache.layer }).

Step debugging works without wiring: when the runner sets RUNNER_DEBUG=1, Action.run lowers Effect's minimum log level to Debug, so Effect.logDebug calls show up as ::debug:: lines. It only ever lowers the level; pass { stepDebugLogLevel: false } to keep it as it is.

Reading inputs

Every accessor shares one absence rule: the runner writes "" for an input the workflow omitted, and this package reads that the same as unset — missing data, not an empty value. An optional input needs Config.withDefault (or Config.option) at the call site:

import { ActionInput } from "@effected/github-actions";
import { Config, Effect } from "effect";

const program = Effect.gen(function* () {
  const dryRun = yield* ActionInput.boolean("dry-run").pipe(Config.withDefault(false));
  const paths = yield* ActionInput.list("paths").pipe(Config.withDefault([]));
  return { dryRun, paths };
});
// dryRun: boolean; paths: readonly string[] — [] when the input was omitted

list accepts a JSON array, a bullet list, or comma- and newline-separated values, with full-line # comments dropped — whichever shape a workflow author reaches for first. A present but malformed value (dry-run: yes instead of a YAML 1.2 boolean) is a different class from an absent one: it fails carrying its actual and is never silently swallowed by a default.

The token bridge

GitHubToken shapes GitHub App authentication like the three-phase workflow it runs inside: mint in pre, use in main, revoke in post. Nothing survives between phases except what GITHUB_STATE carries, so an installation token — which lives about an hour — is persisted rather than held in a Scope:

import { GitHubToken } from "@effected/github-actions";
import { Effect, Redacted } from "effect";

// pre:
const pre = GitHubToken.provision({
  appId: "123456",
  privateKey: Redacted.make(process.env.APP_PRIVATE_KEY as string),
  owner: "acme",
  required: { contents: "write", pull_requests: "write" },
});

// main: bind once — layers memoize by reference
const ClientLayer = GitHubToken.clientLayer();

// post:
const post = GitHubToken.dispose();

GitHubToken.read fails typed with reason: "expired" rather than handing back a token GitHub will answer with a bare 401 — the credential that could re-mint one is the app's private key, and persisting that through a plaintext GITHUB_STATE file would trade a one-hour token for a permanent one.

Secrets

Secret is the only place in this package a Redacted value becomes a plain string, and masking and declassification are the same call — every member registers the value with the runner's ::add-mask:: filter before returning plaintext:

import { Secret } from "@effected/github-actions";
import { Effect } from "effect";

declare const token: import("effect/Redacted").Redacted<string>;

const program = Effect.gen(function* () {
  const env = yield* Secret.forChildEnv({ MY_TOKEN: token });
  // every value in `env` is already masked in the runner log
  return env;
});

The runner's cache, artifacts and OIDC

ActionCache and Artifact speak the Actions Twirp v2 protocol directly — no @actions/cache or @actions/artifact dependency, and both are excluded from ActionRuntime.layer on purpose, so a consumer that only sets an output never links @azure/storage-blob:

import { ActionCache, CacheKey } from "@effected/github-actions";
import { Effect, Option } from "effect";

const program = Effect.gen(function* () {
  const cache = yield* ActionCache;
  const key = CacheKey.of("Linux", "pnpm-store", "abc123");
  const hit = yield* cache.restore(["~/.pnpm-store"], key);
  if (Option.isNone(hit)) {
    yield* cache.save(["~/.pnpm-store"], key);
  }
});

await Action.run(program, { layer: ActionCache.layer });

CacheKey.withNamespace(segment) is the cache-bust case — a run that must match nothing an unbusted run wrote, and whose own entries must be invisible to unbusted runs:

import { CacheKey } from "@effected/github-actions";

const key = CacheKey.of("Linux", "pnpm-store", "abc123").withNamespace("bust7");
console.log(key.key, key.restoreKeys);
// bust7-Linux-pnpm-store-abc123 []

Two decisions make that safe for any segment value, with no prefix reasoning at the call site. The segment goes first, so the namespaced key shares no prefix with an unnamespaced one — a restore key is a prefix match, and folding a bust token in later leaves an ordinary run's rung prefix-matching busted entries. And the ladder is dropped, so no rung reaches outside the namespace even when the segment happens to equal an ordinary leading segment. It is a safe default rather than a prohibition: follow with withRestoreDepths for a deliberate in-namespace ladder.

CacheKey.hashFiles is byte-compatible with @actions/glob's hashFiles, and OidcTokenIssuer.claims decodes the runner's OIDC token for a SLSA provenance predicate without verifying its signature — the token arrived over TLS from the runner's own token service, so the transport is the trust boundary and nothing here branches on the claims for authorization.

ActionsProvenance.capture turns those claims into an @effected/sbom SlsaProvenance predicate, and ActionsIdentityToken.layer serves @effected/oidc's narrow IdentityToken contract, which that package's signer consumes, from the same issuer — the inversion that keeps the Actions runtime out of every consumer that only wants to emit an SBOM:

import { ActionsIdentityToken, ActionsProvenance, OidcTokenIssuer } from "@effected/github-actions";
import { SigstoreSigner } from "@effected/sbom";
import { Effect, Layer } from "effect";

const program = Effect.gen(function* () {
  const provenance = yield* ActionsProvenance.capture();
  return provenance.runDetails.builder.id;
});
// the builder id, derived from the workflow ref in the runner's own claims

const SigningLayer = SigstoreSigner.layer.pipe(
  Layer.provide(ActionsIdentityToken.layer),
  Layer.provide(OidcTokenIssuer.layer),
);

An OidcTokenError passes through capture untouched: whether an unattested publish is allowed is the action's policy, not this package's. reason: "unavailable" almost always means the workflow is missing permissions: id-token: write.

Reporting on GitHub surfaces

The other half of an action's job is saying what happened — on a pull request comment, a check-run summary or the job summary. GitHubMarkdown writes those bodies. Every member takes pre-rendered markdown and returns a string, but the structure around it goes through @effected/markdown's serializer rather than string joining, so a cell carrying >=1 || <2 escapes its pipes instead of shifting every column after it. tableFor goes further and takes the table's whole shape from a row schema:

import { GitHubMarkdown } from "@effected/github-actions";
import { Schema } from "effect";

const Row = Schema.Struct({
  name: Schema.String.annotate({ title: "Check" }),
  detail: Schema.String,
});

console.log(GitHubMarkdown.tableFor(Row).render([{ name: "build", detail: "clean" }]));
// | Check | detail |
// | --- | --- |
// | build | clean |

Column order is field declaration order, each header is the field's title annotation, and each cell is the field's encoded form — so a typed field projects through its own codec, and a row cannot transpose columns because it is an object rather than a positional array.

ManagedDocument owns named regions inside text a human also edits: a sentinel HTML comment identifies the document, marker comments delimit each region, and everything outside them survives byte-for-byte. Regions are replaced from current state rather than appended, which is what makes a sticky comment safe to re-render. CheckDocument puts a debounced reconciler on top — report a check as its state changes, and a background fiber projects the registry onto the document and writes it through a narrow sink:

import { CheckDocument, CheckReport } from "@effected/github-actions";
import { Effect } from "effect";

const program = Effect.gen(function* () {
  const doc = yield* CheckDocument;
  yield* doc.report("build", CheckReport.make({ state: "running" }));
  yield* doc.report("build", CheckReport.make({ state: "pass", outcome: "clean" }));
  return yield* doc.flush;
});
// "written"   — one write carrying the final state
// "unchanged" — the render was byte-identical to the last one, so nothing was sent
// "stale"     — a newer run owns the document; this pass was dropped (see below)

The debounce is trailing with a max-wait, so a burst of reports coalesces into one write carrying the final state while a steady stream still surfaces progress.

A region can also carry metadata: withRegions takes [key, content, meta] triples, the pairs ride the region's own marker, and entry(key) reads them back verbatim. Metadata survives a writer that knows nothing about it — a region a call does not name keeps its content and its metadata — and never takes part in addressing a region, so changing it updates that region in place rather than growing a second one.

CheckDocument uses exactly that to survive two runs writing one document — a re-run overlapping the delayed run it replaced. Give the layer this run's stamp and a sink that can read as well as write, and each pass reconciles against the live document instead of a private shadow of what this process last wrote:

import { CheckDocument } from "@effected/github-actions";
import type { Effect } from "effect";

declare const startedAt: string;
declare const runId: string;
declare const readComment: Effect.Effect<string | undefined>;
declare const writeComment: (text: string) => Effect.Effect<void>;

const layer = CheckDocument.layer({
  namespace: "release-bot",
  key: "checks",
  render: (checks) => [...checks].map(([name, report]) => [name, report.state] as const),
  sink: { write: writeComment, read: readComment },
  stamp: { at: startedAt, runId }, // a per-run constant — mint it once at startup
});

Every region the run writes then carries its at and runId, and a pass whose stamp is strictly older than the newest stamp already on the document is dropped rather than written — flush answers "stale" and the reconciler logs the drop once, at the transition. Equal stamps pass, so a run always may refine its own regions. Keeping the stamp constant is what preserves write suppression: the same run rendering the same state still produces byte-identical text.

CheckState is the vocabulary both of them speak, and it is deliberately wider than GitHub's conclusion set — running is a state rather than the absence of one, and user_interaction_required names a pipeline waiting on a human. projectCheckState maps each onto the wire form GitHub wants, as a status plus a conclusion exactly when the status is completed.

Typed failures

Four failure types in this package are tagged unions of one error class per reason rather than a single class carrying a reason field: ActionOutputError, BlobEnvelopeError, CacheKeyError and DetachedProcessError. Every one of those names still exists and still means the same set of outcomes, so no signature changed — each is now a type alias over its members:

UnionMembers
ActionOutputErrorRunnerFileUnavailableError, RunnerFileWriteError, InvalidOutputNameError, OutputEncodeError, DetachedOutputError
BlobEnvelopeErrorNotABlobEnvelopeError, TruncatedBlobEnvelopeError, UnsupportedBlobEnvelopeVersionError, BlobMetadataDecodeError, BlobMetadataEncodeError
CacheKeyErrorCacheKeyReadError, CacheKeyBadPatternError
DetachedProcessErrorDetachedLogUnavailableError, DetachedSpawnFailedError, InvalidPidError, DetachedSignalFailedError, DetachedNotReadyError

Breaking, and how to migrate. Code that branched on error.reason matches on _tag instead, which is what buys the split: one failure can be handled on its own with Effect.catchTag, and the rest stay in the error channel rather than being caught by a handler that only meant to answer one of them.

import { CacheKey } from "@effected/github-actions";
import { Effect, Option } from "effect";

const digest = CacheKey.hashMatching({ workspace: ".", patterns: ["**/pnpm-lock.yaml"] }).pipe(
  Effect.catchTag("CacheKeyBadPatternError", () => Effect.succeed(Option.none<string>())),
);
// Effect<Option<string>, CacheKeyReadError, FileSystem | Path>
// a bad pattern becomes "no digest"; a read failure stays in the error channel

Testing

Every service ships makeTest(overrides?) and layerTest(overrides?), with unstubbed members dying loudly and naming themselves — three recorded exceptions state why they default instead: ActionEnvironment.makeTest seeds the twelve GITHUB_*/RUNNER_* variables, ActionLogger.makeTest defaults to silent, and DryRun.makeTest defaults to rehearsing, the safe direction. ActionEnvironment.makeTest/layerTest also take the webhook event payload as an optional second argument, serving it directly instead of routing it through a GITHUB_EVENT_PATH filesystem read the stubbed filesystem could never satisfy:

import { ActionEnvironment } from "@effected/github-actions";

const layer = ActionEnvironment.layerTest(
  { GITHUB_EVENT_NAME: "pull_request" },
  { pull_request: { number: 42 } },
);
import { ActionOutputs } from "@effected/github-actions";
import { Effect } from "effect";

const TestOutputs = ActionOutputs.layerTest({
  set: () => Effect.void,
});

OidcTokenIssuer.layerFor(claims) returns a real decodable unsigned JWT built from the same claims claims() reports, and BlobStore.layerMemory runs the real envelope framing — both exist because a synthetic double previously made the provenance path structurally untestable.

Features

  • Action.run / ActionRuntime.layer — the entry point and default runtime: environment, logger, outputs, state, the Node platform and an HttpClient.
  • ActionInput — typed input accessors (string, boolean, integer, redacted, lines, list, pairs, schema) sharing one absence rule, plus the INPUT_-aware ConfigProvider Action.run installs by default. pairs always rejects an empty key (=value, whose damage lands far from the typo) and takes requireValue to reject an empty value too. ActionInput.recording(env) journals which inputs a program reads, for ActionManifest.diff.
  • ActionManifest — action.yml decoded (read, decode, decodeResult): inputs with defaults, outputs, branding and the node, composite or docker runs block. runnerInputs builds the input environment a real runner builds for a node or docker action (every declared input under its INPUT_ variable, with the supplied value, its manifest default or "", plus any undeclared supplied name passed through as the runner does) as the baseline for input tests, and diff reports inputs and outputs the code and the manifest disagree on.
  • ActionOutputs — step outputs, JSON outputs, the job summary, exported variables, PATH additions, failure annotations and log masking.
  • ActionState / ActionEnvironment — persisted pre/main/post state, and the fiber-local GITHUB_*/RUNNER_* context read once from process.env.
  • ActionLogger — collapsible groups (nesting-aware: the runner cannot nest groups, so a group opened inside another writes a ▸ <name> header line instead of a second group command), buffered step transcripts (withBuffer({ onSuccess: "discard" }) drops a green step's transcript entirely, while a failure, defect or interruption still flushes it), withStep(name, effect, options?) for the same buffering plus one summary line on success and a ❌ <name> header above the transcript on failure, ::notice:: annotations, and the Logger that renders every Effect.log* as a workflow command.
  • DryRun — the rehearsal guard every mutation goes through, driven by the dry-run input by default.
  • Secret — the one declassification seam between a Redacted value and a plain string, masking before it returns plaintext.
  • GitHubToken — the App-token lifecycle shaped like the pre/main/post workflow: mint, verify scopes, persist, read, revoke.
  • ActionCache / Artifact — the runner's cache and artifact protocols over Twirp v2, excluded from the default runtime so a consumer that skips them never links Azure.
  • CacheKey — cache keys and their restore-key ladder, withNamespace(segment) for a bust token or a shared-runner prefix, plus hashFiles/matchingFiles/hashMatching byte-compatible with @actions/glob.
  • OidcTokenIssuer — the runner's OIDC token service, with unverified claim decoding for provenance predicates, and a recording(claims) double that journals the audiences asked for.
  • ActionsRegistryCredential — workload({ audience, prefix }) mints an identity token for a registry, masks the bare and the prefixed value, and returns @effected/npm's publish-only WorkloadCredential for PackagePublish.setupAuth.
  • ActionsIdentityToken / ActionsProvenance — the @effected/sbom seam: layer serves @effected/oidc's IdentityToken contract from the runner's issuer, and capture builds a SlsaProvenance predicate out of the runner's OIDC claims.
  • GitHubMarkdown — the escaping-safe writer for GitHub surfaces: table, tableFor(schema), heading, link, code, codeBlock, list, details and raw, and the package's only importer of @effected/markdown.
  • ManagedDocument — marker-delimited named regions in text a human also edits, for a sticky comment or a managed pull request description; content outside the regions survives byte-for-byte, and each region can carry marker metadata that round-trips through entry(key) without ever addressing the region.
  • CheckState / projectCheckState — the kit's check vocabulary, wider than GitHub's conclusions, and its projection onto the check-run wire form.
  • CheckDocument — the debounced check-to-document reconciler: report states as they change, and one write lands carrying the final one. stamp plus a readable sink lets two runs share one document, dropping a strictly older run's pass instead of clobbering the newer one; flush reports written, unchanged or stale.
  • ToolInstaller — download, extract and cache a toolchain in the runner's tool cache, with no @actions/tool-cache dependency; ToolInstallerError always names its subject.
  • BlobStore — durable get/put/has over StoredBlob<A> values (bytes plus caller-schema metadata; the type was named Blob<A> before, which collided with the global), backed by layerS3 (SigV4-signed through @effected/crypto, so it needs core Digest.Digest (effect/crypto) in R, which ActionRuntime.layer provides; no @aws-sdk/* dependency) or GitHubCacheBlobStore.layer (the runner's own cache); layerMemory runs the real envelope framing for tests.

License

MIT

版本与发布记录

@effected/github-actions 共发布 63 个版本,首次发布于 2026/07/26;过去 12 个月发布了 63 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.33.2latest2026/11/13今天
0.33.12026/11/112 天前
0.33.02026/11/102 天前
0.32.32026/11/103 天前
0.32.22026/11/094 天前
0.32.12026/11/094 天前

其他发布通道:next → 0.17.0-next-20260927071821。

关于 @effected/github-actions 的常见问题

@effected/github-actions 是什么?

The GitHub Actions runtime: env, inputs, outputs, state, logging, cache, artifacts, blob storage, OIDC and the token bridge.

如何安装 @effected/github-actions?

在项目目录运行 npm install @effected/github-actions,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:effect(~4.1.0)、@effect/platform-node(~4.1.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@effected/github-actions 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@effected/github-actions 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@effected/github-actions 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=24.11.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@effected/github-actions 可以商用吗?

@effected/github-actions 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@effected/github-actions 的下载量有多少?

根据 npm 官方下载统计,@effected/github-actions 在 2026/10/13 – 2026/11/11 这 30 天内被下载 4,221 次,日均约 141 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@effected/github-actions 还在维护吗?

最新版本 0.33.2 发布于 2026/11/13(今天);至今共发布 63 个版本,其中过去 12 个月发布了 63 个;首次发布于 2026/07/26。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。