From 4dae7bb022c84428ac288450e9bde7ad45bd0a8e Mon Sep 17 00:00:00 2001 From: "C. Spencer Beggs" Date: Thu, 21 May 2026 18:34:04 -0400 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20comprehensive=202.0=20documentation?= =?UTF-8?q?=20=E2=80=94=20new=20guides,=20accuracy=20and=20toolkit=20parit?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds four new guides (best practices, toolkit parity, logging and error handling, resilient GitHub API calls), documents the full 2.0 surface (Glob, IoUtil, Artifact, ActionInput, paginateStream, typed event payload, PathUtils, notice), corrects stale API descriptions (fromEnv is now a function, fromToken is Redacted-only, fromApp is a scoped layer), verifies the Upgrading to 2.0 migration note, and renumbers docs to a contiguous 01-16 reading order. Signed-off-by: C. Spencer Beggs --- .changeset/ws1-foundation-deps-ci.md | 5 +- .changeset/ws4-io-helpers.md | 2 +- .changeset/ws6-documentation.md | 31 ++ README.md | 24 +- docs/01-example-action.md | 10 +- docs/02-advanced-action.md | 6 +- docs/03-services.md | 465 +++++++++++++++++- docs/04-patterns.md | 78 +-- docs/05-best-practices.md | 159 ++++++ docs/06-toolkit-parity.md | 263 ++++++++++ docs/07-logging-and-error-handling.md | 168 +++++++ docs/08-resilient-github-api.md | 152 ++++++ docs/09-step-logging.md | 96 ++++ docs/10-slsa-attestations.md | 167 +++++++ docs/11-publishing.md | 141 ++++++ ...ependencies.md => 12-peer-dependencies.md} | 0 ...error-handling.md => 13-error-handling.md} | 38 +- ...{07-architecture.md => 14-architecture.md} | 136 ++++- ...9-filesystem-io.md => 15-filesystem-io.md} | 34 +- docs/{08-testing.md => 16-testing.md} | 0 docs/README.md | 29 +- src/utils/IoUtil.ts | 2 +- 22 files changed, 1845 insertions(+), 161 deletions(-) create mode 100644 .changeset/ws6-documentation.md create mode 100644 docs/05-best-practices.md create mode 100644 docs/06-toolkit-parity.md create mode 100644 docs/07-logging-and-error-handling.md create mode 100644 docs/08-resilient-github-api.md create mode 100644 docs/09-step-logging.md create mode 100644 docs/10-slsa-attestations.md create mode 100644 docs/11-publishing.md rename docs/{05-peer-dependencies.md => 12-peer-dependencies.md} (100%) rename docs/{06-error-handling.md => 13-error-handling.md} (71%) rename docs/{07-architecture.md => 14-architecture.md} (61%) rename docs/{09-filesystem-io.md => 15-filesystem-io.md} (69%) rename docs/{08-testing.md => 16-testing.md} (100%) diff --git a/.changeset/ws1-foundation-deps-ci.md b/.changeset/ws1-foundation-deps-ci.md index fa153ab..a9d26f3 100644 --- a/.changeset/ws1-foundation-deps-ci.md +++ b/.changeset/ws1-foundation-deps-ci.md @@ -11,9 +11,8 @@ peers but are never imported anywhere in the library. They are now removed from `peerDependencies` and `peerDependenciesMeta`. Consumers only need `effect`, `@effect/platform`, and `@effect/platform-node`; the dropped packages still resolve transitively through `@effect/platform-node` if any code path needs -them. The install docs (`docs/README.md`, `docs/01-example-action.md`, -`docs/05-peer-dependencies.md`) are corrected to match the README's already- -accurate three-peer list. +them. The install docs are corrected to match the README's already-accurate +three-peer list. ### CI now runs the full production build on every pull request diff --git a/.changeset/ws4-io-helpers.md b/.changeset/ws4-io-helpers.md index 16bb155..2f3fa2e 100644 --- a/.changeset/ws4-io-helpers.md +++ b/.changeset/ws4-io-helpers.md @@ -12,5 +12,5 @@ match. Honors `PATHEXT` on Windows and POSIX execute-bit checks. Reads `FileSystem` from context (provided by `ActionsRuntime.Default`). - `cp`/`mv`/`rmRF`/`mkdirP` are documented as direct `@effect/platform` - `FileSystem` calls (new `docs/09-filesystem-io.md` recipe) rather than new + `FileSystem` calls (a documented filesystem I/O recipe) rather than new wrappers, since `FileSystem` is already in context everywhere. diff --git a/.changeset/ws6-documentation.md b/.changeset/ws6-documentation.md new file mode 100644 index 0000000..790d66a --- /dev/null +++ b/.changeset/ws6-documentation.md @@ -0,0 +1,31 @@ +--- +"@savvy-web/github-action-effects": minor +--- + +## Documentation + +Comprehensive documentation pass covering the 2.0 release surface. + +**Accuracy against the final 2.0 API:** corrects stale descriptions throughout +`docs/` — `GitHubClientLive.fromEnv()` is now a function; `fromToken` takes a +`Redacted`; `fromApp` is a scoped layer that revokes its token on scope +close and requires `HttpClient.HttpClient`. The "Upgrading to 2.0" migration note +and `@actions/*` substitution map are re-verified against the merged code. + +**New services documented:** `Glob` (glob patterns + SHA-256 `hashFiles`), +`IoUtil` (`which`/`whichOrFail`/`findInPath`), `Artifact` (upload/list/get/ +download/delete, with the "must run inside a JS action" env constraint), +`ActionInput` (YAML 1.2 Core Schema `boolean`, `multiline`), the typed event +payload (`ActionEnvironment.payload`, `repo`, `issue`, `isDebug` + +`WebhookPayload`), `PathUtils`, `ActionLogger.notice`, and the +`WorkflowCommand` notice/stop-commands/echo helpers. +`GitHubClient.paginateStream` and `GithubMarkdown.image`/`quote` are also +covered. + +**Four new guides:** "Building a robust action" (best practices), "Coming from +`@actions/*`" (toolkit-parity walkthrough), "Logging and error handling", and +"Resilient GitHub API calls" (retry, rate-limit awareness, streaming pagination). + +**Structure:** three existing guides (SLSA attestations, publishing, step-buffered +logging) were already present and are preserved; `docs/` is renumbered to a +contiguous 01-16 reading order with the new guides in the guides cluster. diff --git a/README.md b/README.md index 9e0e8ff..c6c7b9f 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,9 @@ Action.run(program); `GitHubClientLive` builds a `GitHubClient` layer one of three ways: -- `GitHubClientLive.fromEnv` — reads the ambient `process.env.GITHUB_TOKEN`, the repo-scoped workflow token. -- `GitHubClientLive.fromToken(token)` — an explicit token, plain `string` or `Redacted`, with no `process.env` dependency. -- `GitHubClientLive.fromApp({ clientId, privateKey, installationId? })` — generates a fresh installation token from GitHub App credentials. +- `GitHubClientLive.fromEnv()` — reads the ambient `process.env.GITHUB_TOKEN`, the repo-scoped workflow token. It is a function; call it with no arguments. +- `GitHubClientLive.fromToken(token)` — an explicit token with no `process.env` dependency. The token is a `Redacted` — wrap a bare string with `Redacted.make(...)`. +- `GitHubClientLive.fromApp({ clientId, privateKey, installationId? })` — mints an installation token from GitHub App credentials, with `privateKey` as a `Redacted`. It is a scoped layer that revokes the token on scope close and requires `HttpClient.HttpClient`; wrap a bare `Effect.provide` in `Effect.scoped`. ```typescript import { Effect } from "effect"; @@ -59,7 +59,7 @@ const program = Effect.gen(function* () { return yield* client.rest("issues.list", (octokit) => octokit.rest.issues.listForRepo({ owner, repo }), ); -}).pipe(Effect.provide(GitHubClientLive.fromEnv)); +}).pipe(Effect.provide(GitHubClientLive.fromEnv())); Action.run(program); ``` @@ -125,10 +125,18 @@ Two additional accessors are available in any phase after `provision`: - [Advanced action: three-stage app](./docs/02-advanced-action.md) — A complete pre/main/post action with GitHub App auth, cross-phase state and buffered logging. - [Services guide](./docs/03-services.md) — A usage example for every service in the library. - [Common patterns](./docs/04-patterns.md) — Dry-run mode, error accumulation, permission checks and workspace detection. -- [Peer dependencies](./docs/05-peer-dependencies.md) — Which packages to install and why. -- [Error handling](./docs/06-error-handling.md) — Tagged errors, `Action.formatCause` and the `[Tag] message` format. -- [Architecture](./docs/07-architecture.md) — The runtime layer, layer composition and the logging pipeline. -- [Testing GitHub Actions](./docs/08-testing.md) — How to test an action with in-memory test layers. +- [Building a robust action](./docs/05-best-practices.md) — Principles and pointers: wiring, the pre/main/post pattern, dry runs, permission checks, idempotency and secret handling. +- [Coming from `@actions/*`](./docs/06-toolkit-parity.md) — The migration map from each `@actions/*` package to its native ESM replacement. +- [Logging and error handling](./docs/07-logging-and-error-handling.md) — The log-level model, groups, buffered output, annotations, secret masking and the error-handling boundary. +- [Resilient GitHub API calls](./docs/08-resilient-github-api.md) — Default-on retry, `ResilienceOptions`, the `RateLimiter` service and streaming pagination. +- [Step-buffered logging patterns](./docs/09-step-logging.md) — Quiet-on-success, verbose-on-failure step logging with `withStep`, `collapse` and `groupStep`. +- [Generating SLSA attestations](./docs/10-slsa-attestations.md) — Provenance and SBOM attestations, the layer stack and idempotent recovery. +- [Publishing packages with the publish chain](./docs/11-publishing.md) — Pack, probe and publish a tarball, plus registry classification. +- [Peer dependencies](./docs/12-peer-dependencies.md) — Which packages to install and why. +- [Error handling](./docs/13-error-handling.md) — Tagged errors, `Action.formatCause` and the `[Tag] message` format. +- [Architecture](./docs/14-architecture.md) — The runtime layer, layer composition and the logging pipeline. +- [Filesystem I/O](./docs/15-filesystem-io.md) — `IoUtil` (`which`/`findInPath`) and the `cp`/`mv`/`rmRF`/`mkdirP` → `FileSystem` recipe. +- [Testing GitHub Actions](./docs/16-testing.md) — How to test an action with in-memory test layers. ## License diff --git a/docs/01-example-action.md b/docs/01-example-action.md index 4075455..626e366 100644 --- a/docs/01-example-action.md +++ b/docs/01-example-action.md @@ -202,9 +202,9 @@ const result = yield* someEffect.pipe( ) ``` -The error types — `ActionOutputError`, `ActionStateError`, `GitHubClientError` and so on — are all `Data.TaggedError` instances. See [architecture](./07-architecture.md#error-types) for their fields. +The error types — `ActionOutputError`, `ActionStateError`, `GitHubClientError` and so on — are all `Data.TaggedError` instances. See [architecture](./14-architecture.md#error-types) 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](./06-error-handling.md) for details. +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](./13-error-handling.md) for details. ## Testing @@ -247,11 +247,11 @@ describe("package checker action", () => { }) ``` -See [Testing GitHub Actions](./08-testing.md) for the full API of each test layer and patterns for testing logging, state and annotations. +See [Testing GitHub Actions](./16-testing.md) for the full API of each test layer and patterns for testing logging, state and annotations. ## Next steps - [Services guide](./03-services.md) — detailed guide for every service -- [Testing GitHub Actions](./08-testing.md) — test every service with in-memory layers -- [Architecture](./07-architecture.md) — runtime layer, layer composition and the logging pipeline +- [Testing GitHub Actions](./16-testing.md) — test every service with in-memory layers +- [Architecture](./14-architecture.md) — runtime layer, layer composition and the logging pipeline - [Common patterns](./04-patterns.md) — dry-run mode, error accumulation, permission checking and more diff --git a/docs/02-advanced-action.md b/docs/02-advanced-action.md index fe84ee3..4fe07d3 100644 --- a/docs/02-advanced-action.md +++ b/docs/02-advanced-action.md @@ -434,11 +434,11 @@ describe("post phase", () => { }) ``` -See [Testing GitHub Actions](./08-testing.md) for the complete test layer API and more patterns. +See [Testing GitHub Actions](./16-testing.md) for the complete test layer API and more patterns. ## Next steps - [Services guide](./03-services.md) — detailed usage for each service -- [Testing GitHub Actions](./08-testing.md) — test multi-phase actions with in-memory layers +- [Testing GitHub Actions](./16-testing.md) — test multi-phase actions with in-memory layers - [Common patterns](./04-patterns.md) — dry-run, error accumulation and more -- [Peer dependencies](./05-peer-dependencies.md) — which packages to install +- [Peer dependencies](./12-peer-dependencies.md) — which packages to install diff --git a/docs/03-services.md b/docs/03-services.md index 4dba5f7..017a236 100644 --- a/docs/03-services.md +++ b/docs/03-services.md @@ -1,6 +1,6 @@ # Services guide -This guide walks through each service in `@savvy-web/github-action-effects` with a usage example. For architecture and layer composition, see [architecture](./07-architecture.md). For testing, see [testing](./08-testing.md). +This guide walks through each service in `@savvy-web/github-action-effects` with a usage example. For architecture and layer composition, see [architecture](./14-architecture.md). For testing, see [testing](./16-testing.md). ## Inputs via Config API @@ -28,13 +28,33 @@ const program = Effect.gen(function* () { }) ``` +### ActionInput + +`ActionInput` is a namespace of `Config` combinators for GitHub-faithful input parsing. They read through the same `ActionsConfigProvider`, so they compose with `Config.withDefault`, `Config.option` and the rest. + +```typescript +import { Config, Effect } from "effect" +import { ActionInput } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + // YAML 1.2 Core Schema boolean — accepts ONLY true/True/TRUE and + // false/False/FALSE, matching @actions/core.getBooleanInput + const dryRun = yield* ActionInput.boolean("dry-run").pipe(Config.withDefault(false)) + + // Multiline input: split on \n, drop empty lines, trim each line + const paths = yield* ActionInput.multiline("paths").pipe(Config.withDefault([])) +}) +``` + +Prefer `ActionInput.boolean` over Effect's `Config.boolean` for GitHub parity. `Config.boolean` also accepts JS-flavored truthy values like `yes`/`on`/`1` (which GitHub's own composite-action runtime rejects) and rejects `True` (which GitHub accepts). `ActionInput.boolean` fails with `ConfigError.InvalidData` on anything outside the YAML 1.2 Core Schema set. + ## Core services These services are provided automatically by `ActionsRuntime.Default` and `Action.run`. ### ActionLogger -`ActionLogger` adds two operations on top of the built-in Effect logger: collapsible log groups and buffer-on-failure logging. It has exactly two methods, `group` and `withBuffer`. +`ActionLogger` adds three operations on top of the built-in Effect logger: collapsible log groups, buffer-on-failure logging and `::notice::` annotations. Its methods are `group`, `withBuffer` and `notice`. ```typescript import { Effect } from "effect" @@ -57,10 +77,15 @@ const program = Effect.gen(function* () { // If this succeeds, buffered logs are discarded // If this fails, buffered logs flush for debugging })) + + // Notice annotation — there is no Effect log level between Info and Warning, + // so notice is its own method. Optional properties pin it to a file/line. + yield* logger.notice("Cache restored from a previous run") + yield* logger.notice("Generated file is stale", { file: "dist/index.js", startLine: 1 }) }) ``` -Workflow annotations are not a method on `ActionLogger`. Log through Effect at warning or error level instead — the `ActionsLogger` installed by `ActionsRuntime.Default` maps `Effect.logWarning` to a `::warning::` command and `Effect.logError` to a `::error::` command. Log annotations such as `file` and `line` become command properties, so an annotation lands inline on the PR diff. +`notice` mirrors `@actions/core.notice` and emits a `::notice::` workflow command. `Effect.logInfo` writes plain stdout, so a notice annotation needs this dedicated path. Workflow warning and error annotations, by contrast, are not methods on `ActionLogger`. Log through Effect at warning or error level instead — the `ActionsLogger` installed by `ActionsRuntime.Default` maps `Effect.logWarning` to a `::warning::` command and `Effect.logError` to a `::error::` command. Log annotations such as `file` and `line` become command properties, so an annotation lands inline on the PR diff. ```typescript import { Effect } from "effect" @@ -165,9 +190,37 @@ const program = Effect.gen(function* () { // Structured runner context (all RUNNER_* vars, validated) const runner = yield* env.runner + + // True when RUNNER_DEBUG === "1" (mirrors @actions/core.isDebug) + const debugging = yield* env.isDebug +}) +``` + +Beyond the raw and structured contexts, `ActionEnvironment` exposes the parsed webhook event the way `@actions/github`'s `context` does. These three accessors read `GITHUB_EVENT_PATH` and so require `FileSystem` in context (`ActionsRuntime.Default` provides it): + +```typescript +import { Effect } from "effect" +import { ActionEnvironment } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const env = yield* ActionEnvironment + + // Parsed GITHUB_EVENT_PATH payload, schema-decoded into a WebhookPayload. + // Common fields are typed; unknown keys are preserved. Empty {} when the + // event file is absent (matching @actions/github). + const payload = yield* env.payload + const prTitle = payload.pull_request?.number + + // { owner, repo } from GITHUB_REPOSITORY, falling back to payload.repository + const { owner, repo } = yield* env.repo + + // { owner, repo, number } where number resolves from issue ?? pull_request ?? top-level + const { number } = yield* env.issue }) ``` +`WebhookPayload` is also exported as a value (the Effect Schema) and a type, so you can decode an event body yourself or annotate a handler. Decoding preserves unknown keys, mirroring the toolkit's open `[key: string]: any` payload shape. + ## GitHub API services These services use `@octokit/rest` directly (a regular dependency). @@ -188,24 +241,74 @@ const program = Effect.gen(function* () { octokit.repos.getLatestRelease({ owner: "org", repo: "repo" }) ) - // Paginated REST call + // Paginated REST call — eager, collects every page into one array const issues = yield* client.paginate("listIssues", (octokit, page, perPage) => octokit.issues.listForRepo({ owner: "org", repo: "repo", page, per_page: perPage }) ) // Repository context const { owner, repo } = yield* client.repo -}).pipe(Effect.provide(GitHubClientLive.fromEnv)) +}).pipe(Effect.provide(GitHubClientLive.fromEnv())) ``` -`GitHubClientLive` is a namespace of three layer constructors, not a bare layer: +`GitHubClientLive` is a namespace of three layer constructors, not a bare layer. All three are functions: + +- `GitHubClientLive.fromEnv()` — a `Layer` that reads the ambient `process.env.GITHUB_TOKEN`, the repo-scoped workflow token. Call it with no arguments, or pass `ResilienceOptions` to tune retries. +- `GitHubClientLive.fromToken(token)` — a `Layer` built from an explicit token with no `process.env` dependency. The token must be a `Redacted` — wrap a bare string with `Redacted.make(...)` at the call site. +- `GitHubClientLive.fromApp({ clientId, privateKey, installationId? })` — a `Layer` that mints an installation token from GitHub App credentials. `privateKey` is a `Redacted`. It is a scoped layer: the minted token is revoked on scope close, so a bare `Effect.provide` must be wrapped in `Effect.scoped` (the `ActionsRuntime.Default` / `Action.run` path already scopes for you). It composes `GitHubAppLive` and `OctokitAuthAppLive` internally, leaving only `HttpClient.HttpClient` to provide — use `FetchHttpClient.layer` or `ActionsRuntime.Default`. -- `GitHubClientLive.fromEnv` — a `Layer` that reads the ambient `process.env.GITHUB_TOKEN`, the repo-scoped workflow token. -- `GitHubClientLive.fromToken(token)` — a `Layer` built from an explicit token, a plain `string` or a `Redacted`, with no `process.env` dependency. -- `GitHubClientLive.fromApp({ clientId, privateKey, installationId? })` — a `Layer` that generates a fresh installation token from GitHub App credentials each time the layer is built. +```typescript +import { Effect, Redacted } from "effect" +import { FetchHttpClient } from "@effect/platform" +import { GitHubClient, GitHubClientLive } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const client = yield* GitHubClient + const { owner, repo } = yield* client.repo +}).pipe( + Effect.provide(GitHubClientLive.fromToken(Redacted.make(process.env.MY_TOKEN ?? ""))), +) + +// fromApp is scoped — wrap in Effect.scoped and provide an HttpClient. +const appProgram = Effect.gen(function* () { + const client = yield* GitHubClient + const { owner, repo } = yield* client.repo +}).pipe( + Effect.provide( + GitHubClientLive.fromApp({ clientId, privateKey: Redacted.make(pem) }), + ), + Effect.provide(FetchHttpClient.layer), + Effect.scoped, +) +``` For the pre/main/post pattern where one token is shared across phases, use the [`GitHubToken`](#githubtoken) namespace instead of `fromApp`. +#### Streaming pagination + +`paginate` collects every page into one array before it returns. `paginateStream` instead yields a `Stream` of items one page at a time, so a consumer can stop early with `Stream.takeWhile` or `Stream.take` without fetching or buffering the rest. Prefer it for large or early-terminating scans. + +```typescript +import { Effect, Stream } from "effect" +import { GitHubClient, GitHubClientLive } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const client = yield* GitHubClient + + // Walk issues newest-first, stopping at the first one closed before a cutoff. + const recent = yield* client.paginateStream<{ number: number; closed_at: string | null }>( + "listIssues", + (octokit, page, perPage) => + octokit.issues.listForRepo({ owner: "org", repo: "repo", state: "all", page, per_page: perPage }), + ).pipe( + Stream.takeWhile((issue) => issue.closed_at === null || issue.closed_at > "2026-01-01"), + Stream.runCollect, + ) + yield* Effect.log(`scanned ${recent.length} recent issues`) + // scanned recent issues (depends on the repo) +}).pipe(Effect.provide(GitHubClientLive.fromEnv())) +``` + ### GitHubRelease Create and manage GitHub releases. @@ -399,27 +502,37 @@ const program = Effect.gen(function* () { GitHub App authentication: generate, use and revoke installation tokens. `GitHubAppLive` requires an `OctokitAuthApp` layer, so compose it with `OctokitAuthAppLive`. +`GitHubAppLive` also requires `HttpClient.HttpClient` for its API calls. Compose `OctokitAuthAppLive` underneath and provide an HTTP client — `FetchHttpClient.layer` or `ActionsRuntime.Default`. The secret-bearing parameters (`privateKey` and any token argument) are `Redacted`; wrap raw values with `Redacted.make`. + ```typescript -import { Effect, Layer } from "effect" +import { Effect, Layer, Redacted } from "effect" +import { FetchHttpClient } from "@effect/platform" import { GitHubApp, GitHubAppLive, OctokitAuthAppLive, } from "@savvy-web/github-action-effects" -const appLayer = Layer.provide(GitHubAppLive, OctokitAuthAppLive) +const appLayer = Layer.provide( + Layer.provide(GitHubAppLive, OctokitAuthAppLive), + FetchHttpClient.layer, +) const program = Effect.gen(function* () { const app = yield* GitHubApp + // privateKey is a Redacted + const privateKey = Redacted.make(pem) + // Generate an installation token explicitly const installation = yield* app.generateToken(clientId, privateKey) - // installation.token, installation.expiresAt, installation.installationId + // installation.expiresAt, installation.installationId + // installation.token is a Redacted — unwrap with Redacted.value to use it yield* Effect.log(`Generated token for installation ${installation.installationId}`) - // ... use installation.token for API calls ... + // ... unwrap installation.token at the API boundary only ... - // Revoke it when done + // Revoke it when done — revokeToken takes the Redacted token directly yield* app.revokeToken(installation.token) // Resolve the App's public identity (slug, bot user ID, display name). @@ -439,10 +552,11 @@ const program = Effect.gen(function* () { // fallback.name → "github-actions[bot]" // fallback.email → "41898282+github-actions[bot]@users.noreply.github.com" - // Or use the bracket form: the callback receives the bare token string, + // Or use the bracket form: the callback receives the Redacted token, // and the token is always revoked afterwards, even on failure yield* app.withToken(clientId, privateKey, (token) => Effect.gen(function* () { + // token is Redacted — unwrap with Redacted.value at the wire boundary yield* Effect.log("Using installation token for API calls") }) ) @@ -469,22 +583,91 @@ const program = Effect.gen(function* () { }) ``` +### GitHubContent + +Read a repository file's decoded UTF-8 contents at a ref. `ref` is optional; omitting it uses the default branch. Reading a path that is not a file (missing, a directory or a submodule) fails with `GitHubContentError`. + +```typescript +import { Effect } from "effect" +import { GitHubContent, GitHubContentLive } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const content = yield* GitHubContent + + const pkgJson = yield* content.getFile("package.json") + const atRef = yield* content.getFile("package.json", "v1.2.3") + // both return the file as a decoded string + const parsed = JSON.parse(pkgJson) as { version: string } + yield* Effect.log(`version: ${parsed.version}`) +}) +``` + +### GitHubCommit + +Read the GitHub commit graph through the REST API. `get(ref)` returns a `CommitDetail` (sha, message, author, parents); `list(ref)` returns paginated `CommitSummary` entries; `compare(base, head)` returns the commits and changed files between two refs. + +```typescript +import { Effect } from "effect" +import { GitHubCommit, GitHubCommitLive } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const commits = yield* GitHubCommit + + const head = yield* commits.get("main") + // head: { sha, message, author, parents: [{ sha }, ...] } + yield* Effect.log(`${head.sha.slice(0, 7)} ${head.message}`) + + const diff = yield* commits.compare("v1.0.0", "main") + // diff.commits: CommitSummary[] diff.files: { filename, status }[] +}) +``` + +`GitHubCommit` is the REST commit graph — `repos.getCommit` / `listCommits` / `compareCommits`. Do not confuse it with `GitCommit`, which is the Git Data API for *creating* verified commits and updating refs. The names are close; the services are different. Use `GitHubCommit` to read history, `GitCommit` to write it. + +### GitHubArtifactMetadata + +Create a GitHub Packages artifact-metadata storage record linking an attestation to a published artifact. `createStorageRecord` returns the created record IDs. `GitHubArtifactMetadataError` carries a `retryable` flag so callers can back off on rate limits and 5xx responses. + +```typescript +import { Effect } from "effect" +import { GitHubArtifactMetadata, GitHubArtifactMetadataLive } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const metadata = yield* GitHubArtifactMetadata + + const ids = yield* metadata.createStorageRecord({ + name: "pkg:npm/@scope/pkg@1.2.3", + digest: artifactSha256Hex, + version: "1.2.3", + registryUrl: "https://npm.pkg.github.com/", + artifactUrl: "https://github.com/owner/pkg/packages/123", + repo: "pkg", + }) + yield* Effect.log(`created ${ids.length} storage record(s)`) +}) +``` + ### OctokitAuthApp `OctokitAuthApp` wraps `@octokit/auth-app`. `GitHubAppLive` depends on it, so in practice you provide `OctokitAuthAppLive` as the layer underneath `GitHubAppLive` rather than reaching for `OctokitAuthApp` directly. ```typescript import { Layer } from "effect" +import { FetchHttpClient } from "@effect/platform" import { GitHubAppLive, OctokitAuthAppLive, } from "@savvy-web/github-action-effects" -// OctokitAuthAppLive satisfies the OctokitAuthApp requirement of GitHubAppLive -const appLayer = Layer.provide(GitHubAppLive, OctokitAuthAppLive) +// OctokitAuthAppLive satisfies the OctokitAuthApp requirement of GitHubAppLive; +// FetchHttpClient.layer satisfies its HttpClient.HttpClient requirement. +const appLayer = Layer.provide( + Layer.provide(GitHubAppLive, OctokitAuthAppLive), + FetchHttpClient.layer, +) ``` -`OctokitAuthAppLive` is a bare `Layer` — provide it as-is. Use the service tag directly only when you need raw `createAppAuth` access. +`OctokitAuthAppLive` is a bare `Layer` — provide it as-is. `GitHubAppLive` also needs `HttpClient.HttpClient`; under `Action.run` / `ActionsRuntime.Default` it is already in context, so a hand-composed `appLayer` only needs the explicit `FetchHttpClient.layer` when wired outside that path. Use the `OctokitAuthApp` service tag directly only when you need raw `createAppAuth` access. ## GitHub App token lifecycle @@ -606,16 +789,21 @@ const program = Effect.gen(function* () { Publish packages to one or more registries. +Registry tokens are `Redacted` — `setupAuth` and each `RegistryTarget.token` take a redacted value, so wrap raw tokens with `Redacted.make`. + ```typescript -import { Effect } from "effect" +import { Effect, Redacted } from "effect" import { PackagePublish, PackagePublishLive } from "@savvy-web/github-action-effects" const program = Effect.gen(function* () { const publisher = yield* PackagePublish + const npmToken = Redacted.make(process.env.NPM_TOKEN ?? "") + const ghToken = Redacted.make(process.env.GITHUB_TOKEN ?? "") + yield* publisher.setupAuth("https://registry.npmjs.org/", npmToken) - const { tarball, digest } = yield* publisher.pack("./dist/npm") + const { tarballPath, digest, sha256Hex } = yield* publisher.pack("./dist/npm") yield* publisher.publish("./dist/npm", { registry: "https://registry.npmjs.org/", @@ -635,6 +823,8 @@ const program = Effect.gen(function* () { }) ``` +For the recommended `pack` → probe → `publishTarball` chain, the two `PackResult` digests and the `publishIdempotent` deprecation, see [publishing packages](./11-publishing.md). + ### WorkspaceDetector Detect and query monorepo workspaces. @@ -695,6 +885,110 @@ const program = Effect.gen(function* () { }) ``` +## Attestation services + +These four services produce signed software attestations. The full wired layer stack and the provenance / SBOM walkthroughs live in [generating SLSA attestations](./10-slsa-attestations.md); the recaps below are the per-service surface. + +### Attest + +End-to-end attestation: build an in-toto statement, sign it via `SigstoreSigner`, and upload the Sigstore bundle to GitHub's attestation store. `provenance` and `sbom` are the two convenience entry points; `listForSubject` reads existing attestations for a digest so a re-run stays idempotent. + +```typescript +import { Effect } from "effect" +import { Attest, CYCLONEDX_BOM } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const attest = yield* Attest + + // Reuse-or-write: list first, write only when absent. + const existing = yield* attest.listForSubject(tarballSha256Hex, { predicateType: CYCLONEDX_BOM }) + if (existing.length === 0) { + const record = yield* attest.sbom({ + rootName: "@scope/pkg", + rootVersion: "1.2.3", + subjectSha256: tarballSha256Hex, + dependencies: [{ name: "effect", version: "3.18.4" }], + }) + yield* Effect.log(`attestation: ${record.attestationUrl}`) + // e.g. https://github.com/{owner}/{repo}/attestations/{id} (varies) + } +}) +``` + +`Attest.provenance` / `.sbom` / `.attest` require `SigstoreSigner | OidcTokenIssuer | GitHubClient`; `.sbom` also requires `Sbom` when it builds the BOM. `listForSubject`'s `predicateType` filter is applied client-side — see the [attestations guide](./10-slsa-attestations.md#idempotent-recovery). + +### Sbom + +Generate a CycloneDX 1.5 BOM from a resolved dependency graph, serialize it to canonical JSON, or write it to disk. + +```typescript +import { Effect } from "effect" +import { Sbom, SbomLive } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const sbom = yield* Sbom + + const bom = yield* sbom.generate({ + rootName: "@scope/pkg", + rootVersion: "1.2.3", + supplier: { name: "Acme, Inc." }, + dependencies: [{ name: "effect", version: "3.18.4" }], + }) + const json = yield* sbom.serializeJson(bom) + // json is the canonical CycloneDX JSON form +}) +``` + +### SigstoreSigner + +Sign an in-toto statement into a Sigstore DSSE bundle — the structure GitHub's attestations API accepts. `signStatement` requires `OidcTokenIssuer` because Fulcio issues the signing certificate against an OIDC token. Most callers reach `SigstoreSigner` indirectly through `Attest`. + +```typescript +import { Effect } from "effect" +import { SigstoreSigner } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const signer = yield* SigstoreSigner + const bundle = yield* signer.signStatement(statement) + // bundle is ready to POST as the `bundle` field of the attestation upload +}) +``` + +### OidcTokenIssuer + +Request a GitHub Actions OIDC token. The runner exposes the token-service endpoint only when the workflow has `id-token: write`. `getToken(audience)` returns a `Redacted` — unwrap with `Redacted.value` only where you must. + +```typescript +import { Effect, Redacted } from "effect" +import { OidcTokenIssuer, SIGSTORE_OIDC_AUDIENCE } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const issuer = yield* OidcTokenIssuer + const token = yield* issuer.getToken(SIGSTORE_OIDC_AUDIENCE) + const raw = Redacted.value(token) // unwrap only to feed the pure JWT decoder +}) +``` + +## Step-buffered logging + +### Step + +`Step` is a top-level namespace, not an injected service. `withStep` runs an Effect with debug/info logs buffered, emitting one summary line on success and spilling the buffer under a `❌` header on failure. `success` sets that summary line; `collapse` reduces N parallel steps to one line; `groupStep` wraps an effect in both a log group and a step. + +```typescript +import { Effect } from "effect" +import { Step } from "@savvy-web/github-action-effects" + +const program = Step.withStep("resolve versions", Effect.gen(function* () { + yield* Effect.logDebug("reading workspace manifest") // buffered on success + yield* Step.success("7 packages") + return 7 +})) +// On success, one line: ✅ resolve versions: 7 packages +``` + +See [step-buffered logging patterns](./09-step-logging.md) for `defaultSummary`, `collapse`, `groupStep` and the failure-spill behaviour. + ## Infrastructure services ### ActionCache @@ -721,6 +1015,66 @@ const program = Effect.gen(function* () { }) ``` +`ActionCacheLive` requires `HttpClient.HttpClient` for the Twirp RPCs (provide `FetchHttpClient.layer` or use `ActionsRuntime.Default`). It reads `ACTIONS_RESULTS_URL` / `ACTIONS_RUNTIME_TOKEN`, which the runner injects only into action (`uses:`) contexts — so the cache works inside a bundled JS action, not a `run:` step. The same constraint applies to `Artifact` below. + +### Artifact + +Upload, list, download and delete GitHub Actions artifacts — `@actions/artifact` v2 parity over the results backend (Twirp + Azure Block Blob), with no dependency on `@actions/artifact`. `getArtifact` returns `Option.none()` on a miss rather than throwing. + +```typescript +import { Effect, Option } from "effect" +import { Artifact, ArtifactLive } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const artifacts = yield* Artifact + + // Zip files relative to a root dir and upload under a name (unique per run) + const { id, size } = yield* artifacts.uploadArtifact( + "build-output", + ["dist/index.js", "dist/index.js.map"], + "dist", + { retentionDays: 7, compressionLevel: 6 }, + ) + yield* Effect.log(`uploaded artifact ${id} (${size} bytes)`) + + // List, look up by name, download by id, delete by name + const all = yield* artifacts.listArtifacts() + const found = yield* artifacts.getArtifact("build-output") + if (Option.isSome(found)) { + const { downloadPath } = yield* artifacts.downloadArtifact(found.value.id) + yield* Effect.log(`downloaded to ${downloadPath}`) + } + yield* artifacts.deleteArtifact("build-output") +}) +``` + +`ArtifactLive` requires `HttpClient.HttpClient`. Like `ActionCache`, it depends on the runner-injected `ACTIONS_RESULTS_URL` / `ACTIONS_RUNTIME_TOKEN` and so **must run inside a bundled JS action, not a `run:` step** — those variables are absent from `run:` shell contexts. Pass a `FindBy` (token, run id, owner, repo) to list/get/download/delete across runs or repos through the public REST API instead. + +### Glob + +Resolve glob patterns and compute `@actions/glob`-compatible file hashes, backed by `node:fs.globSync` with no `@actions/glob` dependency. `glob` returns absolute paths in sorted order; `hashFiles` returns a SHA-256 hash-of-hashes wrapped in an `Option`. + +```typescript +import { Effect, Option } from "effect" +import { Glob, GlobLive } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const glob = yield* Glob + + // Newline- or comma-separated patterns; `!` excludes, `#` comments, `~` HOME. + const files = yield* glob.glob("src/**/*.ts\n!src/**/*.test.ts") + yield* Effect.log(`matched ${files.length} files`) + + // Hash-of-hashes over matched files, in sorted glob order. Files outside the + // workspace root are skipped. Option.none() when nothing matched — the + // toolkit returns "", which you can recover verbatim: + const hash = yield* glob.hashFiles("**/package-lock.json") + const key = `deps-${Option.getOrElse(hash, () => "")}` +}) +``` + +`hashFiles` matches `@actions/glob`'s algorithm exactly: each matched file is streamed through its own SHA-256, and the binary digests are folded — in sorted order — into one accumulating SHA-256 whose hex digest is returned. `GlobOptions`' `followSymbolicLinks`, `implicitDescendants`, `matchDirectories` and `omitBrokenSymbolicLinks` are accepted for parity but are documented no-ops, since `node:fs.globSync` does not expose those controls. + ### TokenPermissionChecker Verify GitHub token permissions before using them. @@ -967,3 +1321,74 @@ const program = Effect.gen(function* () { yield* report.toCheckRun(checkRunId) // requires CheckRun }) ``` + +### RegistryClassifier + +Pure functions that classify a registry URL by parsing it and matching the hostname. Substring matching on URLs is a security issue, so these never use it — `http://evil-npmjs.org` does not pass as npm. + +```typescript +import { RegistryClassifier } from "@savvy-web/github-action-effects" + +RegistryClassifier.getRegistryType("https://npm.pkg.github.com/") +// → "github-packages" + +RegistryClassifier.getRegistryDisplayName("https://registry.npmjs.org/") +// → "npm" + +RegistryClassifier.generatePackageViewUrl("https://registry.npmjs.org/", "@scope/pkg") +// → "https://www.npmjs.com/package/@scope/pkg" +``` + +`getRegistryType` resolves to `"npm"`, `"github-packages"`, `"jsr"` or `"custom"`, with a null or undefined registry resolving to `"npm"` (the publishing default). The predicates `isNpmRegistry`, `isGitHubPackagesRegistry`, `isJsrRegistry` and `isCustomRegistry` answer the same question one registry at a time. See [publishing packages](./11-publishing.md#registry-classification) for how the publish flow uses it. + +### PathUtils + +Pure path-separator normalizers, matching `@actions/core`'s path utilities. Plain synchronous functions — no Effect, no context. + +```typescript +import { PathUtils } from "@savvy-web/github-action-effects" + +PathUtils.toPosixPath("a\\b") +// → "a/b" + +PathUtils.toWin32Path("a/b") +// → "a\\b" + +PathUtils.toPlatformPath("a/b\\c") +// → "a/b/c" on POSIX, "a\\b\\c" on Windows +``` + +### IoUtil + +Locate a binary on `PATH`, mirroring `@actions/io`'s `which` / `findInPath`. `which` and `findInPath` have a `never` error channel — a miss is `Option.none()` / `[]`, not a failure — while `whichOrFail` puts the not-found case in the typed error channel as `IoError`. All three read `FileSystem` from context. See [filesystem I/O](./15-filesystem-io.md) for the full behavior table and the `cp`/`mv`/`rmRF`/`mkdirP` → `FileSystem` recipe. + +```typescript +import { Effect, Option } from "effect" +import { IoUtil } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const git = yield* IoUtil.which("git") // Option, never fails + if (Option.isNone(git)) { + yield* Effect.logWarning("git not found on PATH") + } + + const node = yield* IoUtil.whichOrFail("node") // string, fails with IoError on miss + const all = yield* IoUtil.findInPath("python") // every match across PATH, [] on none +}) +``` + +### GithubMarkdown + +Pure GitHub-Flavored Markdown builders. The full method table lives in [architecture](./14-architecture.md#githubmarkdown-namespace); two builders worth calling out are `image` and `quote`, which match `@actions/core`'s step-summary `addImage` / `addQuote`. + +```typescript +import { GithubMarkdown } from "@savvy-web/github-action-effects" + +GithubMarkdown.image("https://example.com/logo.png", "Logo", { width: "120" }) +// → Logo + +GithubMarkdown.quote("All checks passed", "https://example.com/run/123") +// →
All checks passed
+``` + +Attribute and text values are interpolated raw, not HTML-escaped — exactly as `@actions/core` does, since GitHub sanitizes step-summary HTML server-side. If you embed the output elsewhere with untrusted input, escape it yourself. diff --git a/docs/04-patterns.md b/docs/04-patterns.md index aec54da..8b3636e 100644 --- a/docs/04-patterns.md +++ b/docs/04-patterns.md @@ -186,79 +186,9 @@ const program = Effect.gen(function* () { ## Package publishing workflow -This example wires several services together into a multi-registry publish workflow. +Publishing has its own guide. The recommended path is the `pack` → probe → `publishTarball` chain: pack the package once, compare the packed integrity digest against what the registry already has, and upload the exact bytes only when they differ. That composition is what makes a re-run recover cleanly across multiple registries, where the older fused `publishIdempotent` could not. -```typescript -import { Config, Effect, Layer } from "effect" -import { - Action, - DryRun, - DryRunLive, - NpmRegistry, - NpmRegistryLive, - PackagePublish, - PackagePublishLive, - TokenPermissionChecker, - TokenPermissionCheckerLive, - ErrorAccumulator, - GithubMarkdown, - ActionOutputs, -} from "@savvy-web/github-action-effects" - -// `granted` is the permissions record from the token in use -const granted = { contents: "write" } - -const program = Effect.gen(function* () { - const dryRun = yield* DryRun - const checker = yield* TokenPermissionChecker - const npm = yield* NpmRegistry - const publisher = yield* PackagePublish - const outputs = yield* ActionOutputs - - // 1. Verify permissions - yield* checker.assertSufficient({ contents: "write" }) - - // 2. Check if version already exists - const latest = yield* npm.getLatestVersion("@scope/my-pkg") - yield* Effect.log(`Current latest: ${latest}`) - - // 3. Pack and publish - const packed = yield* dryRun.guard( - "pack", - publisher.pack("./dist/npm"), - { tarball: "dry-run.tgz", digest: "sha512-dry" }, - ) - - yield* dryRun.guard( - "publish", - publisher.publishToRegistries("./dist/npm", [ - { registry: "https://registry.npmjs.org/", token: npmToken }, - { registry: "https://npm.pkg.github.com/", token: ghToken }, - ]), - undefined, - ) - - // 4. Write summary - yield* outputs.summary( - GithubMarkdown.table( - ["Package", "Version", "Status"], - [["@scope/my-pkg", "1.0.0", GithubMarkdown.statusIcon("pass")]], - ), - ) -}) - -Action.run( - program, - { - layer: Layer.mergeAll( - DryRunLive, - NpmRegistryLive, - PackagePublishLive, - TokenPermissionCheckerLive(granted), - ), - }, -) -``` +See [publishing packages](./11-publishing.md) for the full chain, the two distinct `PackResult` digests (npm integrity vs the SHA-256 attestation subject), `RegistryTarget.packageManager`, dry runs and the `publishIdempotent` deprecation. ## Report builder @@ -351,9 +281,9 @@ import { } from "@savvy-web/github-action-effects" // GitHubClientLive is a namespace of constructors — call one to get a layer. -// `fromEnv` reads process.env.GITHUB_TOKEN. +// `fromEnv()` reads process.env.GITHUB_TOKEN. const ExtendedLayer = Layer.mergeAll( - GitHubClientLive.fromEnv, + GitHubClientLive.fromEnv(), GitHubReleaseLive, CommandRunnerLive, DryRunLive, diff --git a/docs/05-best-practices.md b/docs/05-best-practices.md new file mode 100644 index 0000000..5b1936f --- /dev/null +++ b/docs/05-best-practices.md @@ -0,0 +1,159 @@ +# Building a robust action + +This guide collects the decisions that keep an action working on a flaky runner instead of failing the first time a 503 comes back. It is principles and pointers, not a second copy of each service's reference — every section links to the guide that goes deep. Read it once before you wire your first action, then come back when something behaves oddly in CI. + +## Wiring: ActionsRuntime.Default vs hand-composed layers + +`Action.run(program)` is the path you want for almost every action. It provides `ActionsRuntime.Default` (the ConfigProvider, the Effect Logger, the core services, a Node.js `FileSystem` and a fetch-backed `HttpClient`), wraps the program in buffered logging, catches every cause, emits an `::error::` workflow command and sets `process.exitCode = 1` on failure. The clean exit behaviour comes for free; you write none of it. + +```typescript +import { Effect } from "effect" +import { Action } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + yield* Effect.log("starting") + // ... your logic +}) + +Action.run(program) +``` + +Reach for hand-composed layers only when the default stack is not enough — an extra service, a non-default credential source, a memoized App client shared across sub-programs. Even then, layer your additions on top of the default rather than rebuilding it: + +```typescript +import { Layer } from "effect" +import { Action, GitHubClientLive, GitHubReleaseLive, DryRunLive } from "@savvy-web/github-action-effects" + +Action.run(program, { + layer: Layer.mergeAll(GitHubClientLive.fromEnv(), GitHubReleaseLive, DryRunLive), +}) +``` + +For the runtime internals behind `ActionsRuntime.Default` and the `Action.run` type-erasure boundary, see [architecture](./14-architecture.md#actionsruntimedefault). For composing additional layers, see [common patterns](./04-patterns.md#composing-additional-layers). + +## Structure long actions as pre / main / post + +A single-phase action runs once and exits. An action that needs setup before the job and teardown after it — provisioning a token, then revoking it — should split into three phases backed by `ActionState`. State crosses phases through the `GITHUB_STATE` file, schema-encoded so the `post` phase reads back the same shape the `pre` phase wrote. + +The canonical case is App-token authentication: `pre` provisions, `main` builds a client from the persisted token, `post` revokes it. `GitHubToken` coordinates all three. The full three-phase walkthrough — including the `action.yml` `pre`/`post` wiring — is [the advanced action guide](./02-advanced-action.md). + +## Guard mutations with DryRun + +Any action that creates releases, opens PRs or publishes packages should support a dry-run mode so a maintainer can preview what it would do. The `DryRun` service intercepts a mutation effect and returns a fallback value instead of running it when dry-run is enabled, so the surrounding code stays identical between real and rehearsal runs. + +```typescript +import { Effect } from "effect" +import { DryRun, GitHubRelease } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const dryRun = yield* DryRun + const releases = yield* GitHubRelease + + // In dry-run mode the create call is skipped and the fallback is returned. + const release = yield* dryRun.guard( + "create-release", + releases.create({ tag: "v1.0.0", name: "v1.0.0", body: "Release notes" }), + { id: 0, tag: "v1.0.0", name: "v1.0.0", body: "", draft: false, prerelease: false, uploadUrl: "" }, + ) + yield* Effect.log(`release id: ${release.id}`) + // release id: 0 (in dry-run; the real id on a live run) +}) +``` + +The full pattern is in [common patterns](./04-patterns.md#dry-run-mode). + +## Check token permissions before you use them + +A token that lacks a scope fails deep inside an API call with a cryptic 403. Check the scopes up front instead, so the action stops with a message that names the missing permission. `TokenPermissionCheckerLive` takes the granted permissions record and returns a layer; `assertSufficient` fails fast on a gap, `assertExact` enforces least-privilege, `warnOverPermissioned` flags excess without failing. + +```typescript +import { Effect } from "effect" +import { TokenPermissionChecker, TokenPermissionCheckerLive } from "@savvy-web/github-action-effects" + +const granted = { contents: "write", "pull-requests": "write" } + +const program = Effect.gen(function* () { + const checker = yield* TokenPermissionChecker + yield* checker.assertSufficient({ contents: "write", "pull-requests": "write" }) +}).pipe(Effect.provide(TokenPermissionCheckerLive(granted))) +``` + +When you provision an App token with `GitHubToken.provision({ permissions })`, the check runs for you and the rejected token is revoked on a gap — you do not wire the checker by hand. See [permission checking](./04-patterns.md#permission-checking) and [App token provisioning](./04-patterns.md#app-token-provisioning). + +## Make re-runs idempotent + +A workflow re-run should not write a release twice, publish the same tarball twice or attest the same digest twice. Design every mutation so that running it again on an already-done artifact is a no-op. Two library patterns make this concrete: + +- **Probe then publish.** Pack the package once, compare its integrity digest against what the registry already has and upload only when they differ. See [publishing packages](./11-publishing.md#probe-then-publish). +- **List before you attest.** `Attest.listForSubject(sha256Hex)` returns the attestations already written for a digest, so the orchestrator reuses an existing attestation URL instead of writing a fresh one. See [generating SLSA attestations](./10-slsa-attestations.md#idempotent-recovery). + +Both come down to the same idea: read the current state, then act only on the delta. That is what lets a partially-failed run recover cleanly on a retry. + +## Handle secrets as Redacted, mask generated ones + +Tokens and private keys are `Redacted` throughout the secret-bearing API surface — `GitHubClientLive.fromToken`, `fromApp`'s `privateKey`, `GitHubApp.generateToken`, `PackagePublish.setupAuth` and the rest. Wrap a raw string with `Redacted.make(...)` at the call site and unwrap with `Redacted.value(...)` only at the single wire boundary that needs the plain text. A `Redacted` value prints as `` if it lands in a log line, so an accidental `Effect.log(token)` cannot leak it. + +```typescript +import { Effect, Redacted } from "effect" +import { GitHubClientLive } from "@savvy-web/github-action-effects" + +const layer = GitHubClientLive.fromToken(Redacted.make(process.env.MY_TOKEN ?? "")) +``` + +Values your action generates at runtime — an installation token, a derived password — are not read through an input, so GitHub does not mask them automatically. Register them with `ActionOutputs.setSecret(value)` so the runner scrubs them from every subsequent log line. + +```typescript +import { Effect } from "effect" +import { ActionOutputs } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const outputs = yield* ActionOutputs + yield* outputs.setSecret(generatedToken) + yield* Effect.log(`token: ${generatedToken}`) + // token: *** (the runner masks the registered value) +}) +``` + +Secret masking in logs is covered alongside the rest of the logging story in [logging and error handling](./07-logging-and-error-handling.md#mask-secrets-in-logs). + +## Accumulate errors when a batch should not stop on the first failure + +Processing a list of packages, repos or files often should not abort the moment one item fails — you want every result and a report of what broke. `ErrorAccumulator.forEachAccumulate` runs the whole batch, collects successes and failures into one result and never short-circuits. Its error channel is `never`; every failure lands in `result.failures`. + +```typescript +import { Effect } from "effect" +import { ErrorAccumulator } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const result = yield* ErrorAccumulator.forEachAccumulate( + ["pkg-a", "pkg-b", "pkg-c"], + (pkg) => publishPackage(pkg), + ) + yield* Effect.log(`published ${result.successes.length}, failed ${result.failures.length}`) + // published 2, failed 1 (counts depend on which items failed) +}) +``` + +For bounded concurrency, `forEachAccumulateConcurrent(items, fn, n)` runs at most `n` at once. See [error accumulation](./04-patterns.md#error-accumulation). + +## Which service for which job + +A quick map from "I need to ..." to the service that does it. Each links to its reference. + +| You need to | Reach for | Guide | +| --- | --- | --- | +| Read an input | `Config.string` / `ActionInput.boolean` | [toolkit parity](./06-toolkit-parity.md#actionscore) | +| Set an output or write a step summary | `ActionOutputs` | [services](./03-services.md#actionoutputs) | +| Pass state between phases | `ActionState` | [advanced action](./02-advanced-action.md) | +| Read the event payload / repo / issue | `ActionEnvironment` | [toolkit parity](./06-toolkit-parity.md#actionsgithub-context-and-api) | +| Call the GitHub REST or GraphQL API | `GitHubClient` | [resilient GitHub API calls](./08-resilient-github-api.md) | +| Run a shell command | `CommandRunner` | [toolkit parity](./06-toolkit-parity.md#actionsexec) | +| Resolve globs or hash files | `Glob` | [toolkit parity](./06-toolkit-parity.md#actionsglob) | +| Save / restore a cache | `ActionCache` | [toolkit parity](./06-toolkit-parity.md#actionscache) | +| Upload / download an artifact | `Artifact` | [toolkit parity](./06-toolkit-parity.md#actionsartifact) | +| Install a tool binary | `ToolInstaller` | [toolkit parity](./06-toolkit-parity.md#actionstool-cache) | +| Publish a package | `PackagePublish` | [publishing](./11-publishing.md) | +| Sign provenance or an SBOM | `Attest` | [SLSA attestations](./10-slsa-attestations.md) | +| Keep logs quiet on success | `Step` | [step-buffered logging](./09-step-logging.md) | + +For the full per-service catalog, see the [services guide](./03-services.md). diff --git a/docs/06-toolkit-parity.md b/docs/06-toolkit-parity.md new file mode 100644 index 0000000..306070c --- /dev/null +++ b/docs/06-toolkit-parity.md @@ -0,0 +1,263 @@ +# Coming from `@actions/*` + +If you have written a GitHub Action before, you reached for the `@actions/*` toolkit: `@actions/core` for inputs and outputs, `@actions/github` for the API, `@actions/exec` to run commands, and so on. This library replaces all of it with native ESM, so there is zero CJS in your dependency tree. This guide is the migration map: for each `@actions/*` package, the equivalent here and how the shape differs. + +The big difference is that everything returns an `Effect` instead of a `Promise` or a bare value. Inputs come through Effect's `Config` API, side effects run inside `Effect.gen` and failures live in a typed error channel instead of thrown exceptions. If you are new to Effect, read [building a GitHub Action with Effect](./01-example-action.md) first — it walks one complete action end to end. + +This guide is the narrative walkthrough. For a single at-a-glance lookup table of every `@actions/*` package and its replacement, see [the substitution map in architecture](./14-architecture.md#replacing-actions-packages). + +## `@actions/core` + +`@actions/core` is the toolkit's grab bag — inputs, outputs, state, logging, summaries, paths. It splits across several pieces here. + +### Inputs + +`core.getInput("name")` becomes `Config.string("name")`. The `ActionsConfigProvider` (wired in by `ActionsRuntime.Default`) reads the `INPUT_*` environment variables GitHub sets, so the key maps the same way the toolkit's does: `Config.string("package-name")` reads `INPUT_PACKAGE-NAME`. + +```typescript +import { Config, Effect } from "effect" + +const program = Effect.gen(function* () { + const name = yield* Config.string("package-name") // core.getInput("package-name") + const count = yield* Config.integer("count") // core.getInput + parseInt + const debug = yield* Config.boolean("debug").pipe(Config.withDefault(false)) +}) +``` + +For booleans and multiline inputs where you want byte-for-byte toolkit semantics, use `ActionInput` instead of Effect's built-in combinators. `core.getBooleanInput` follows the YAML 1.2 Core Schema exactly — it accepts only `true | True | TRUE | false | False | FALSE` and rejects `yes` / `on` / `1`. Effect's `Config.boolean` is more permissive, so `ActionInput.boolean` exists to match the runtime's strictness: + +```typescript +import { Config, Effect } from "effect" +import { ActionInput } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const dryRun = yield* ActionInput.boolean("dry-run") // core.getBooleanInput + const paths = yield* ActionInput.multiline("paths").pipe( // core.getMultilineInput + Config.withDefault([] as ReadonlyArray), + ) +}) +``` + +### Outputs, state and the step summary + +`core.setOutput`, `core.exportVariable`, `core.addPath`, `core.setFailed` and `core.summary` all live on the `ActionOutputs` service. They write through the same `GITHUB_OUTPUT` / `GITHUB_ENV` / `GITHUB_PATH` / `GITHUB_STEP_SUMMARY` env files the toolkit uses. + +```typescript +import { Effect } from "effect" +import { ActionOutputs, GithubMarkdown } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const outputs = yield* ActionOutputs + yield* outputs.set("version", "1.2.3") // core.setOutput + yield* outputs.exportVariable("CACHE_HIT", "true") // core.exportVariable + yield* outputs.addPath("/opt/tool/bin") // core.addPath + yield* outputs.summary( // core.summary.addRaw().write() + GithubMarkdown.table(["Package", "Status"], [["@scope/pkg", "published"]]), + ) +}) +``` + +`core.summary`'s fluent builder maps onto the `GithubMarkdown` namespace — pure functions that build GFM strings (`table`, `heading`, `details`, `checklist`, `image`, `quote` and more) which you then hand to `outputs.summary`. `image` and `quote` match `core.summary.addImage` / `addQuote`. For multi-target reports (summary plus a PR comment plus a check run from one source), `ReportBuilder` builds the markdown once and writes it to each surface — see [common patterns](./04-patterns.md#report-builder). + +`core.setSecret` is `ActionOutputs.setSecret` — register a generated value so the runner masks it in logs. Cross-phase state (`core.saveState` / `core.getState`) is the `ActionState` service, which schema-encodes the value so the `post` phase decodes the same shape. See [the advanced action guide](./02-advanced-action.md). + +### Logging + +`core.debug` / `core.info` / `core.warning` / `core.error` map onto Effect's own logger. The `ActionsLogger` (installed by `ActionsRuntime.Default`) translates each Effect log level to the matching workflow command, so you call `Effect.logDebug` / `Effect.log` / `Effect.logWarning` / `Effect.logError` and the right `::debug::` / `::warning::` / `::error::` comes out. The two operations the Effect logger does not cover — collapsible groups (`core.startGroup` / `endGroup`) and `::notice::` (`core.notice`) — live on the `ActionLogger` service as `group` and `notice`. + +```typescript +import { Effect } from "effect" +import { ActionLogger } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + yield* Effect.logWarning("deprecated input") // core.warning + const logger = yield* ActionLogger + yield* logger.notice("build complete", { title: "CI" }) // core.notice + yield* logger.group("Install", Effect.log("installing")) // core.group +}) +``` + +The full logging story — the level-to-command mapping, file/line annotations, buffered output and step logging — is in [logging and error handling](./07-logging-and-error-handling.md). + +### Paths + +`core.toPosixPath` / `toWin32Path` / `toPlatformPath` are the pure `PathUtils` namespace, no service required. + +```typescript +import { PathUtils } from "@savvy-web/github-action-effects" + +PathUtils.toPosixPath("a\\b") +// "a/b" +``` + +## `@actions/github` (context and API) + +`@actions/github` gives you two things: a pre-authenticated Octokit (`getOctokit`) and the parsed event context (`context`). They split into `GitHubClient` and `ActionEnvironment`. + +### The API client + +`getOctokit(token)` becomes a `GitHubClient` layer. It wraps `@octokit/rest` directly — there is no `@actions/github` dependency — and adds retry, rate-limit awareness and streaming pagination. You call REST through `client.rest(operation, fn)` and GraphQL through `client.graphql`: + +```typescript +import { Effect, Redacted } from "effect" +import { GitHubClient, GitHubClientLive } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const client = yield* GitHubClient + const { owner, repo } = yield* client.repo + const data = yield* client.rest("repos.get", (octokit) => + (octokit as { rest: { repos: { get: (p: unknown) => Promise<{ data: unknown }> } } }).rest.repos.get({ owner, repo }), + ) + yield* Effect.log(`default branch: ${(data as { default_branch: string }).default_branch}`) + // default branch: main (whatever the repo's default is) +}).pipe(Effect.provide(GitHubClientLive.fromToken(Redacted.make(process.env.MY_TOKEN ?? "")))) +``` + +The resilience and pagination behaviour is its own guide — see [resilient GitHub API calls](./08-resilient-github-api.md). For the three credential sources (`fromEnv`, `fromToken`, `fromApp`), see [building a GitHubClient layer](./14-architecture.md#building-a-githubclient-layer). + +### The event context + +`github.context` becomes the `ActionEnvironment` service. The webhook payload, the `{ owner, repo }` pair and the `{ owner, repo, number }` issue/PR triple all mirror `@actions/github`'s `context.payload` / `context.repo` / `context.issue`. The payload is schema-decoded into a typed `WebhookPayload` — common fields are typed, unknown keys are preserved. + +```typescript +import { Effect } from "effect" +import { ActionEnvironment } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const env = yield* ActionEnvironment + const { owner, repo } = yield* env.repo // context.repo + const issue = yield* env.issue // context.issue → { owner, repo, number } + const payload = yield* env.payload // context.payload (typed WebhookPayload) + yield* Effect.log(`${owner}/${repo}#${issue.number}`) + // octocat/hello-world#42 (values from the triggering event) +}) +``` + +`env.payload`, `env.repo` and `env.issue` read `GITHUB_EVENT_PATH` and need a `FileSystem`, which `ActionsRuntime.Default` provides. Like `@actions/github`, an absent event path yields an empty payload rather than an error. + +## `@actions/glob` + +`@actions/glob`'s `create(patterns).glob()` and `hashFiles(patterns)` are the `Glob` service. `Glob.glob` resolves newline- or comma-separated patterns (honoring `!` excludes, `~` expansion and `#` comments) to sorted absolute paths. `Glob.hashFiles` computes the same SHA-256 hash-of-hashes the toolkit produces — useful for cache keys. + +```typescript +import { Effect, Option } from "effect" +import { Glob } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const glob = yield* Glob + const files = yield* glob.glob("src/**/*.ts\n!src/**/*.test.ts") + yield* Effect.log(`matched ${files.length} files`) + // matched 37 files (count depends on the working tree) + + // hashFiles returns Option.none() on no match; the toolkit returns "". + const hash = yield* glob.hashFiles("**/package-lock.json") + yield* Effect.log(Option.getOrElse(hash, () => "")) + // a 64-char hex digest, or "" when nothing matched +}) +``` + +`GlobOptions` accepts the documented `@actions/glob` option names for parity; the ones `node:fs.globSync` cannot enforce (`followSymbolicLinks`, `implicitDescendants`, `matchDirectories`, `omitBrokenSymbolicLinks`) are documented no-ops rather than silent drops. + +## `@actions/io` + +`@actions/io` splits two ways. The lookups with no `@effect/platform` equivalent — `which` and `findInPath` — are the `IoUtil` namespace. The filesystem mutations — `cp`, `mv`, `rmRF`, `mkdirP` — map directly onto `@effect/platform`'s `FileSystem` and need no wrapper. + +```typescript +import { Effect, Option } from "effect" +import { IoUtil } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const git = yield* IoUtil.which("git") // io.which("git") → Option.none() on miss + if (Option.isNone(git)) { + yield* Effect.logWarning("git not found on PATH") + } + const node = yield* IoUtil.whichOrFail("node") // io.which("node", true) → fails on miss +}) +``` + +The `cp` / `mv` / `rmRF` / `mkdirP` recipe and the full `IoUtil` behaviour table are in [filesystem I/O](./15-filesystem-io.md). + +## `@actions/exec` + +`exec.exec` and `exec.getExecOutput` become the `CommandRunner` service. `exec` returns the exit code; `execCapture` returns `{ exitCode, stdout, stderr }`. A non-zero exit fails with `CommandRunnerError` (unlike the toolkit, which resolves and leaves you to check the code), and `execJson` / `execLines` add typed parsing on top. + +```typescript +import { Effect } from "effect" +import { CommandRunner } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const runner = yield* CommandRunner + const out = yield* runner.execCapture("git", ["rev-parse", "HEAD"]) // exec.getExecOutput + yield* Effect.log(out.stdout.trim()) + // the resolved commit SHA on stdout +}) +``` + +## `@actions/cache` + +`cache.saveCache` / `restoreCache` are the `ActionCache` service. It speaks the V2 Twirp cache protocol with Azure Blob Storage — no `@actions/cache` dependency. `restore` returns the matched key wrapped in `Option`, so a miss is `Option.none()` rather than `undefined`. + +```typescript +import { Effect, Option } from "effect" +import { ActionCache } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const cache = yield* ActionCache + const hit = yield* cache.restore(["node_modules"], "deps-abc123") + if (Option.isNone(hit)) { + yield* Effect.log("cache miss") + // ... install, then save + yield* cache.save(["node_modules"], "deps-abc123") + } +}) +``` + +`ActionCache` reads the runner-injected `ACTIONS_RESULTS_URL` / `ACTIONS_RUNTIME_TOKEN`, which are present only inside a JS action (`uses:`), not a `run:` step. The same constraint applies to `Artifact`. + +## `@actions/artifact` + +`@actions/artifact`'s `DefaultArtifactClient` is the `Artifact` service (v2 parity). It uploads, lists, downloads and deletes through the same results backend the toolkit uses. `getArtifact` returns `Option.none()` on a miss instead of throwing `ArtifactNotFoundError`. + +```typescript +import { Effect } from "effect" +import { Artifact } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const artifact = yield* Artifact + const result = yield* artifact.uploadArtifact("dist", ["dist/bundle.js"], ".") + yield* Effect.log(`uploaded artifact ${result.id} (${result.size} bytes)`) + // uploaded artifact 1234 (size varies by content) +}) +``` + +Like the toolkit, `Artifact` works only inside a bundled JS action where the runner exposes its backend variables. + +## `@actions/tool-cache` + +`tool-cache`'s `downloadTool` / `extractTar` / `extractZip` / `cacheDir` / `cacheFile` / `find` are the `ToolInstaller` service, built on `node:https` and `child_process`. It is the low-level primitive set — compose the steps your tool needs (`find`, then `download`, then `extract`, then `cacheDir`) rather than a single opinionated installer. + +```typescript +import { Effect, Option } from "effect" +import { ToolInstaller } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const tools = yield* ToolInstaller + const cached = yield* tools.find("shellcheck", "0.10.0") // tc.find + const path = Option.isSome(cached) + ? cached.value + : yield* Effect.gen(function* () { + const tar = yield* tools.download("https://example.com/shellcheck.tar.gz") // tc.downloadTool + const dir = yield* tools.extractTar(tar) // tc.extractTar + return yield* tools.cacheDir(dir, "shellcheck", "0.10.0") // tc.cacheDir + }) + yield* Effect.log(`shellcheck at ${path}`) +}) +``` + +## `@actions/attest` + +`@actions/attest`'s `attestProvenance` / `attest` are the `Attest` cluster — `Attest.provenance`, `Attest.sbom` and the lower-level `Attest.attest` / `buildBundle`. It signs an in-toto statement into a Sigstore bundle (via OIDC + Fulcio + Rekor) and uploads it to GitHub's attestation store, producing a predicate shaped to match what `@actions/attest` emits so verifiers see the same structure. The full wiring, the provenance and SBOM shapes and the idempotent-recovery path are in [generating SLSA attestations](./10-slsa-attestations.md). + +## `@actions/http-client` + +There is no dedicated replacement export. Use `@effect/platform`'s `HttpClient` directly — it is the idiomatic Effect substitute, and `ActionsRuntime.Default` already provides a fetch-backed instance. diff --git a/docs/07-logging-and-error-handling.md b/docs/07-logging-and-error-handling.md new file mode 100644 index 0000000..56dea78 --- /dev/null +++ b/docs/07-logging-and-error-handling.md @@ -0,0 +1,168 @@ +# Logging and error handling + +A GitHub Action's log is its user interface. When a run passes, the log should be short and scannable; when it fails, it should put the cause where the reader will find it. This guide covers how to produce clean, debuggable output: the log-level model, collapsible groups, buffered and step-buffered logging, annotations, secret masking and the error-handling boundary that catches whatever reaches the top of your program. + +## The log-level model + +Logging runs through Effect's own logger. The `ActionsLogger` installed by `ActionsRuntime.Default` maps each Effect log level to the matching GitHub Actions workflow command, so you write idiomatic Effect and the right command comes out: + +| Effect level | Workflow command | Where it shows | +| --- | --- | --- | +| `Effect.logDebug` / `logTrace` | `::debug::` | Visible only with `ACTIONS_STEP_DEBUG=true` | +| `Effect.log` / `logInfo` | stdout | Plain text in the log | +| `Effect.logWarning` | `::warning::` | Yellow annotation | +| `Effect.logError` / `logFatal` | `::error::` | Red annotation | + +```typescript +import { Effect } from "effect" + +const program = Effect.gen(function* () { + yield* Effect.logDebug("resolved 12 packages") // ::debug:: — hidden unless step-debug is on + yield* Effect.log("publishing") // plain stdout + yield* Effect.logWarning("registry slow") // ::warning:: + yield* Effect.logError("publish failed") // ::error:: +}) +``` + +Debug lines stay out of the way on a normal run and appear when a maintainer flips on step-debug — so log liberally at debug level. The level-to-command mapping lives in `ActionsLogger`; see [architecture](./14-architecture.md#actionslogger). + +## Annotations on the diff + +`::warning::` and `::error::` become inline annotations on a pull request diff when you attach `file`, `line` and `col` log annotations. The logger reads those annotations off the Effect log call and emits them as workflow-command properties, producing `::error file=...,line=...::` — the form GitHub renders against the offending line. + +```typescript +import { Effect } from "effect" + +const program = Effect.logError("unused import").pipe( + Effect.annotateLogs({ file: "src/index.ts", line: "10", col: "1" }), +) +// ::error file=src/index.ts,line=10,col=1::unused import +``` + +`ActionLogger` has no annotation methods of its own — annotations come from logging through Effect this way. + +## Notices + +GitHub has a fourth annotation level, `::notice::`, that sits between info and warning. Effect has no log level there, so it is a dedicated method on the `ActionLogger` service rather than a remapped level. Use it for a neutral callout the reader should see without it reading as a problem. + +```typescript +import { Effect } from "effect" +import { ActionLogger } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const logger = yield* ActionLogger + yield* logger.notice("3 packages published", { title: "Release" }) + // ::notice title=Release::3 packages published +}) +``` + +## Collapsible groups + +Wrap a noisy phase in `ActionLogger.group(name, effect)` to render it as a collapsible block in the runner UI — the toolkit's `core.group`. The group's lines start folded; the reader expands the ones they care about. + +```typescript +import { Effect } from "effect" +import { ActionLogger } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const logger = yield* ActionLogger + yield* logger.group("Install dependencies", Effect.gen(function* () { + yield* Effect.log("resolving") + yield* Effect.log("linking") + })) +}) +``` + +When a grouped effect fails, the buffered verbose lines inside it are flushed before the `::endgroup::`, so the failing group keeps its context instead of collapsing it away. + +## Buffered logging: quiet on success, verbose on failure + +`ActionLogger.withBuffer(label, effect)` captures `info`-level output in memory while the effect runs. On success the buffer is discarded — the reader sees only warnings and errors. On failure the buffer is flushed with labeled delimiters, so the full trail that led to the failure is right there. `Action.run` already wraps your whole program in `withBuffer`, so you get this behaviour for free at the top level; reach for it explicitly to scope a buffer to a sub-phase. + +```typescript +import { Effect } from "effect" +import { ActionLogger } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const logger = yield* ActionLogger + yield* logger.withBuffer("analysis", Effect.gen(function* () { + yield* Effect.log("step 1 of 4") // discarded on success, flushed on failure + yield* Effect.log("step 2 of 4") + // ... + })) +}) +``` + +Warnings and errors are never buffered — they pass straight through so their annotations are never suppressed. See [architecture](./14-architecture.md#withbuffer) for the mechanism. + +## Step-buffered logging + +`Step.withStep` is the per-unit-of-work refinement of buffered logging: it captures a step's debug and info lines, emits one summary line on success (`✅ : `) and spills the buffer under a `❌ ` header on failure. `Step.collapse` reduces a parallel fan-out to one line; `Step.groupStep` combines a group and a step for a phase's outer scope. That is its own guide — see [step-buffered logging patterns](./09-step-logging.md). Use plain `withBuffer` when you want a single buffer around a phase; reach for `Step` when each unit of work should announce its own one-line outcome. + +## Mask secrets in logs + +A value read through an action input is masked automatically — GitHub knows it is a secret. A value your action generates at runtime is not, so register it with `ActionOutputs.setSecret` (the toolkit's `core.setSecret`). After registration the runner scrubs the value from every subsequent log line, including ones written by child processes. + +```typescript +import { Effect } from "effect" +import { ActionOutputs } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const outputs = yield* ActionOutputs + yield* outputs.setSecret(generatedToken) + yield* Effect.log(`token: ${generatedToken}`) + // token: *** (the runner masks the registered value) +}) +``` + +The complementary discipline is keeping the secret out of logs in the first place. Tokens and private keys are `Redacted` across the secret-bearing API, and a `Redacted` value renders as `` if it reaches a log line, so an accidental `Effect.log(token)` cannot leak it. Wrap raw secrets with `Redacted.make(...)` at the boundary and unwrap with `Redacted.value(...)` only where the plain text is genuinely needed. Combine both: redact secrets in your own code, and `setSecret` any value that crosses into a child process or third-party output. + +## The error-handling boundary + +`Action.run` wraps your program in `Effect.catchAllCause` so no failure goes unhandled. Whatever reaches the top — a typed error, a defect, an interrupt — is formatted by `Action.formatCause`, emitted as an `::error::` workflow command, and `process.exitCode` is set to `1`. You do not write top-level error handling; the action exits cleanly and the message shows in the UI. + +```typescript +import { Effect } from "effect" +import { Action } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + // ... your logic; any failure here is caught by Action.run +}) + +Action.run(program) +``` + +Handle errors explicitly only when you want custom behaviour — recovering from a specific tagged error, or writing a failure summary before letting the action fail. Match on the tag with `Effect.catchTag`: + +```typescript +import { Effect } from "effect" + +const program = myEffect.pipe( + Effect.catchTag("GitHubClientError", (e) => + Effect.logError(`API call "${e.operation}" failed: ${e.reason}`), + ), +) +``` + +`Action.formatCause` produces a stable `[Tag] message` string from any `Cause` — `[GitHubClientError] 404 Not Found`, `[Error] ENOENT: no such file or directory` — that both humans and log scrapers can parse. The tagged-error catalog, the `formatCause` fallback chain and the full set of `catchTag` recipes are in [error handling](./13-error-handling.md). + +## Accumulate errors across a batch + +When a batch should not abort on the first failure, do not let an error propagate to `Action.run` — collect them. `ErrorAccumulator.forEachAccumulate` runs every item, captures successes and failures and returns both, so you can log a complete report instead of stopping at item one. + +```typescript +import { Effect } from "effect" +import { ErrorAccumulator } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const result = yield* ErrorAccumulator.forEachAccumulate(packages, publishPackage) + for (const { item, error } of result.failures) { + yield* Effect.logError(`failed: ${item}`) + // one ::error:: line per failed item + } + yield* Effect.log(`published ${result.successes.length}, failed ${result.failures.length}`) + // published 2, failed 1 (counts depend on which items failed) +}) +``` + +See [error accumulation](./04-patterns.md#error-accumulation) for the concurrent variant and the result shape. diff --git a/docs/08-resilient-github-api.md b/docs/08-resilient-github-api.md new file mode 100644 index 0000000..3ef0c27 --- /dev/null +++ b/docs/08-resilient-github-api.md @@ -0,0 +1,152 @@ +# Resilient GitHub API calls + +The GitHub API returns 429s under rate pressure and 5xxs when something on its side hiccups. An action that does not retry those will fail a run that a single retry would have saved. `GitHubClient` retries them for you — resilience is on by default on every call — and exposes the knobs to tune or disable it. This guide covers the default retry policy, the `ResilienceOptions` you pass per constructor, the `RateLimiter` service and the streaming pagination that lets a scan stop early. + +## Retry is on by default + +Every `GitHubClient` call — `rest`, `graphql`, `paginate`, `paginateStream` — runs through `withResilience`. On a retryable error it waits, then tries again, up to a bounded number of attempts. An error is retryable when the response status is `429` or `>= 500`; the client sets that flag when it wraps the underlying error into a `GitHubClientError`. Non-retryable errors (a `404`, a `422`, a malformed request) fail immediately — retrying them would only waste time. + +You do not opt in. `GitHubClientLive.fromEnv()` already gives you the resilient client: + +```typescript +import { Effect } from "effect" +import { GitHubClient, GitHubClientLive } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const client = yield* GitHubClient + const { owner, repo } = yield* client.repo + // A transient 503 here is retried automatically before the effect fails. + const data = yield* client.rest("repos.get", (octokit) => + (octokit as { rest: { repos: { get: (p: unknown) => Promise<{ data: { default_branch: string } }> } } }).rest.repos.get({ owner, repo }), + ) + yield* Effect.log(`default branch: ${data.default_branch}`) + // default branch: main (whatever the repo's default is) +}).pipe(Effect.provide(GitHubClientLive.fromEnv())) +``` + +### How the backoff works + +When the server tells the client how long to wait — a `Retry-After` header, or `x-ratelimit-remaining: 0` plus an `x-ratelimit-reset` timestamp — the client honors that delay exactly. The `GitHubClientError` carries it as `retryAfterMs`, and the server-advised value takes precedence over any computed backoff. When there is no server hint, the client falls back to an exponential, jittered, capped backoff: the delay doubles each attempt, is capped per interval, and a full-jitter randomizes it within `[0, capped]` to avoid a thundering herd of retries lining up on the same tick. + +## Tuning resilience per constructor + +Each `GitHubClientLive` constructor takes an optional `ResilienceOptions` as its last argument. Pass it to change the policy for that client: + +```typescript +import { Duration } from "effect" +import { GitHubClientLive } from "@savvy-web/github-action-effects" + +const layer = GitHubClientLive.fromEnv({ + maxRetries: 6, // default 4 + baseDelay: Duration.seconds(2), // default 1s + maxDelay: Duration.seconds(60), // default 30s +}) +``` + +| Option | Default | Meaning | +| --- | --- | --- | +| `enabled` | `true` | Master switch. `false` runs each call once, no retry. | +| `maxRetries` | `4` | Maximum retry attempts for retryable errors. | +| `baseDelay` | `1s` | Base of the exponential schedule. | +| `maxDelay` | `30s` | Cap on any single backoff interval. | + +The same `resilience` argument is accepted by `fromToken(token, resilience?)` and `fromApp(options, resilience?)`. To turn retry off entirely — for a test, or a call where you want failures to surface immediately — pass `{ enabled: false }`: + +```typescript +import { Redacted } from "effect" +import { GitHubClientLive } from "@savvy-web/github-action-effects" + +const bare = GitHubClientLive.fromToken(Redacted.make(token), { enabled: false }) +``` + +## resilienceSchedule for standalone retries + +The backoff schedule is exported as `resilienceSchedule(options?)` so you can apply the same policy to an effect of your own with `Effect.retry`. It builds an Effect `Schedule` that recurs only while the error is `retryable` and stops on the first non-retryable failure, bounded by `maxRetries`: + +```typescript +import { Effect } from "effect" +import { resilienceSchedule } from "@savvy-web/github-action-effects" + +const program = myGitHubClientCall.pipe(Effect.retry(resilienceSchedule({ maxRetries: 3 }))) +``` + +Two differences from the built-in `withResilience` that wraps every client call are worth knowing. `resilienceSchedule` does not consult the server-advised `retryAfterMs` — it always uses its computed backoff — and it jitters multiplicatively rather than with the full-jitter the internal wrapper applies. Reach for `resilienceSchedule` when you want the same shape of policy on a non-client effect; rely on the built-in resilience when server-advised `Retry-After` / `x-ratelimit-reset` delays must be honored, which is the case for the client's own calls. + +## RateLimiter: spend quota deliberately + +Retry handles the call that already failed. `RateLimiter` keeps you from getting there: it guards a call when the remaining quota is nearly gone. It reads the `x-ratelimit-*` headers the client already saw on real responses (shared through `RateLimitState`), so it never burns a request on a pre-flight probe before each guarded call. + +`withRateLimit(effect)` checks the cached quota. If the remaining requests are above 10% of the limit it runs the effect straight through. If they are at or below the threshold it waits until the reset — but only if the reset is within 60 seconds; a longer wait fails with `RateLimitError` instead of stalling the runner. + +```typescript +import { Effect } from "effect" +import { RateLimiter, RateLimiterLive, GitHubClientLive } from "@savvy-web/github-action-effects" +import { Layer } from "effect" + +const program = Effect.gen(function* () { + const limiter = yield* RateLimiter + const data = yield* limiter.withRateLimit(someApiCall) + return data +}).pipe( + Effect.provide(Layer.provide(RateLimiterLive, GitHubClientLive.fromEnv())), +) +``` + +`RateLimiterLive` requires a `GitHubClient`, which is why it is provided beneath it above. + +### checkRest vs checkGraphQL + +The two check methods differ on purpose, because REST and GraphQL have independent quotas on the GitHub API and the shared snapshot only ever records the REST (core) bucket: + +- `checkRest()` serves the cached snapshot when one exists and probes `GET /rate_limit` only on a cache miss. Cache-first — cheap to call repeatedly. +- `checkGraphQL()` always probes the `graphql` resource directly. Serving the cached core-bucket snapshot here would report the wrong quota, so it never does. + +```typescript +import { Effect } from "effect" +import { RateLimiter } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const limiter = yield* RateLimiter + const rest = yield* limiter.checkRest() // cached when available + const graphql = yield* limiter.checkGraphQL() // always a fresh probe + yield* Effect.log(`rest remaining: ${rest.remaining}, graphql remaining: ${graphql.remaining}`) + // rest remaining: 4998, graphql remaining: 5000 (values vary per token and usage) +}) +``` + +## Streaming pagination + +`paginate` collects every page into one array — fine for a few pages, wasteful when you only need the first match in a long list. `paginateStream` yields one page's worth of items at a time as an Effect `Stream`, so you can `Stream.takeWhile` or `Stream.take` and stop without fetching or buffering the rest. Each page fetch is wrapped in the same resilience as a single call. + +```typescript +import { Effect, Stream } from "effect" +import { GitHubClient } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const client = yield* GitHubClient + const { owner, repo } = yield* client.repo + + // Find the first open PR labeled "release" without paging the whole list. + const first = yield* client + .paginateStream<{ number: number; labels: Array<{ name: string }> }>( + "pulls.list", + (octokit, page, perPage) => + (octokit as { rest: { pulls: { list: (p: unknown) => Promise<{ data: Array<{ number: number; labels: Array<{ name: string }> }> }> } } }).rest.pulls.list({ + owner, + repo, + state: "open", + per_page: perPage, + page, + }), + ) + .pipe( + Stream.filter((pr) => pr.labels.some((l) => l.name === "release")), + Stream.take(1), + Stream.runCollect, + ) + yield* Effect.log(`first matching PR: ${first.length > 0 ? "found" : "none"}`) + // first matching PR: found (or "none" when no open PR carries the label) +}) +``` + +Both `paginate` and `paginateStream` default to `perPage: 100` and accept `maxPages` to bound the scan. Prefer `paginateStream` for large or early-terminating scans; reach for `paginate` only when you genuinely need every page materialized at once. For the credential-source constructors behind these calls, see [building a GitHubClient layer](./14-architecture.md#building-a-githubclient-layer). diff --git a/docs/09-step-logging.md b/docs/09-step-logging.md new file mode 100644 index 0000000..13120e2 --- /dev/null +++ b/docs/09-step-logging.md @@ -0,0 +1,96 @@ +# Step-buffered logging patterns + +`Step.withStep` runs an Effect with its debug and info logs captured instead of printed live. On success it emits one summary line and discards the buffer; on failure it prints a `❌ ` header, spills the buffered lines indented underneath, then propagates the original error untouched. The result is a quiet log on the happy path and full context exactly where something broke. Warnings and errors are never buffered — they pass straight through, because they map to GitHub Actions annotations whose UI affordances would be lost if held back. + +`Step` is a top-level namespace, like `Action`. It exports `withStep`, `success`, `failure`, `collapse` and `groupStep`. For the runtime that backs it, see [architecture](./14-architecture.md#runtime-layer). + +## withStep and success + +Wrap a unit of work in `withStep`. Inside the body, call `Step.success(line)` to set the text shown to the right of the library-managed `✅ :` prefix. The library owns the icon and the `:` prefix; the body passes only the outcome. + +```typescript +import { Effect } from "effect" +import { PackagePublish, Step } from "@savvy-web/github-action-effects" + +const packStep = Effect.gen(function* () { + const publisher = yield* PackagePublish + + yield* Step.withStep("pack dist/npm", Effect.gen(function* () { + const result = yield* publisher.pack("./dist/npm") + yield* Effect.logDebug(`packed ${result.tarballPath}`) // buffered, not printed on success + yield* Step.success(`${result.name}@${result.version} (${result.fileCount} files)`) + return result + })) +}) +// On success, one line: ✅ pack dist/npm: my-pkg@1.2.3 (42 files) +``` + +When the step fails, the success line never appears. Instead the header and the buffered debug lines spill, then the error propagates: + +```text +❌ pack dist/npm: ENOENT: no such file or directory + packed /tmp/build/dist/npm (the buffered debug lines, indented) +``` + +The exact paths, byte counts and file counts depend on the build, so treat the numbers above as illustrative. + +## defaultSummary + +When a step's summary is always the same shape, pass a `defaultSummary` builder instead of calling `Step.success` in the body. `withStep` calls it with the step's result if the body never set an explicit summary: + +```typescript +import { Effect } from "effect" +import { Step } from "@savvy-web/github-action-effects" + +const program = Step.withStep( + "resolve versions", + Effect.succeed({ count: 7 }), + { defaultSummary: (result) => `${result.count} packages` }, +) +// On success: ✅ resolve versions: 7 packages +``` + +An explicit `Step.success` inside the body wins over `defaultSummary`. With neither set, the step emits the bare `✅ resolve versions` line. + +## collapse + +`Step.collapse` runs N steps in parallel and reduces them to a single line when they all succeed. The reducer receives `{ name, result }` pairs in input order; return a string to emit that one line instead of N per-step lines, or `null` to abandon the collapse and let each child emit its own line. + +```typescript +import { Effect } from "effect" +import { NpmRegistry, Step } from "@savvy-web/github-action-effects" + +const probeAll = Effect.gen(function* () { + const npm = yield* NpmRegistry + + yield* Step.collapse( + [ + { name: "probe npm", effect: npm.getPublishedIntegrity("@scope/pkg", "1.2.3", { registry: npmUrl }) }, + { name: "probe ghp", effect: npm.getPublishedIntegrity("@scope/pkg", "1.2.3", { registry: ghpUrl }) }, + ], + (results) => `probed ${results.length} registries`, + ) +}) +// On all-success: probed 2 registries +``` + +On any child failure the collapse is abandoned: every child emits its own success line or failure block, and the first failure's cause is then propagated. The concurrency is unbounded — `collapse` is meant for the small fan-out of parallel registry probes or attestations, typically two to four. + +## groupStep + +`Step.groupStep` wraps an Effect in both `ActionLogger.group` and `withStep`. That is the natural outer scope for a phase: a collapsible GitHub Actions block with a step summary at the end. It adds `ActionLogger` to the requirements channel, which `ActionsRuntime.Default` already provides. + +```typescript +import { Effect } from "effect" +import { Action, Step } from "@savvy-web/github-action-effects" + +const phase = Step.groupStep("Publish", Effect.gen(function* () { + // ... pack, probe, publish, attest ... + yield* Step.success("3 packages published") +})) + +Action.run(phase) +// Renders a collapsible "Publish" group; on success: ✅ Publish: 3 packages published +``` + +For a non-fatal failure inside a loop — one registry rejecting a publish while siblings succeed — call `Step.failure(line)` instead of failing the effect. The step renders the same `❌` block and buffer spill, but resolves with its value so the surrounding loop can keep going and aggregate the outcome. diff --git a/docs/10-slsa-attestations.md b/docs/10-slsa-attestations.md new file mode 100644 index 0000000..391bb60 --- /dev/null +++ b/docs/10-slsa-attestations.md @@ -0,0 +1,167 @@ +# Generating SLSA attestations + +An attestation is a signed in-toto statement that ties an artifact's digest to a claim about how it was built. The `Attest` cluster signs those statements into a Sigstore bundle and uploads them to GitHub's attestation store, so a downstream verifier can confirm a tarball came out of a specific workflow run (provenance) and what went into it (an SBOM). This guide wires the four services involved — `OidcTokenIssuer`, `SigstoreSigner`, `GitHubClient` and `Attest` — and walks the two attestation shapes plus the idempotent-recovery path. + +For a per-service interface recap, see the [services guide](./03-services.md#attestation-services). The publish chain that produces the tarball digest these attestations describe is in [publishing packages](./11-publishing.md). + +## Requirements + +Attestation needs two workflow permissions. Without them the runner does not expose the environment the cluster reads: + +```yaml +permissions: + id-token: write # exposes ACTIONS_ID_TOKEN_REQUEST_TOKEN / _URL for OIDC + attestations: write # allows POST /repos/{owner}/{repo}/attestations +``` + +`id-token: write` is what lets `OidcTokenIssuer` request the OIDC token Sigstore's Fulcio uses to issue a short-lived signing certificate. `attestations: write` is what lets the upload land. The repo-scoped `GITHUB_TOKEN` carries the `attestations: write` scope once the permission is declared, so `GitHubClientLive.fromEnv()` is enough for the upload. + +## The layer stack + +The attestation methods carry their dependencies in the requirements channel rather than baking them in, so you compose the stack at the call site. `Attest.provenance`, `Attest.sbom` and `Attest.attest` each require `SigstoreSigner | OidcTokenIssuer | GitHubClient`; `Attest.buildBundle` (sign without uploading) needs only `SigstoreSigner | OidcTokenIssuer`; `Attest.sbom` additionally requires `Sbom` when it builds the BOM itself. + +```typescript +import { Layer } from "effect" +import { + AttestLive, + GitHubClientLive, + OidcTokenIssuerLive, + SbomLive, + SigstoreSignerLive, +} from "@savvy-web/github-action-effects" + +// AttestLive depends on the signing + token + client services beneath it. +const AttestationLayer = Layer.provide( + AttestLive, + Layer.mergeAll( + SigstoreSignerLive, + OidcTokenIssuerLive, + GitHubClientLive.fromEnv(), + SbomLive, + ), +) +``` + +`OidcTokenIssuerLive` and `SigstoreSignerLive` read no inputs; `GitHubClientLive.fromEnv()` reads the ambient `GITHUB_TOKEN`. Swap `fromEnv()` for `fromToken(token)` when the upload should run as an explicit identity. + +## Provenance + +Provenance records who built the artifact and how. The caller assembles the SLSA Provenance v1 predicate from the runner's OIDC claims, then hands it to `Attest.provenance`. The two pure helpers `decodeJwtClaims` and `buildSLSAProvenancePredicate` produce a predicate shaped to match what `@actions/attest` emits, so verifiers see the same `buildDefinition` / `runDetails` structure regardless of which tool wrote it. + +```typescript +import { Effect, Redacted } from "effect" +import { + Attest, + buildSLSAProvenancePredicate, + decodeJwtClaims, + OidcTokenIssuer, + SIGSTORE_OIDC_AUDIENCE, +} from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const issuer = yield* OidcTokenIssuer + const attest = yield* Attest + + // 1. Request an OIDC token scoped to the Sigstore audience. The token is + // Redacted — unwrap it only to feed the pure JWT decoder. + const token = yield* issuer.getToken(SIGSTORE_OIDC_AUDIENCE) + const claims = yield* decodeJwtClaims(Redacted.value(token)) + + // 2. Build the SLSA v1 predicate from the claims + runner environment. + const predicate = yield* buildSLSAProvenancePredicate(claims) + + // 3. Sign and upload. `subjectSha256` is the hex digest of the tarball + // (PackResult.sha256Hex from the publish chain). + const record = yield* attest.provenance({ + subjectName: "pkg:npm/@scope/pkg@1.2.3", + subjectSha256: tarballSha256Hex, + predicate, + }) + + yield* Effect.log(`Provenance attestation: ${record.attestationUrl}`) + // e.g. https://github.com/{owner}/{repo}/attestations/{id} (id varies per run) +}) +``` + +`SIGSTORE_OIDC_AUDIENCE` is the `"sigstore"` audience Fulcio expects. The returned `AttestationRecord` carries the local `statement` and `bundle`, the numeric `attestationId` and the UI `attestationUrl`. + +## SBOM + +An SBOM attestation records the artifact's bill of materials. `Attest.sbom` accepts the BOM one of two mutually exclusive ways: + +- **`dependencies`** — hand the service the resolved dependency list and it builds a CycloneDX 1.5 BOM for you. +- **`bomDocument`** — hand the service a pre-built BOM (typically from `Sbom.generate` + `Sbom.serializeJson`, with full NTIA supplier metadata) and it attests that document verbatim. The library does not validate the BOM's NTIA fields on this path: the contract is "you give us the BOM, we attest it". + +Supplying neither falls back to an empty-deps BOM and logs a debug warning. New code should always pick one path. + +```typescript +import { Effect } from "effect" +import { Attest, Sbom } from "@savvy-web/github-action-effects" + +// Path A — let Attest build the BOM from a resolved dependency list. +const fromDeps = Effect.gen(function* () { + const attest = yield* Attest + const record = yield* attest.sbom({ + rootName: "@scope/pkg", + rootVersion: "1.2.3", + subjectSha256: tarballSha256Hex, + dependencies: [ + { name: "effect", version: "3.18.4" }, + { name: "@octokit/rest", version: "21.0.2" }, + ], + }) + return record +}) + +// Path B — generate a fully-populated BOM first, then attest it verbatim. +const fromDocument = Effect.gen(function* () { + const sbom = yield* Sbom + const attest = yield* Attest + + const bom = yield* sbom.generate({ + rootName: "@scope/pkg", + rootVersion: "1.2.3", + supplier: { name: "Acme, Inc." }, + dependencies: [{ name: "effect", version: "3.18.4" }], + }) + const json = yield* sbom.serializeJson(bom) + + const record = yield* attest.sbom({ + rootName: "@scope/pkg", + rootVersion: "1.2.3", + subjectSha256: tarballSha256Hex, + bomDocument: JSON.parse(json) as Record, + }) + yield* Effect.log(`SBOM attestation predicateType: ${record.statement.predicateType}`) + // SBOM attestations carry predicateType https://cyclonedx.org/bom + return record +}) +``` + +## Idempotent recovery + +A re-run should not write a second attestation for the same artifact. `Attest.listForSubject` reads the existing attestations for a tarball digest so the orchestrator can reuse a URL instead of writing a fresh one. + +```typescript +import { Effect } from "effect" +import { Attest, CYCLONEDX_BOM } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const attest = yield* Attest + + // List existing attestations for this artifact, filtered to SBOM entries. + const existing = yield* attest.listForSubject(tarballSha256Hex, { + predicateType: CYCLONEDX_BOM, + }) + + if (existing.length > 0) { + yield* Effect.log(`Reusing SBOM attestation: ${existing[0].attestationUrl}`) + // reused URL placeholder — points at the already-written attestation + } else { + yield* Effect.log("No SBOM attestation found; writing a fresh one") + // ... call attest.sbom(...) here + } +}) +``` + +The `predicateType` filter is applied **client-side**: `listForSubject` fetches every attestation for the digest, parses the in-toto statement out of each Sigstore bundle, reads its `predicateType` and keeps the matching entries. It is not a server query parameter — the GitHub REST API does not expose one. Pass `https://slsa.dev/provenance/v1` (or use no filter) to ask the same question about provenance. The call returns an empty array when the subject has no attestations or the API returns a 404; only genuine failures (auth, network, rate limit) surface as `AttestError`. diff --git a/docs/11-publishing.md b/docs/11-publishing.md new file mode 100644 index 0000000..aaa6d6f --- /dev/null +++ b/docs/11-publishing.md @@ -0,0 +1,141 @@ +# Publishing packages with the publish chain + +The recommended way to publish is a three-step chain: `pack` the package directory into a tarball, probe the registry for what is already published, then `publishTarball` the exact bytes you packed when they differ. Composing those steps yourself — rather than the older fused `publishIdempotent` — is what makes a re-run recover cleanly across multiple registries. + +For the `PackagePublish`, `NpmRegistry` and `RegistryClassifier` interface recaps, see the [services guide](./03-services.md#package-management). For attesting the tarball you publish, see [generating SLSA attestations](./10-slsa-attestations.md). + +## The chain + +`pack(packageDir)` returns a `PackResult` with two distinct digests. They describe the same bytes but are not interchangeable: + +- **`digest`** — npm's `dist.integrity` format, `sha512-`. Sourced from `npm pack --json` so it matches byte-for-byte what the registry stores. Compare this against the registry to decide whether to publish. +- **`sha256Hex`** — a lowercase hex SHA-256 of the tarball, no `sha256:` prefix. This is the subject digest the GitHub attestation and artifact-metadata APIs accept. Use this when attesting. + +Reaching for `digest` where `sha256Hex` is expected (or the reverse) produces a silent mismatch: different algorithm, different encoding. `PackResult` also carries `tarballPath`, `name`, `version`, `packedSize`, `unpackedSize` and `fileCount`. + +## Probe then publish + +Compare the packed `digest` against the registry's published integrity. Skip the upload when they match; `publishTarball` the packed file otherwise. `getPublishedIntegrity` returns `Option.none()` when the version is not on that registry — a normal branch of the flow, not an error. + +```typescript +import { Effect, Option } from "effect" +import { + Attest, + CYCLONEDX_BOM, + NpmRegistry, + PackagePublish, +} from "@savvy-web/github-action-effects" + +const registry = "https://registry.npmjs.org/" + +const program = Effect.gen(function* () { + const publisher = yield* PackagePublish + const npm = yield* NpmRegistry + const attest = yield* Attest + + // 1. Pack once. The tarball is reused for the upload and the attestation. + const packed = yield* publisher.pack("./dist/npm") + + // 2. Probe the target registry for the already-published integrity. + const published = yield* npm.getPublishedIntegrity(packed.name, packed.version, { registry }) + + // 3. Skip when identical, publish the exact tarball otherwise. + if (Option.isSome(published) && published.value === packed.digest) { + yield* Effect.log("status: skipped (already-published-identical)") + } else { + yield* publisher.publishTarball(packed.tarballPath, { registry, access: "public" }) + yield* Effect.log("status: published") + } + + // 4. Reuse-or-write the attestation against the SHA-256 subject. + const existing = yield* attest.listForSubject(packed.sha256Hex, { predicateType: CYCLONEDX_BOM }) + if (existing.length > 0) { + yield* Effect.log(`reused attestation: ${existing[0].attestationUrl}`) + // reused URL placeholder — points at the already-written attestation + } +}) +``` + +When a recovery run finds the version already published with a matching integrity and an attestation already written for the digest, it does nothing on either front — that idempotent behaviour is the whole reason the chain exists. + +## RegistryTarget and package managers + +`publishToRegistries(packageDir, targets)` walks a list of `RegistryTarget` entries in sequence. Each target carries the `registry` URL, the `token`, an optional `tag` and `access`, and an optional `packageManager`: + +```typescript +import type { RegistryTarget } from "@savvy-web/github-action-effects" + +const targets: ReadonlyArray = [ + { registry: "https://registry.npmjs.org/", token: npmToken, access: "public", packageManager: "pnpm" }, + { registry: "https://npm.pkg.github.com/", token: ghToken }, +] +``` + +`packageManager` (`"npm" | "pnpm" | "yarn" | "bun"`, default `"npm"`) controls how the publish is dispatched. The non-`npm` dispatchers — `pnpm dlx npm`, `yarn npm`, `bun x npm` — fetch a fresh `npm` rather than the runner's bundled one. That matters for OIDC trusted publishing, which needs npm ≥ 11.5.1; GitHub-hosted runners on Node 24 still ship npm 10.x. Set it to match your project's package manager when you publish with provenance. + +## Dry runs + +`dryRun(packageDir, options?)` simulates `npm publish --dry-run`. It returns a `DryRunResult` with `ok`, the packed sizes and file count, and the raw npm `output`: + +```typescript +import { Effect } from "effect" +import { PackagePublish } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const publisher = yield* PackagePublish + const result = yield* publisher.dryRun("./dist/npm", { registry: "https://registry.npmjs.org/" }) + yield* Effect.log(`would publish: ${result.ok}`) + // would publish: true (or false on a version conflict / bad auth) +}) +``` + +A non-zero npm exit — bad auth, unreachable registry, version conflict — comes back as `ok: false`, not as a failure. The error channel is reserved for a structural problem: npm could not be spawned, or its `--json` output could not be parsed. + +## publishIdempotent (deprecated) + +`publishIdempotent` fused the probe and publish into one call. It is deprecated: the fused dispatch hardcoded the default npm registry and could not recover a partial publish across multiple registries. New callers compose `pack` + `getPublishedIntegrity` + `publishTarball` as shown above. It stays exported for the migration window — live callers exist — but do not reach for it in new code. + +```typescript +import { Effect } from "effect" +import { PackagePublish } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const publisher = yield* PackagePublish + const packed = yield* publisher.pack("./dist/npm") + + // Deprecated — kept for the migration window only. + const result = yield* publisher.publishIdempotent({ + packageDir: "./dist/npm", + packageName: packed.name, + version: packed.version, + digest: packed.digest, + }) + yield* Effect.log(`status: ${result.status}`) + // status: skipped (skipReason: "already-published-identical" when nothing changed) +}) +``` + +## Registry classification + +`RegistryClassifier` is a namespace of pure functions that classify a registry URL. The publish flow uses it to label output and to build a package-view URL. Detection parses the URL and matches the hostname; it never substring-matches, so a hostile URL like `http://evil-npmjs.org` does not pass as npm. + +```typescript +import { RegistryClassifier } from "@savvy-web/github-action-effects" + +RegistryClassifier.getRegistryType("https://npm.pkg.github.com/") +// → "github-packages" + +RegistryClassifier.getRegistryType("https://registry.npmjs.org/") +// → "npm" + +RegistryClassifier.getRegistryType(null) +// → "npm" (an absent registry resolves to the public npm registry, the default) + +RegistryClassifier.getRegistryDisplayName("https://npm.pkg.github.com/") +// → "GitHub Packages" + +RegistryClassifier.generatePackageViewUrl("https://registry.npmjs.org/", "@scope/pkg") +// → "https://www.npmjs.com/package/@scope/pkg" +``` + +`getRegistryType` resolves to one of `"npm"`, `"github-packages"`, `"jsr"` or `"custom"`. The boolean predicates `isNpmRegistry`, `isGitHubPackagesRegistry`, `isJsrRegistry` and `isCustomRegistry` answer the same question one registry at a time. `generatePackageViewUrl` returns `undefined` for registries with no standard package-view URL. diff --git a/docs/05-peer-dependencies.md b/docs/12-peer-dependencies.md similarity index 100% rename from docs/05-peer-dependencies.md rename to docs/12-peer-dependencies.md diff --git a/docs/06-error-handling.md b/docs/13-error-handling.md similarity index 71% rename from docs/06-error-handling.md rename to docs/13-error-handling.md index 3fa6e7f..4b764f1 100644 --- a/docs/06-error-handling.md +++ b/docs/13-error-handling.md @@ -19,7 +19,43 @@ const program = Effect.gen(function* () { ) ``` -Each error type carries structured fields — `outputName`, `reason`, `operation` and so on — so the handler knows exactly what failed. See [architecture](./07-architecture.md#error-types) for the full error catalog. +Each error type carries structured fields — `outputName`, `reason`, `operation` and so on — so the handler knows exactly what failed. See [architecture](./14-architecture.md#error-types) for the full error catalog. + +## The attestation and publish error surface + +The attestation cluster and the newer GitHub services add eight tagged errors. Each carries a discriminator — `reason` or `operation` — so a `catchTag` handler can branch on the failing stage without coupling to the implementation. + +| Error | Tag | Discriminator and fields | +| --- | --- | --- | +| `AttestError` | `"AttestError"` | `reason: "build" \| "save" \| "oidc" \| "sign" \| "upload"`, `message` | +| `SbomError` | `"SbomError"` | `reason: "build" \| "serialize" \| "save"`, `message` | +| `SigstoreSignerError` | `"SigstoreSignerError"` | `reason: "sign" \| "witness" \| "bundle"`, `message` | +| `OidcTokenError` | `"OidcTokenError"` | `reason: "env" \| "http" \| "decode" \| "save"`, `message` | +| `SlsaError` | `"SlsaError"` | `reason: "decode" \| "claims" \| "env"`, `message` | +| `GitHubContentError` | `"GitHubContentError"` | `operation: "getFile"`, `reason`, optional `path` | +| `GitHubCommitError` | `"GitHubCommitError"` | `operation: "get" \| "list" \| "compare"`, `reason`, optional `ref` | +| `GitHubArtifactMetadataError` | `"GitHubArtifactMetadataError"` | `operation: "createStorageRecord"`, `reason`, `retryable: boolean` | + +Matching on the discriminator keeps recovery precise — retry only the retryable artifact-metadata failures, fall back only on a `"http"` OIDC failure, surface a `"claims"` `SlsaError` as a configuration problem rather than a transient one. + +```typescript +import { Effect } from "effect" + +const program = Effect.gen(function* () { + // ... attestation work ... +}).pipe( + Effect.catchTag("OidcTokenError", (e) => + e.reason === "env" + ? Effect.logError("Missing id-token: write permission") + : Effect.logError(`OIDC token request failed: ${e.message}`), + ), + Effect.catchTag("GitHubArtifactMetadataError", (e) => + e.retryable + ? Effect.logWarning(`Retryable: ${e.reason}`) + : Effect.logError(`Storage record failed: ${e.reason}`), + ), +) +``` ## Action.formatCause diff --git a/docs/07-architecture.md b/docs/14-architecture.md similarity index 61% rename from docs/07-architecture.md rename to docs/14-architecture.md index f725580..3409671 100644 --- a/docs/07-architecture.md +++ b/docs/14-architecture.md @@ -44,7 +44,7 @@ Formats GitHub Actions workflow commands following the `::command::` protocol: ::endgroup:: ``` -Handles value escaping (`%`, `\r`, `\n`) and property escaping (`%`, `\r`, `\n`, `:`, `,`). +Handles value escaping (`%`, `\r`, `\n`) and property escaping (`%`, `\r`, `\n`, `:`, `,`). Beyond `format` / `issue`, it exposes the named commands that have no Effect log level: `notice(properties, message)` for `::notice::`, `stopCommands(token)` / `resumeCommands(token)` to suspend and resume command processing, and `setCommandEcho(enabled)` for `::echo::on` / `::echo::off`. `ActionLogger.notice` is the Effect-level entry point built on `WorkflowCommand.notice`. ### RuntimeFile @@ -101,6 +101,7 @@ Provides: - `ActionLogger` for group markers and buffered logging - `ActionEnvironment` for reading GitHub/runner context variables - `FileSystem` (Node.js) required by output and state services +- `HttpClient` (fetch-backed) required by `GitHubAppLive`, `ActionCacheLive`, `ArtifactLive` and `OidcTokenIssuerLive` ## Action.run helper @@ -111,12 +112,12 @@ Provides: Action.run(program) // With additional layers -Action.run(program, { layer: Layer.mergeAll(GitHubClientLive.fromEnv, DryRunLive) }) +Action.run(program, { layer: Layer.mergeAll(GitHubClientLive.fromEnv(), DryRunLive) }) ``` It handles: -- Providing `ActionsRuntime.Default` (ConfigProvider, Logger, core services, Node.js FileSystem) +- Providing `ActionsRuntime.Default` (ConfigProvider, Logger, core services, Node.js FileSystem, fetch-backed HttpClient) - Wrapping the program in `ActionLogger.withBuffer` for buffered output - Catching all errors via `Effect.catchAllCause` and emitting `::error::` workflow commands - Setting `process.exitCode = 1` on failure @@ -134,6 +135,7 @@ Covers the two GitHub Actions logging operations the Effect Logger leaves out | --- | --- | --- | | `group` | `(name, effect) => Effect` | Run an effect inside a collapsible log group | | `withBuffer` | `(label, effect) => Effect` | Run an effect with buffered logging (buffer-on-failure pattern) | +| `notice` | `(message, properties?) => Effect` | Emit a `::notice::` annotation (mirrors `@actions/core.notice`) | `ActionLogger` has no annotation methods. Workflow annotations come from logging through Effect itself: call `Effect.logError` or `Effect.logWarning` with `file`, `line` and `col` log annotations, and `ActionsLogger` turns those into `::error file=...,line=...::` and `::warning file=...::` workflow commands. See the `ActionsLogger` section above for the level-to-command mapping. @@ -188,8 +190,114 @@ All error types use `Data.TaggedError` for structural equality and pattern match - **Tag**: `"ActionStateError"` - **Fields**: `key` (string), `reason` (string), `rawValue` (string or undefined) +### Attestation and publish errors + +The attestation cluster and the newer GitHub services add eight tagged errors. Each carries a `reason` or `operation` discriminator so a handler can branch on the failing stage: + +| Error | Tag | Discriminator and fields | +| --- | --- | --- | +| `AttestError` | `"AttestError"` | `reason: "build" \| "save" \| "oidc" \| "sign" \| "upload"`, `message` | +| `SbomError` | `"SbomError"` | `reason: "build" \| "serialize" \| "save"`, `message` | +| `SigstoreSignerError` | `"SigstoreSignerError"` | `reason: "sign" \| "witness" \| "bundle"`, `message` | +| `OidcTokenError` | `"OidcTokenError"` | `reason: "env" \| "http" \| "decode" \| "save"`, `message` | +| `SlsaError` | `"SlsaError"` | `reason: "decode" \| "claims" \| "env"`, `message` | +| `GitHubContentError` | `"GitHubContentError"` | `operation: "getFile"`, `reason`, optional `path` | +| `GitHubCommitError` | `"GitHubCommitError"` | `operation: "get" \| "list" \| "compare"`, `reason`, optional `ref` | +| `GitHubArtifactMetadataError` | `"GitHubArtifactMetadataError"` | `operation: "createStorageRecord"`, `reason`, `retryable: boolean` | + +See [error handling](./13-error-handling.md#the-attestation-and-publish-error-surface) for `catchTag` examples against these. + Every error class in `src/errors/` extends `Data.TaggedError("Tag")<{ ... }>` directly. There is no intermediate `Base` class. +## The Octokit cast seam + +`AttestLive` uploads through `client.rest(..., (octokit) => ...)`, then guards the handle with `isOctokitLike(octokit)` before calling `octokit.request(...)`. If the provided client is not Octokit-shaped, it throws `"GitHubClient did not provide an Octokit-compatible client"`. + +The trade-off is deliberate. The `GitHubClient` interface is intentionally Octokit-agnostic — most call sites work through `client.rest` / `client.paginate` and never touch a concrete Octokit type. The attestation upload is the one path that needs the raw `octokit.request(route, params)` shape, because `POST /repos/{owner}/{repo}/attestations` is not on a typed Octokit method. Rather than widen the public `GitHubClient` interface to expose Octokit internals everywhere, `AttestLive` narrows the type at runtime in the one place that needs it. The cost is a runtime guard instead of a compile-time guarantee; the benefit is a client interface that stays free of `@octokit/rest` types. + +## The Action.run type-erasure boundary + +`Action.run` merges `ActionsRuntime.Default` with any caller-supplied `options.layer`. Both sides can surface their own remaining requirements (for example `FileSystem` from `@effect/platform`), and `Layer.mergeAll` widens the requires-channel to the union. Internally the merged layer is annotated `Layer.Layer` behind a `noExplicitAny` Biome ignore, and the program is cast back to `Effect` at the `runPromise` boundary. + +The `any` is a type-erasure seam, not a type hole. The two public overloads keep call-site safety intact: + +```typescript +(program: Effect.Effect, options?: ActionRunOptions): Promise +(program: Effect.Effect, options: ActionRunOptions): Promise +``` + +A caller still names exactly the services its program requires (`CoreServices`, plus any `R` it provides through `options.layer`). The internal erasure exists only so callers do not have to construct a single concrete `Layer<…>` annotation that spells out every transitively-required service of the merged stack. By the time the program reaches `runPromise`, the layer has resolved every requirement, so no requirements actually remain — the cast is sound. + +## Replacing @actions/* packages + +Native ESM stands in for every `@actions/*` package. This table is the at-a-glance lookup; for the narrative migration walkthrough with a worked example per package, see [coming from `@actions/*`](./06-toolkit-parity.md). The mapping: + +| `@actions/*` | Replacement | Notes | +| --- | --- | --- | +| `@actions/core` (inputs) | `Config.*` via `ActionsConfigProvider`, plus `ActionInput.boolean` / `multiline` | reads `INPUT_*`; `ActionInput` adds YAML 1.2 boolean + multiline parity | +| `@actions/core` (outputs/state) | `ActionOutputs` / `ActionState` | via `RuntimeFile` | +| `@actions/core` (logging) | Effect `Logger` via `ActionsLogger` + `ActionLogger` | level→command mapping; `ActionLogger.notice` for `::notice::` | +| `@actions/core` (commands) | `WorkflowCommand` | `notice` / `stopCommands` / `resumeCommands` / `setCommandEcho` | +| `@actions/core` (paths) | `PathUtils` | `toPosixPath` / `toWin32Path` / `toPlatformPath` | +| `@actions/github` (context) | `ActionEnvironment` (`payload` / `repo` / `issue` / `isDebug`) + `WebhookPayload` | parsed `GITHUB_EVENT_PATH` | +| `@actions/github` (API) | `GitHubClient` (+ `@octokit/rest`) | direct dep, not `@actions/github` | +| `@actions/exec` | `CommandRunner` | `node:child_process` | +| `@actions/io` (`which`/`findInPath`) | `IoUtil.which` / `IoUtil.whichOrFail` / `IoUtil.findInPath` | namespace reading `FileSystem` | +| `@actions/io` (`cp`/`mv`/`rmRF`/`mkdirP`) | `@effect/platform` `FileSystem` | `copy` / `rename` / `remove` / `makeDirectory` | +| `@actions/glob` | `Glob` service | `Glob.glob` + `Glob.hashFiles` (SHA-256 hash-of-hashes, `@actions/glob`-compatible) | +| `@actions/http-client` | `@effect/platform` `HttpClient` | idiomatic substitute; no new export | +| `@actions/cache` | `ActionCache` | V2 Twirp + Azure Blob | +| `@actions/artifact` | `Artifact` service | V2 Twirp + Azure Block Blob; runs inside a JS action only | +| `@actions/tool-cache` | `ToolInstaller` | `node:https`/`http` + child_process | +| `@actions/attest` | `Attest` cluster | Sigstore + GitHub attestation store | + +The `IoUtil`, `Glob` and `Artifact` rows reflect the filesystem, glob and artifact work that shipped in this release. The `@actions/io` `cp`/`mv`/`rmRF`/`mkdirP` helpers are deliberately NOT wrapped — they map onto `@effect/platform` `FileSystem` (`copy` / `rename` / `remove` / `makeDirectory`) directly. See [filesystem I/O](./15-filesystem-io.md) for the operation-by-operation recipe. + +## Upgrading to 2.0 + +The 2.0 release changes a handful of layer signatures. If you wire through `ActionsRuntime.Default` or `Action.run`, no changes are needed — both bundle the new requirements. The migration affects only consumers that compose layers by hand. + +### Scopes and the fromApp / fromEnv constructors + +`GitHubClientLive.fromApp` is now a scoped layer: it acquires the installation token when built and revokes it on scope close, so building the layer needs a `Scope`. Wrap a bare `Effect.provide` in `Effect.scoped`. `fromApp` also requires `HttpClient.HttpClient` and takes `privateKey` as a `Redacted`: + +```typescript +import { Effect, Redacted } from "effect" +import { FetchHttpClient } from "@effect/platform" +import { GitHubClient, GitHubClientLive } from "@savvy-web/github-action-effects" + +const program = Effect.gen(function* () { + const client = yield* GitHubClient + // ... +}).pipe( + Effect.provide( + GitHubClientLive.fromApp({ clientId, privateKey: Redacted.make(pem) }), + ), + Effect.provide(FetchHttpClient.layer), // 2.0: fromApp needs an HttpClient + Effect.scoped, // 2.0: fromApp acquires the token in a scope +) +``` + +`GitHubClientLive.fromEnv` is now a function — call `fromEnv()` where you previously referenced the bare property. + +### Redacted secret-bearing signatures + +The secret-bearing APIs now take `Redacted` instead of plain `string`: `GitHubApp.generateToken` / `resolveAppIdentity` / `revokeToken` / `withToken`, `GitHubClientLive.fromToken` / `fromApp`, and `PackagePublish.setupAuth` / `RegistryTarget.token`. `InstallationToken.token` now decodes to `Redacted` — read it with `Redacted.value` where you need the plain string. Wrap raw tokens with `Redacted.make` before passing them in. + +### HttpClient requirement + +`GitHubAppLive` and `ActionCacheLive` now require `HttpClient.HttpClient` in their requirements channel. Provide `FetchHttpClient.layer` from `@effect/platform`, or use `ActionsRuntime.Default`, which now bundles it: + +```typescript +import { Layer } from "effect" +import { FetchHttpClient } from "@effect/platform" +import { ActionCacheLive } from "@savvy-web/github-action-effects" + +const cacheLayer = Layer.provide(ActionCacheLive, FetchHttpClient.layer) +``` + +The filesystem, glob and artifact services (`IoUtil`, `Glob`) and the input/event/notice helpers added in 2.0 are additive — they add new exports without changing existing signatures, so they need no migration. + ## GithubMarkdown namespace Pure functions in `src/utils/GithubMarkdown.ts` for building GitHub Flavored Markdown strings, accessed via the `GithubMarkdown` namespace. None of these have side effects or dependencies. @@ -207,6 +315,10 @@ Pure functions in `src/utils/GithubMarkdown.ts` for building GitHub Flavored Mar | `GithubMarkdown.link(text, url)` | Build an inline markdown link | | `GithubMarkdown.list(items)` | Build a bulleted list | | `GithubMarkdown.rule()` | Horizontal rule (`---`) | +| `GithubMarkdown.image(src, alt, options?)` | Build an `` tag (matches `@actions/core` `addImage`) | +| `GithubMarkdown.quote(text, cite?)` | Build a `
` (matches `@actions/core` `addQuote`) | + +`image` and `quote` interpolate their values raw, not HTML-escaped — matching `@actions/core`, which relies on GitHub sanitizing step-summary HTML server-side. Escape the values yourself if you embed the output elsewhere with untrusted input. ### Schemas @@ -248,16 +360,16 @@ See [services guide](./03-services.md) for usage examples of each service. | Constructor | Signature | Result type | | --- | --- | --- | -| `fromEnv` | property, no arguments | `Layer` | -| `fromToken` | `(token: string \| Redacted)` | `Layer` | -| `fromApp` | `({ clientId, privateKey, installationId? })` | `Layer` | +| `fromEnv` | `(resilience?)`, no required arguments | `Layer` | +| `fromToken` | `(token: Redacted, resilience?)` | `Layer` | +| `fromApp` | `({ clientId, privateKey: Redacted, installationId? }, resilience?)` | `Layer` | -- `fromEnv` reads `process.env.GITHUB_TOKEN`, the repo-scoped workflow token. It fails with `GitHubClientError` if the variable is unset. -- `fromToken` takes a token you constructed — a plain `string` or a `Redacted`. It has no `process.env` dependency and cannot fail, so its error channel is `never`. -- `fromApp` generates a fresh installation token from GitHub App credentials. It composes `GitHubAppLive` and `OctokitAuthAppLive` internally, so the consumer does not provide them. Each time the layer is built it generates a new token; for the pre/main/post pattern where one token is shared across phases, use `GitHubToken` instead. +- `fromEnv()` reads `process.env.GITHUB_TOKEN`, the repo-scoped workflow token. It is a function — call it with no arguments, or pass `ResilienceOptions` to tune retries. It fails with `GitHubClientError` if the variable is unset. +- `fromToken` takes a token you constructed. The token must be a `Redacted` — wrap a bare string with `Redacted.make(...)` at the call site. It has no `process.env` dependency and cannot fail, so its error channel is `never`. +- `fromApp` mints an installation token from GitHub App credentials, with `privateKey` as a `Redacted`. It composes `GitHubAppLive` and `OctokitAuthAppLive` internally, leaving only `HttpClient.HttpClient` to provide. It is a scoped layer: the minted token is revoked on scope close, so a bare `Effect.provide` must be wrapped in `Effect.scoped` (the `ActionsRuntime.Default` / `Action.run` path already scopes). For the pre/main/post pattern where one token is shared across phases, use `GitHubToken` instead. ```typescript -import { Effect } from "effect" +import { Effect, Redacted } from "effect" import { GitHubClient, GitHubClientLive } from "@savvy-web/github-action-effects" const program = Effect.gen(function* () { @@ -266,7 +378,7 @@ const program = Effect.gen(function* () { return yield* client.rest("repos.get", (octokit) => octokit.rest.repos.get({ owner, repo }), ) -}).pipe(Effect.provide(GitHubClientLive.fromToken(process.env.MY_TOKEN ?? ""))) +}).pipe(Effect.provide(GitHubClientLive.fromToken(Redacted.make(process.env.MY_TOKEN ?? "")))) ``` ### GitHubToken namespace @@ -355,4 +467,4 @@ All extended services ship with test layers following the same pattern: The namespace pattern lets tests inspect captured operations after the effect completes. -See [testing GitHub Actions](./08-testing.md) for usage details and [common patterns](./04-patterns.md) for common testing patterns. +See [testing GitHub Actions](./16-testing.md) for usage details and [common patterns](./04-patterns.md) for common testing patterns. diff --git a/docs/09-filesystem-io.md b/docs/15-filesystem-io.md similarity index 69% rename from docs/09-filesystem-io.md rename to docs/15-filesystem-io.md index 8d23f39..6b2e048 100644 --- a/docs/09-filesystem-io.md +++ b/docs/15-filesystem-io.md @@ -1,18 +1,13 @@ # Filesystem I/O (`@actions/io` parity) -This library closes the `@actions/io` gap with two complementary pieces: +`@actions/io` splits into two parts here: -1. **`IoUtil`** — a namespace for the lookups that have no `@effect/platform` - equivalent: locating a binary on `PATH` (`which` / `findInPath`). -2. **A substitution recipe** for `cp` / `mv` / `rmRF` / `mkdirP`, which map - directly onto `@effect/platform` `FileSystem` and need no wrappers. +1. **`IoUtil`** — a namespace for the lookups with no `@effect/platform` equivalent: locating a binary on `PATH` (`which` / `findInPath`). +2. **A substitution recipe** for `cp` / `mv` / `rmRF` / `mkdirP`, which map straight onto `@effect/platform` `FileSystem` and need no wrappers. ## `IoUtil` — `which` / `findInPath` -`IoUtil` is a namespace object (like `RegistryClassifier` and `SemverResolver`), -not a service. Its functions return `Effect` values and read `FileSystem` from -context. `FileSystem` is already provided by `ActionsRuntime.Default`, so inside -`Action.run` no extra wiring is needed. +`IoUtil` is a namespace object, like `RegistryClassifier` and `SemverResolver` — not a service. Its functions return `Effect` values and read `FileSystem` from context. `ActionsRuntime.Default` already provides `FileSystem`, so inside `Action.run` you wire up nothing extra. ```typescript import { IoUtil } from "@savvy-web/github-action-effects" @@ -42,24 +37,15 @@ const program = Effect.gen(function* () { Behavior mirrors `@actions/io`: - `PATH` is split on the OS path delimiter; empty segments are dropped. -- A `tool` that already contains a path separator is resolved directly rather - than searched on `PATH`. -- **POSIX:** a candidate is executable when it is a regular file with any - execute bit set (`mode & 0o111`). The platform `FileSystem.File.Info.mode` is - the raw numeric stat mode, so this check works directly; a defensive - `fs.access(path, X_OK)` probe covers the rare zero-mode case. -- **Windows:** every `PATHEXT` extension is tried for each PATH directory; - extension membership (not execute bits) is the test. - -The two `which` shapes replace `@actions/io`'s overloaded `check` boolean: the -common "is it installed?" case stays in the success channel (`which`), while the -"must exist" case (`whichOrFail`) puts the failure in the typed error channel. +- A `tool` that already contains a path separator is resolved directly rather than searched on `PATH`. +- **POSIX:** a candidate is executable when it is a regular file with any execute bit set (`mode & 0o111`). The platform `FileSystem.File.Info.mode` is the raw numeric stat mode, so this check works directly; a defensive `fs.access(path, X_OK)` probe covers the rare zero-mode case. +- **Windows:** every `PATHEXT` extension is tried for each PATH directory; extension membership, not execute bits, is the test. + +The two `which` shapes replace `@actions/io`'s overloaded `check` boolean. The common "is it installed?" case stays in the success channel (`which`); the "must exist" case (`whichOrFail`) puts the failure in the typed error channel. ## `cp` / `mv` / `rmRF` / `mkdirP` → `@effect/platform` `FileSystem` -These four `@actions/io` helpers map cleanly onto `FileSystem`, which is already -in context everywhere via `ActionsRuntime.Default`. Adding wrapper functions -would be redundant — use `FileSystem` directly. +These four `@actions/io` helpers map cleanly onto `FileSystem`, which `ActionsRuntime.Default` already puts in context everywhere. A wrapper would just add a layer of indirection over a method that already does the job — use `FileSystem` directly. > Verified against `@effect/platform` `0.96.1`: `copy` (with `overwrite`), > `rename`, `remove` (with `recursive` / `force`) and `makeDirectory` (with diff --git a/docs/08-testing.md b/docs/16-testing.md similarity index 100% rename from docs/08-testing.md rename to docs/16-testing.md diff --git a/docs/README.md b/docs/README.md index 9264105..1dd0d5a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,11 +16,18 @@ pnpm add @savvy-web/github-action-effects effect @effect/platform @effect/platfo - [Advanced action: three-stage app](./02-advanced-action.md) — A complete pre/main/post action with GitHub App auth, cross-phase state and buffered logging. - [Services guide](./03-services.md) — A usage example for every service in the library. - [Common patterns](./04-patterns.md) — Dry-run mode, error accumulation, permission checks and workspace detection. -- [Peer dependencies](./05-peer-dependencies.md) — Which packages to install and why. -- [Error handling](./06-error-handling.md) — `Action.formatCause`, `Action.run` error handling and the `[Tag] message` format. -- [Architecture](./07-architecture.md) — The runtime layer, layer composition and the logging pipeline. -- [Testing GitHub Actions](./08-testing.md) — How to test an action with in-memory test layers. -- [Filesystem I/O](./09-filesystem-io.md) — `IoUtil` (`which`/`findInPath`) and the `cp`/`mv`/`rmRF`/`mkdirP` → `FileSystem` recipe. +- [Building a robust action](./05-best-practices.md) — Principles and pointers: wiring, the pre/main/post pattern, dry runs, permission checks, idempotency and secret handling. +- [Coming from `@actions/*`](./06-toolkit-parity.md) — The migration map from each `@actions/*` package to its native ESM replacement. +- [Logging and error handling](./07-logging-and-error-handling.md) — The log-level model, groups, buffered output, annotations, secret masking and the error-handling boundary. +- [Resilient GitHub API calls](./08-resilient-github-api.md) — Default-on retry, `ResilienceOptions`, the `RateLimiter` service and streaming pagination. +- [Step-buffered logging patterns](./09-step-logging.md) — Quiet-on-success, verbose-on-failure step logging with `withStep`, `collapse` and `groupStep`. +- [Generating SLSA attestations](./10-slsa-attestations.md) — Provenance and SBOM attestations, the layer stack and idempotent recovery. +- [Publishing packages with the publish chain](./11-publishing.md) — Pack, probe and publish a tarball, plus registry classification. +- [Peer dependencies](./12-peer-dependencies.md) — Which packages to install and why. +- [Error handling](./13-error-handling.md) — `Action.formatCause`, `Action.run` error handling and the `[Tag] message` format. +- [Architecture](./14-architecture.md) — The runtime layer, layer composition and the logging pipeline. +- [Filesystem I/O](./15-filesystem-io.md) — `IoUtil` (`which`/`findInPath`) and the `cp`/`mv`/`rmRF`/`mkdirP` → `FileSystem` recipe. +- [Testing GitHub Actions](./16-testing.md) — How to test an action with in-memory test layers. ## How inputs work @@ -42,17 +49,19 @@ const program = Effect.gen(function* () { | Service | Purpose | | --- | --- | -| ActionLogger | Collapsible log groups (group) and buffer-on-failure logging (withBuffer) | +| ActionLogger | Collapsible log groups (group), buffer-on-failure logging (withBuffer) and `::notice::` annotations (notice) | | ActionOutputs | Typed outputs (set, setJson, summary, exportVariable, addPath, setFailed, setSecret) | | ActionState | Schema-serialized state for multi-phase actions (save, get, getOptional) | -| ActionEnvironment | Typed access to `GITHUB_*` and `RUNNER_*` env vars | +| ActionEnvironment | Typed access to `GITHUB_*` and `RUNNER_*` env vars, plus the parsed webhook payload (`payload`, `repo`, `issue`, `isDebug`) | ### Extended services (provide via additional layers) | Service | Purpose | | --- | --- | | ActionCache | Save/restore with withCache bracket pattern | -| GitHubClient | Octokit REST/GraphQL with pagination (uses @octokit/rest directly) | +| Artifact | Upload, list, download and delete GitHub Actions artifacts (`@actions/artifact` v2 parity) | +| Glob | Resolve glob patterns and compute `@actions/glob`-compatible file hashes | +| GitHubClient | Octokit REST/GraphQL with eager (`paginate`) and streaming (`paginateStream`) pagination (uses @octokit/rest directly) | | GitHubGraphQL | Typed GraphQL queries and mutations | | GitHubRelease | Create, update and list releases and assets | | GitHubIssue | List, close, comment and get issues | @@ -91,7 +100,9 @@ const program = Effect.gen(function* () { | `Step` | Step-buffered execution: `withStep`, `success`, `collapse`, `groupStep` | | `GitHubToken` | GitHub App installation-token lifecycle: `provision`, `client`, `read`, `botIdentity`, `dispose` | | `GitHubClientLive` | `GitHubClient` layer constructors: `fromEnv`, `fromToken`, `fromApp` | -| `GithubMarkdown` | Pure GFM builder functions: `table`, `heading`, `bold`, `details`, `checklist`, etc. | +| `ActionInput` | GitHub-faithful `Config` input combinators: `boolean` (YAML 1.2 Core Schema), `multiline` | +| `GithubMarkdown` | Pure GFM builder functions: `table`, `heading`, `bold`, `details`, `checklist`, `image`, `quote`, etc. | +| `PathUtils` | Path-separator normalizers: `toPosixPath`, `toWin32Path`, `toPlatformPath` | | `AutoMerge` | Enable/disable PR auto-merge via GraphQL | | `SemverResolver` | Semver comparison, range satisfaction, increment, parse | | `ErrorAccumulator` | Process items collecting all successes and failures | diff --git a/src/utils/IoUtil.ts b/src/utils/IoUtil.ts index 1578388..9e63b6a 100644 --- a/src/utils/IoUtil.ts +++ b/src/utils/IoUtil.ts @@ -153,7 +153,7 @@ const collectMatches = (fs: FileSystem.FileSystem, tool: string): Effect.Effect< * * `cp` / `mv` / `rmRF` / `mkdirP` are intentionally NOT provided here — they map * directly onto `@effect/platform` `FileSystem` (`copy` / `rename` / `remove` / - * `makeDirectory`). See `docs/09-filesystem-io.md` for the substitution recipe. + * `makeDirectory`). See `docs/15-filesystem-io.md` for the substitution recipe. * * @public */ From c5fa24d94fed96850314839d198de7ab13ba93ff Mon Sep 17 00:00:00 2001 From: "C. Spencer Beggs" Date: Thu, 21 May 2026 18:59:46 -0400 Subject: [PATCH 2/3] docs: correct fromApp scope guidance and type-cast octokit in examples fromApp is a scoped layer, but Scope is not in its requirements channel: a plain Effect.provide revokes the installation token when the program finishes, and Effect.scoped is only needed when sharing one token via Layer.memoize. Correct that wording in the services and architecture guides and the fromApp JSDoc, and cast the unknown octokit handle in the rest, paginate, and paginateStream examples so they compile under tsc strict mode, matching the resilient GitHub API guide. Signed-off-by: C. Spencer Beggs --- docs/03-services.md | 18 +++++++++++------- docs/14-architecture.md | 5 +++-- src/layers/GitHubClientLive.ts | 9 +++++---- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/03-services.md b/docs/03-services.md index 017a236..9487be1 100644 --- a/docs/03-services.md +++ b/docs/03-services.md @@ -236,14 +236,17 @@ import { GitHubClient, GitHubClientLive } from "@savvy-web/github-action-effects const program = Effect.gen(function* () { const client = yield* GitHubClient - // REST API call + // REST API call. The callback receives `octokit: unknown`, so narrow it with + // a structural cast (or `octokit as Octokit` from @octokit/rest) before use. const release = yield* client.rest("getLatestRelease", (octokit) => - octokit.repos.getLatestRelease({ owner: "org", repo: "repo" }) + (octokit as { rest: { repos: { getLatestRelease: (p: unknown) => Promise<{ data: { id: number } }> } } }) + .rest.repos.getLatestRelease({ owner: "org", repo: "repo" }), ) // Paginated REST call — eager, collects every page into one array const issues = yield* client.paginate("listIssues", (octokit, page, perPage) => - octokit.issues.listForRepo({ owner: "org", repo: "repo", page, per_page: perPage }) + (octokit as { rest: { issues: { listForRepo: (p: unknown) => Promise<{ data: Array<{ number: number }> }> } } }) + .rest.issues.listForRepo({ owner: "org", repo: "repo", page, per_page: perPage }), ) // Repository context @@ -255,7 +258,7 @@ const program = Effect.gen(function* () { - `GitHubClientLive.fromEnv()` — a `Layer` that reads the ambient `process.env.GITHUB_TOKEN`, the repo-scoped workflow token. Call it with no arguments, or pass `ResilienceOptions` to tune retries. - `GitHubClientLive.fromToken(token)` — a `Layer` built from an explicit token with no `process.env` dependency. The token must be a `Redacted` — wrap a bare string with `Redacted.make(...)` at the call site. -- `GitHubClientLive.fromApp({ clientId, privateKey, installationId? })` — a `Layer` that mints an installation token from GitHub App credentials. `privateKey` is a `Redacted`. It is a scoped layer: the minted token is revoked on scope close, so a bare `Effect.provide` must be wrapped in `Effect.scoped` (the `ActionsRuntime.Default` / `Action.run` path already scopes for you). It composes `GitHubAppLive` and `OctokitAuthAppLive` internally, leaving only `HttpClient.HttpClient` to provide — use `FetchHttpClient.layer` or `ActionsRuntime.Default`. +- `GitHubClientLive.fromApp({ clientId, privateKey, installationId? })` — a `Layer` that mints an installation token from GitHub App credentials. `privateKey` is a `Redacted`. It is a scoped layer: the minted token is revoked when its scope closes. The scope is managed by the layer itself — `Scope` is not in the requirements channel — so a plain `Effect.provide` revokes the token automatically when the provided program finishes. Reach for `Effect.scoped` only when sharing one token across sub-programs with `Layer.memoize`. It composes `GitHubAppLive` and `OctokitAuthAppLive` internally, leaving only `HttpClient.HttpClient` to provide — use `FetchHttpClient.layer` or `ActionsRuntime.Default`. ```typescript import { Effect, Redacted } from "effect" @@ -269,7 +272,8 @@ const program = Effect.gen(function* () { Effect.provide(GitHubClientLive.fromToken(Redacted.make(process.env.MY_TOKEN ?? ""))), ) -// fromApp is scoped — wrap in Effect.scoped and provide an HttpClient. +// fromApp is scoped — provide an HttpClient; the installation token is revoked +// when `appProgram` finishes, so a plain Effect.provide needs no Effect.scoped. const appProgram = Effect.gen(function* () { const client = yield* GitHubClient const { owner, repo } = yield* client.repo @@ -278,7 +282,6 @@ const appProgram = Effect.gen(function* () { GitHubClientLive.fromApp({ clientId, privateKey: Redacted.make(pem) }), ), Effect.provide(FetchHttpClient.layer), - Effect.scoped, ) ``` @@ -299,7 +302,8 @@ const program = Effect.gen(function* () { const recent = yield* client.paginateStream<{ number: number; closed_at: string | null }>( "listIssues", (octokit, page, perPage) => - octokit.issues.listForRepo({ owner: "org", repo: "repo", state: "all", page, per_page: perPage }), + (octokit as { rest: { issues: { listForRepo: (p: unknown) => Promise<{ data: Array<{ number: number; closed_at: string | null }> }> } } }) + .rest.issues.listForRepo({ owner: "org", repo: "repo", state: "all", page, per_page: perPage }), ).pipe( Stream.takeWhile((issue) => issue.closed_at === null || issue.closed_at > "2026-01-01"), Stream.runCollect, diff --git a/docs/14-architecture.md b/docs/14-architecture.md index 3409671..bd2a9a7 100644 --- a/docs/14-architecture.md +++ b/docs/14-architecture.md @@ -259,7 +259,7 @@ The 2.0 release changes a handful of layer signatures. If you wire through `Acti ### Scopes and the fromApp / fromEnv constructors -`GitHubClientLive.fromApp` is now a scoped layer: it acquires the installation token when built and revokes it on scope close, so building the layer needs a `Scope`. Wrap a bare `Effect.provide` in `Effect.scoped`. `fromApp` also requires `HttpClient.HttpClient` and takes `privateKey` as a `Redacted`: +`GitHubClientLive.fromApp` is now a scoped layer: it acquires the installation token when built and revokes it on scope close. The scope is managed by the layer itself — `Scope` is not in the layer's requirements channel — so a plain `Effect.provide` revokes the token automatically when the provided program finishes. You only need an explicit `Effect.scoped` wrapper when you share one token across sub-programs with `Layer.memoize`. `fromApp` also requires `HttpClient.HttpClient` and takes `privateKey` as a `Redacted`: ```typescript import { Effect, Redacted } from "effect" @@ -274,8 +274,9 @@ const program = Effect.gen(function* () { GitHubClientLive.fromApp({ clientId, privateKey: Redacted.make(pem) }), ), Effect.provide(FetchHttpClient.layer), // 2.0: fromApp needs an HttpClient - Effect.scoped, // 2.0: fromApp acquires the token in a scope ) +// The installation token is revoked when `program` finishes — no Effect.scoped +// needed for a plain provide. Use Effect.scoped with Layer.memoize(fromApp(...)). ``` `GitHubClientLive.fromEnv` is now a function — call `fromEnv()` where you previously referenced the bare property. diff --git a/src/layers/GitHubClientLive.ts b/src/layers/GitHubClientLive.ts index 3e82f01..a9d9d4b 100644 --- a/src/layers/GitHubClientLive.ts +++ b/src/layers/GitHubClientLive.ts @@ -358,10 +358,11 @@ const fromToken = (token: Redacted.Redacted, resilience?: ResilienceOpti * client. Composes `OctokitAuthAppLive` + `GitHubAppLive` internally. * * Builds as a scoped layer: the minted installation token is revoked on scope - * close (best-effort `DELETE /installation/token`). Consumers on - * `ActionsRuntime.Default` / `Action.run` (which wrap in `Effect.scoped`) are - * unaffected; a consumer providing this via a bare `Effect.provide` must wrap in - * `Effect.scoped` to satisfy the build-time `Scope` requirement. + * close (best-effort `DELETE /installation/token`). The scope is managed by the + * layer itself — `Scope` is not in the requirements channel — so a plain + * `Effect.provide` revokes the token when the provided program finishes. An + * explicit `Effect.scoped` is only needed when sharing one token across + * sub-programs via `Layer.memoize` (see the example below). * * Resilient by default; pass `resilience` to tune or disable retries. * From 828e349544a2870853d143610fd127cf08b4ad32 Mon Sep 17 00:00:00 2001 From: "C. Spencer Beggs" Date: Thu, 21 May 2026 19:01:39 -0400 Subject: [PATCH 3/3] chore: squash changesets Signed-off-by: C. Spencer Beggs --- .changeset/major-two-point-oh-release.md | 233 ++++++++++++++++++ .changeset/ws1-foundation-deps-ci.md | 23 -- .changeset/ws2-resilient-github-client.md | 64 ----- .../ws3-effect-quality-observability.md | 57 ----- .changeset/ws4-artifact.md | 29 --- .changeset/ws4-glob-hashfiles.md | 20 -- .changeset/ws4-io-helpers.md | 16 -- .changeset/ws5-toolkit-parity-context.md | 26 -- .changeset/ws6-documentation.md | 31 --- 9 files changed, 233 insertions(+), 266 deletions(-) create mode 100644 .changeset/major-two-point-oh-release.md delete mode 100644 .changeset/ws1-foundation-deps-ci.md delete mode 100644 .changeset/ws2-resilient-github-client.md delete mode 100644 .changeset/ws3-effect-quality-observability.md delete mode 100644 .changeset/ws4-artifact.md delete mode 100644 .changeset/ws4-glob-hashfiles.md delete mode 100644 .changeset/ws4-io-helpers.md delete mode 100644 .changeset/ws5-toolkit-parity-context.md delete mode 100644 .changeset/ws6-documentation.md diff --git a/.changeset/major-two-point-oh-release.md b/.changeset/major-two-point-oh-release.md new file mode 100644 index 0000000..36fa821 --- /dev/null +++ b/.changeset/major-two-point-oh-release.md @@ -0,0 +1,233 @@ +--- +"@savvy-web/github-action-effects": major +--- + +## Breaking Changes + +### `GitHubClientLive.fromApp` now requires a `Scope` + +`fromApp` builds as a scoped layer so it can revoke its installation token on +scope close. Consumers on `ActionsRuntime.Default` / `Action.run` are unaffected +(the run boundary establishes and finalizes the scope automatically). Consumers +who provide `fromApp` via a bare `Effect.provide` must now wrap in +`Effect.scoped`. + +### `GitHubClientLive.fromEnv` is now a constructor function + +`fromEnv` changed from a bare `Layer` value to a function +`(resilience?: ResilienceOptions) => Layer` so it can accept resilience tuning, +matching `fromToken` and `fromApp`. Call it as `GitHubClientLive.fromEnv()` +(or `GitHubClientLive.fromEnv({ enabled: false })` for bare behavior). + +### Secrets are now `Redacted` by default + +Public method signatures that take a token or private key now accept +`Redacted` instead of `string`: `GitHubApp.generateToken` / +`resolveAppIdentity` / `revokeToken` / `withToken`, +`GitHubClientLive.fromToken(token)` / `fromApp({ privateKey })`, and +`PackagePublish.setupAuth(registry, token)` / `RegistryTarget.token`. Wrap bare +strings with `Redacted.make(...)` at the call site. The persisted +`InstallationToken.token` field is now `Redacted` (decoded type) — read +it with `Redacted.value(...)`. The encoded `GITHUB_STATE` bytes are unchanged. + +### `GitHubAppLive` and `ActionCacheLive` require `HttpClient.HttpClient` + +The raw-`fetch` migration adds an `HttpClient.HttpClient` requirement to both +layers. The `Action.run` / `ActionsRuntime.Default` path provides it +automatically (it now bundles `FetchHttpClient.layer`). Consumers that compose +layers manually must add `FetchHttpClient.layer` (from `@effect/platform`). + +## Features + +### Resilient `GitHubClient` — automatic retry and rate-limit awareness + +Every `GitHubClient` call (`rest`, `graphql`, `paginate`, and the new +`paginateStream`) now retries retryable failures (429 and 5xx) automatically +with an exponential, jittered, capped backoff, and honors server-advised delays +from the `Retry-After` and `x-ratelimit-reset` response headers. Resilience is +on by default; every `GitHubClientLive` constructor (`fromEnv`, `fromToken`, +`fromApp`) accepts an optional `ResilienceOptions` argument to tune +`maxRetries` / `baseDelay` / `maxDelay` or disable retries entirely. The pure +`resilienceSchedule` builder is exported for reuse. All 14 `GitHubClient`-backed +services inherit this with no code change. `GitHubClientError` gained an optional +`retryAfterMs` field carrying the server-advised delay. + +### Streaming pagination — `GitHubClient.paginateStream` + +A new `paginateStream` method returns an Effect `Stream` that fetches one page at +a time, so consumers can `takeWhile` / `take` and stop early without buffering or +fetching the remaining pages. The eager `paginate` is unchanged and agrees with +`paginateStream` on page boundaries. + +### Resource safety + +`CommandRunner` and `ToolInstaller` now register interruption finalizers on their +async spawns, so a `timeout` / `race` / interrupt no longer leaks child processes +or download sockets. + +### Secret hardening + +Every token / private key stays `Redacted` end-to-end, unwrapped only at the wire +boundary; the npm auth token is no longer passed as a command argument (it is +written to `.npmrc` directly) and `CommandRunnerError` scrubs known auth-token +args, closing the error-message leak; the generated installation token is masked +via `setSecret`. + +### HTTP seam + +`GitHubAppLive` and `ActionCacheLive` use `@effect/platform` `HttpClient` instead +of raw `fetch` — interruption-aware and testable. + +### Observability (opt-in) + +GitHub API calls, command executions, and rate-limit events now emit +`Effect.withSpan` traces and `Metric` counters. Inert unless an OpenTelemetry / +metrics layer is provided. + +### New `Artifact` service (`@actions/artifact` v2 parity) + +- `uploadArtifact(name, files, rootDirectory, options?)` zips the file set and + uploads it via the GitHub Actions results backend (Twirp + `github.actions.results.api.v1.ArtifactService` + Azure Block Blob), returning + `{ id, size }`. `listArtifacts`, `getArtifact` (-> `Option`), `downloadArtifact` + (signed-URL download + unzip) and `deleteArtifact` complete the surface. A + `findBy` option is reserved for cross-run/cross-repo reads through the public + REST API (`actions:read`); that path is not yet implemented and fails clearly. +- Reads `ACTIONS_RESULTS_URL` / `ACTIONS_RUNTIME_TOKEN` (set on GitHub-hosted + runners), decoding the run/job backend IDs from the runtime token's `scp` + claim. v2 rejects re-uploading the same artifact name in a run, surfaced as a + typed error. New `ArtifactError` (with a `retryable` flag) and `ArtifactTest` + in-memory layer. No dependency on `@actions/artifact`; zip/unzip shells out to + `zip`/`unzip` with a Windows PowerShell fallback. +- The Twirp plumbing (`twirpCall`, the `CONFLICT` sentinel and the retry + schedule) is now shared between the cache and artifact layers; no behavior + change to `ActionCache`. + +> The artifact backend is an internal GitHub protocol reverse-engineered from +> `actions/toolkit` and may change without notice; the implementation mirrors +> the already-shipped V2 cache layer. This ships as a draft pending end-to-end +> validation against a live GitHub-hosted runner. + +### New `Glob` service + `hashFiles` (`@actions/glob` parity) + +- `Glob.glob(patterns, options?)` resolves newline/comma-separated glob patterns + (`*`, `?`, `[...]`, `**`, `!` excludes, `~` expansion) to a sorted array of + absolute paths. `GlobLive` wraps `node:fs.globSync`; `GlobTest` is an in-memory + namespace layer (`empty`/`layer`). +- `Glob.hashFiles(patterns, options?)` computes the `@actions/glob`-compatible + SHA-256 hash-of-hashes over matched files (per-file SHA-256 binary digests fed, + in glob order, into one accumulating SHA-256), so keys interoperate with + `ActionCache`. Files outside the workspace root are skipped. Returns + `Option.none()` when nothing matches (the toolkit returns `""`; recover that + verbatim with `Option.getOrElse(() => "")`). +- New `GlobError`. Internal path-resolution shared with `ActionCache` (refactor; + no behavior change). + +### New `IoUtil` namespace (`@actions/io` `which`/`findInPath` parity) + +- `IoUtil.which(tool)` returns `Option.some(absolutePath)` for the first + executable match on `PATH`, `Option.none()` on miss; `IoUtil.whichOrFail(tool)` + fails with the new `IoError` instead. `IoUtil.findInPath(tool)` returns every + match. Honors `PATHEXT` on Windows and POSIX execute-bit checks. Reads + `FileSystem` from context (provided by `ActionsRuntime.Default`). +- `cp`/`mv`/`rmRF`/`mkdirP` are documented as direct `@effect/platform` + `FileSystem` calls (a documented filesystem I/O recipe) rather than new + wrappers, since `FileSystem` is already in context everywhere. + +### Toolkit parity — context, inputs, and core conveniences + +- `ActionInput.boolean` / `ActionInput.multiline`: GitHub-faithful input `Config` + combinators. `boolean` follows the YAML 1.2 "Core Schema" exactly + (`true|True|TRUE` / `false|False|FALSE`), failing on anything else — unlike + `Config.boolean`, which silently accepts `yes`/`on`/`1`/`no`/`off`/`0`. +- `ActionEnvironment.payload`: parses `GITHUB_EVENT_PATH` into a schema-validated + `WebhookPayload` (tolerant of unknown keys; empty when unset/missing). + `ActionEnvironment.repo` / `.issue` mirror `@actions/github` `context.repo` / + `context.issue`; `ActionEnvironment.isDebug` mirrors `core.isDebug()`. +- `WorkflowCommand.notice` + `ActionLogger.notice` for `::notice::` annotations, + with an `AnnotationProperties` → command-properties mapper matching the toolkit. +- `WorkflowCommand.stopCommands` / `resumeCommands` / `setCommandEcho` for + untrusted-output handling. +- `GithubMarkdown.image` / `GithubMarkdown.quote` (exact `@actions/core` summary + HTML). +- `PathUtils.toPosixPath` / `toWin32Path` / `toPlatformPath`. +- `OidcTokenIssuer.getToken(audience?)` — `audience` is now optional, matching + `core.getIDToken(audience?)` for cloud-provider OIDC federation. Backward + compatible; Sigstore callers are unaffected. + +## Bug Fixes + +### `RateLimiter` no longer probes `GET /rate_limit` on every guarded call + +`withRateLimit` previously issued a pre-flight `GET /rate_limit` before every +guarded effect, wasting a request and quota per call. It now reads the +`x-ratelimit-*` headers observed on real responses (cached in a shared `Ref` +via an internal `RateLimitState`) and only waits or fails when the cached +remaining quota is below the 10 percent threshold. `checkRest` is cache-first +(the shared snapshot holds the core/REST bucket) and probes only on a cache +miss; `checkGraphQL` always probes, since REST and GraphQL have independent +quotas. Strictly fewer requests, identical wait/fail policy. To share the observed +snapshot between the client and the rate limiter, provide `RateLimitState.Default` +once at the graph root; without it each falls back to a private cache (still +probe-free). + +### `fromApp` revokes its installation token on scope close + +`GitHubClientLive.fromApp` now builds as a scoped layer and revokes the minted +installation token when its scope closes, instead of leaving short-lived tokens +to expire. A `Layer.memoize` recipe is documented on `fromApp` for sharing one +App client (and one token) across multiple provides in a single run. + +## Refactoring + +### Sigstore bundle serialized via `Schema.encode` + +`AttestLive` now serializes the Sigstore bundle with `Schema.encode` instead of a +`JSON.parse(JSON.stringify(...))` round-trip. + +## Documentation + +Comprehensive documentation pass covering the 2.0 release surface. + +**Accuracy against the final 2.0 API:** corrects stale descriptions throughout +`docs/` — `GitHubClientLive.fromEnv()` is now a function; `fromToken` takes a +`Redacted`; `fromApp` is a scoped layer that revokes its token on scope +close and requires `HttpClient.HttpClient`. The "Upgrading to 2.0" migration note +and `@actions/*` substitution map are re-verified against the merged code. + +**New services documented:** `Glob` (glob patterns + SHA-256 `hashFiles`), +`IoUtil` (`which`/`whichOrFail`/`findInPath`), `Artifact` (upload/list/get/ +download/delete, with the "must run inside a JS action" env constraint), +`ActionInput` (YAML 1.2 Core Schema `boolean`, `multiline`), the typed event +payload (`ActionEnvironment.payload`, `repo`, `issue`, `isDebug` + +`WebhookPayload`), `PathUtils`, `ActionLogger.notice`, and the +`WorkflowCommand` notice/stop-commands/echo helpers. +`GitHubClient.paginateStream` and `GithubMarkdown.image`/`quote` are also +covered. + +**Four new guides:** "Building a robust action" (best practices), "Coming from +`@actions/*`" (toolkit-parity walkthrough), "Logging and error handling", and +"Resilient GitHub API calls" (retry, rate-limit awareness, streaming pagination). + +**Structure:** three existing guides (SLSA attestations, publishing, step-buffered +logging) were already present and are preserved; `docs/` is renumbered to a +contiguous 01-16 reading order with the new guides in the guides cluster. + +## Maintenance + +### Trim required peer dependencies to the three Effect packages actually used + +`@effect/cluster`, `@effect/rpc`, and `@effect/sql` were declared as required +peers but are never imported anywhere in the library. They are now removed from +`peerDependencies` and `peerDependenciesMeta`. Consumers only need `effect`, +`@effect/platform`, and `@effect/platform-node`; the dropped packages still +resolve transitively through `@effect/platform-node` if any code path needs +them. + +### CI now runs the full production build on every pull request + +The shared `release-validate` reusable workflow now runs `ci:build` (rslib dev + +prod, api-extractor forgotten-export detection, and TSDoc validation) on PRs. +The previous PR checks ran lint and tests but not the production build, which is +how a forgotten barrel export / multi-line TSDoc code span shipped a broken +build in a prior release. diff --git a/.changeset/ws1-foundation-deps-ci.md b/.changeset/ws1-foundation-deps-ci.md deleted file mode 100644 index a9d26f3..0000000 --- a/.changeset/ws1-foundation-deps-ci.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@savvy-web/github-action-effects": patch ---- - -## Maintenance - -### Trim required peer dependencies to the three Effect packages actually used - -`@effect/cluster`, `@effect/rpc`, and `@effect/sql` were declared as required -peers but are never imported anywhere in the library. They are now removed from -`peerDependencies` and `peerDependenciesMeta`. Consumers only need `effect`, -`@effect/platform`, and `@effect/platform-node`; the dropped packages still -resolve transitively through `@effect/platform-node` if any code path needs -them. The install docs are corrected to match the README's already-accurate -three-peer list. - -### CI now runs the full production build on every pull request - -The shared `release-validate` reusable workflow now runs `ci:build` (rslib dev + -prod, api-extractor forgotten-export detection, and TSDoc validation) on PRs. -The previous PR checks ran lint and tests but not the production build, which is -how a forgotten barrel export / multi-line TSDoc code span shipped a broken -build in a prior release. diff --git a/.changeset/ws2-resilient-github-client.md b/.changeset/ws2-resilient-github-client.md deleted file mode 100644 index 0120550..0000000 --- a/.changeset/ws2-resilient-github-client.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -"@savvy-web/github-action-effects": major ---- - -## Breaking changes - -### `GitHubClientLive.fromApp` now requires a `Scope` - -`fromApp` builds as a scoped layer so it can revoke its installation token on -scope close. Consumers on `ActionsRuntime.Default` / `Action.run` are unaffected -(the run boundary establishes and finalizes the scope automatically). Consumers -who provide `fromApp` via a bare `Effect.provide` must now wrap in -`Effect.scoped`. Part of the 2.0 release. - -### `GitHubClientLive.fromEnv` is now a constructor function - -`fromEnv` changed from a bare `Layer` value to a function -`(resilience?: ResilienceOptions) => Layer` so it can accept resilience tuning, -matching `fromToken` and `fromApp`. Call it as `GitHubClientLive.fromEnv()` -(or `GitHubClientLive.fromEnv({ enabled: false })` for bare behavior). - -## Features - -### Resilient `GitHubClient` — automatic retry and rate-limit awareness - -Every `GitHubClient` call (`rest`, `graphql`, `paginate`, and the new -`paginateStream`) now retries retryable failures (429 and 5xx) automatically -with an exponential, jittered, capped backoff, and honors server-advised delays -from the `Retry-After` and `x-ratelimit-reset` response headers. Resilience is -on by default; every `GitHubClientLive` constructor (`fromEnv`, `fromToken`, -`fromApp`) accepts an optional `ResilienceOptions` argument to tune -`maxRetries` / `baseDelay` / `maxDelay` or disable retries entirely. The pure -`resilienceSchedule` builder is exported for reuse. All 14 `GitHubClient`-backed -services inherit this with no code change. `GitHubClientError` gained an optional -`retryAfterMs` field carrying the server-advised delay. - -### Streaming pagination — `GitHubClient.paginateStream` - -A new `paginateStream` method returns an Effect `Stream` that fetches one page at -a time, so consumers can `takeWhile` / `take` and stop early without buffering or -fetching the remaining pages. The eager `paginate` is unchanged and agrees with -`paginateStream` on page boundaries. - -## Bug Fixes - -### `RateLimiter` no longer probes `GET /rate_limit` on every guarded call - -`withRateLimit` previously issued a pre-flight `GET /rate_limit` before every -guarded effect, wasting a request and quota per call. It now reads the -`x-ratelimit-*` headers observed on real responses (cached in a shared `Ref` -via an internal `RateLimitState`) and only waits or fails when the cached -remaining quota is below the 10 percent threshold. `checkRest` is cache-first -(the shared snapshot holds the core/REST bucket) and probes only on a cache -miss; `checkGraphQL` always probes, since REST and GraphQL have independent -quotas. Strictly fewer requests, identical wait/fail policy. To share the observed snapshot between the client -and the rate limiter, provide `RateLimitState.Default` once at the graph root; -without it each falls back to a private cache (still probe-free). - -### `fromApp` revokes its installation token on scope close - -`GitHubClientLive.fromApp` now builds as a scoped layer and revokes the minted -installation token when its scope closes, instead of leaving short-lived tokens -to expire. A `Layer.memoize` recipe is documented on `fromApp` for sharing one -App client (and one token) across multiple provides in a single run. diff --git a/.changeset/ws3-effect-quality-observability.md b/.changeset/ws3-effect-quality-observability.md deleted file mode 100644 index d9910f4..0000000 --- a/.changeset/ws3-effect-quality-observability.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -"@savvy-web/github-action-effects": major ---- - -## Breaking Changes - -### Secrets are now `Redacted` by default - -Public method signatures that take a token or private key now accept -`Redacted` instead of `string`: `GitHubApp.generateToken` / -`resolveAppIdentity` / `revokeToken` / `withToken`, -`GitHubClientLive.fromToken(token)` / `fromApp({ privateKey })`, and -`PackagePublish.setupAuth(registry, token)` / `RegistryTarget.token`. Wrap bare -strings with `Redacted.make(...)` at the call site. The persisted -`InstallationToken.token` field is now `Redacted` (decoded type) — read -it with `Redacted.value(...)`. The encoded `GITHUB_STATE` bytes are unchanged. - -### `GitHubAppLive` and `ActionCacheLive` require `HttpClient.HttpClient` - -The raw-`fetch` migration adds an `HttpClient.HttpClient` requirement to both -layers. The `Action.run` / `ActionsRuntime.Default` path provides it -automatically (it now bundles `FetchHttpClient.layer`). Consumers that compose -layers manually must add `FetchHttpClient.layer` (from `@effect/platform`). - -## Features - -### Resource safety - -`CommandRunner` and `ToolInstaller` now register interruption finalizers on their -async spawns, so a `timeout` / `race` / interrupt no longer leaks child processes -or download sockets. - -### Secret hardening - -Every token / private key stays `Redacted` end-to-end, unwrapped only at the wire -boundary; the npm auth token is no longer passed as a command argument (it is -written to `.npmrc` directly) and `CommandRunnerError` scrubs known auth-token -args, closing the error-message leak; the generated installation token is masked -via `setSecret`. - -### HTTP seam - -`GitHubAppLive` and `ActionCacheLive` use `@effect/platform` `HttpClient` instead -of raw `fetch` — interruption-aware and testable. - -### Observability (opt-in) - -GitHub API calls, command executions, and rate-limit events now emit -`Effect.withSpan` traces and `Metric` counters. Inert unless an OpenTelemetry / -metrics layer is provided. - -## Refactoring - -### Sigstore bundle serialized via `Schema.encode` - -`AttestLive` now serializes the Sigstore bundle with `Schema.encode` instead of a -`JSON.parse(JSON.stringify(...))` round-trip. diff --git a/.changeset/ws4-artifact.md b/.changeset/ws4-artifact.md deleted file mode 100644 index 3f9777c..0000000 --- a/.changeset/ws4-artifact.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@savvy-web/github-action-effects": minor ---- - -## Features - -### New `Artifact` service (`@actions/artifact` v2 parity) - -- `uploadArtifact(name, files, rootDirectory, options?)` zips the file set and - uploads it via the GitHub Actions results backend (Twirp - `github.actions.results.api.v1.ArtifactService` + Azure Block Blob), returning - `{ id, size }`. `listArtifacts`, `getArtifact` (-> `Option`), `downloadArtifact` - (signed-URL download + unzip) and `deleteArtifact` complete the surface. A - `findBy` option is reserved for cross-run/cross-repo reads through the public - REST API (`actions:read`); that path is not yet implemented and fails clearly. -- Reads `ACTIONS_RESULTS_URL` / `ACTIONS_RUNTIME_TOKEN` (set on GitHub-hosted - runners), decoding the run/job backend IDs from the runtime token's `scp` - claim. v2 rejects re-uploading the same artifact name in a run, surfaced as a - typed error. New `ArtifactError` (with a `retryable` flag) and `ArtifactTest` - in-memory layer. No dependency on `@actions/artifact`; zip/unzip shells out to - `zip`/`unzip` with a Windows PowerShell fallback. -- The Twirp plumbing (`twirpCall`, the `CONFLICT` sentinel and the retry - schedule) is now shared between the cache and artifact layers; no behavior - change to `ActionCache`. - -> The artifact backend is an internal GitHub protocol reverse-engineered from -> `actions/toolkit` and may change without notice; the implementation mirrors -> the already-shipped V2 cache layer. This ships as a draft pending end-to-end -> validation against a live GitHub-hosted runner. diff --git a/.changeset/ws4-glob-hashfiles.md b/.changeset/ws4-glob-hashfiles.md deleted file mode 100644 index 7e01035..0000000 --- a/.changeset/ws4-glob-hashfiles.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@savvy-web/github-action-effects": minor ---- - -## Features - -### New `Glob` service + `hashFiles` (`@actions/glob` parity) - -- `Glob.glob(patterns, options?)` resolves newline/comma-separated glob patterns - (`*`, `?`, `[...]`, `**`, `!` excludes, `~` expansion) to a sorted array of - absolute paths. `GlobLive` wraps `node:fs.globSync`; `GlobTest` is an in-memory - namespace layer (`empty`/`layer`). -- `Glob.hashFiles(patterns, options?)` computes the `@actions/glob`-compatible - SHA-256 hash-of-hashes over matched files (per-file SHA-256 binary digests fed, - in glob order, into one accumulating SHA-256), so keys interoperate with - `ActionCache`. Files outside the workspace root are skipped. Returns - `Option.none()` when nothing matches (the toolkit returns `""`; recover that - verbatim with `Option.getOrElse(() => "")`). -- New `GlobError`. Internal path-resolution shared with `ActionCache` (refactor; - no behavior change). diff --git a/.changeset/ws4-io-helpers.md b/.changeset/ws4-io-helpers.md deleted file mode 100644 index 2f3fa2e..0000000 --- a/.changeset/ws4-io-helpers.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@savvy-web/github-action-effects": minor ---- - -## Features - -### New `IoUtil` namespace (`@actions/io` `which`/`findInPath` parity) - -- `IoUtil.which(tool)` returns `Option.some(absolutePath)` for the first - executable match on `PATH`, `Option.none()` on miss; `IoUtil.whichOrFail(tool)` - fails with the new `IoError` instead. `IoUtil.findInPath(tool)` returns every - match. Honors `PATHEXT` on Windows and POSIX execute-bit checks. Reads - `FileSystem` from context (provided by `ActionsRuntime.Default`). -- `cp`/`mv`/`rmRF`/`mkdirP` are documented as direct `@effect/platform` - `FileSystem` calls (a documented filesystem I/O recipe) rather than new - wrappers, since `FileSystem` is already in context everywhere. diff --git a/.changeset/ws5-toolkit-parity-context.md b/.changeset/ws5-toolkit-parity-context.md deleted file mode 100644 index 86dc318..0000000 --- a/.changeset/ws5-toolkit-parity-context.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@savvy-web/github-action-effects": minor ---- - -## Features - -### Toolkit parity II — context, inputs, and core conveniences - -- `ActionInput.boolean` / `ActionInput.multiline`: GitHub-faithful input `Config` - combinators. `boolean` follows the YAML 1.2 "Core Schema" exactly - (`true|True|TRUE` / `false|False|FALSE`), failing on anything else — unlike - `Config.boolean`, which silently accepts `yes`/`on`/`1`/`no`/`off`/`0`. -- `ActionEnvironment.payload`: parses `GITHUB_EVENT_PATH` into a schema-validated - `WebhookPayload` (tolerant of unknown keys; empty when unset/missing). - `ActionEnvironment.repo` / `.issue` mirror `@actions/github` `context.repo` / - `context.issue`; `ActionEnvironment.isDebug` mirrors `core.isDebug()`. -- `WorkflowCommand.notice` + `ActionLogger.notice` for `::notice::` annotations, - with an `AnnotationProperties` → command-properties mapper matching the toolkit. -- `WorkflowCommand.stopCommands` / `resumeCommands` / `setCommandEcho` for - untrusted-output handling. -- `GithubMarkdown.image` / `GithubMarkdown.quote` (exact `@actions/core` summary - HTML). -- `PathUtils.toPosixPath` / `toWin32Path` / `toPlatformPath`. -- `OidcTokenIssuer.getToken(audience?)` — `audience` is now optional, matching - `core.getIDToken(audience?)` for cloud-provider OIDC federation. Backward - compatible; Sigstore callers are unaffected. diff --git a/.changeset/ws6-documentation.md b/.changeset/ws6-documentation.md deleted file mode 100644 index 790d66a..0000000 --- a/.changeset/ws6-documentation.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@savvy-web/github-action-effects": minor ---- - -## Documentation - -Comprehensive documentation pass covering the 2.0 release surface. - -**Accuracy against the final 2.0 API:** corrects stale descriptions throughout -`docs/` — `GitHubClientLive.fromEnv()` is now a function; `fromToken` takes a -`Redacted`; `fromApp` is a scoped layer that revokes its token on scope -close and requires `HttpClient.HttpClient`. The "Upgrading to 2.0" migration note -and `@actions/*` substitution map are re-verified against the merged code. - -**New services documented:** `Glob` (glob patterns + SHA-256 `hashFiles`), -`IoUtil` (`which`/`whichOrFail`/`findInPath`), `Artifact` (upload/list/get/ -download/delete, with the "must run inside a JS action" env constraint), -`ActionInput` (YAML 1.2 Core Schema `boolean`, `multiline`), the typed event -payload (`ActionEnvironment.payload`, `repo`, `issue`, `isDebug` + -`WebhookPayload`), `PathUtils`, `ActionLogger.notice`, and the -`WorkflowCommand` notice/stop-commands/echo helpers. -`GitHubClient.paginateStream` and `GithubMarkdown.image`/`quote` are also -covered. - -**Four new guides:** "Building a robust action" (best practices), "Coming from -`@actions/*`" (toolkit-parity walkthrough), "Logging and error handling", and -"Resilient GitHub API calls" (retry, rate-limit awareness, streaming pagination). - -**Structure:** three existing guides (SLSA attestations, publishing, step-buffered -logging) were already present and are preserved; `docs/` is renumbered to a -contiguous 01-16 reading order with the new guides in the guides cluster.