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 fa153ab..0000000 --- a/.changeset/ws1-foundation-deps-ci.md +++ /dev/null @@ -1,24 +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 (`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. - -### 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 16bb155..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 (new `docs/09-filesystem-io.md` 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/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..9487be1 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). @@ -183,29 +236,83 @@ 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 + // 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 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 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`. -- `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 — 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 +}).pipe( + Effect.provide( + GitHubClientLive.fromApp({ clientId, privateKey: Redacted.make(pem) }), + ), + Effect.provide(FetchHttpClient.layer), +) +``` 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 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, + ) + 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 +506,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 +556,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 +587,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 +793,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 +827,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 +889,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 +1019,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 +1325,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 60% rename from docs/07-architecture.md rename to docs/14-architecture.md index f725580..bd2a9a7 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,115 @@ 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. 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" +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 +) +// 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. + +### 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 +316,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 +361,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 +379,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 +468,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/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. * 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 */