Skip to content
This repository was archived by the owner on May 30, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/ws4-artifact.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@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.
21 changes: 21 additions & 0 deletions src/errors/ArtifactError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Data } from "effect";

/**
* Error when an artifact operation (upload, download, list, get, delete) fails.
*
* @public
*/
export class ArtifactError extends Data.TaggedError("ArtifactError")<{
/** The operation that failed. */
readonly operation: "upload" | "download" | "list" | "get" | "delete";
/** Artifact name or id (string for uniform formatting). */
readonly artifact: string;
/** Human-readable description of what went wrong. */
readonly reason: string;
/**
* True for 5xx / network failures — the caller may retry. Mirrors
* `GitHubClientError`'s `retryable` flag for consistency with the WS2 retry
* story.
*/
readonly retryable?: boolean;
}> {}
12 changes: 12 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export { ActionEnvironmentError } from "./errors/ActionEnvironmentError.js";
export { ActionInputError } from "./errors/ActionInputError.js";
export { ActionOutputError } from "./errors/ActionOutputError.js";
export { ActionStateError } from "./errors/ActionStateError.js";
export { ArtifactError } from "./errors/ArtifactError.js";
export { AttestError } from "./errors/AttestError.js";
export { ChangesetError } from "./errors/ChangesetError.js";
export { CheckRunError } from "./errors/CheckRunError.js";
Expand Down Expand Up @@ -67,6 +68,9 @@ export { ActionOutputsTest } from "./layers/ActionOutputsTest.js";
export { ActionStateLive } from "./layers/ActionStateLive.js";
export type { ActionStateTestState } from "./layers/ActionStateTest.js";
export { ActionStateTest } from "./layers/ActionStateTest.js";
export { ArtifactLive } from "./layers/ArtifactLive.js";
export type { ArtifactTestState } from "./layers/ArtifactTest.js";
export { ArtifactTest } from "./layers/ArtifactTest.js";
export { AttestLive } from "./layers/AttestLive.js";
export type { AttestTestState } from "./layers/AttestTest.js";
export { AttestTest, AttestTestFullLayer, makeAttestTestState } from "./layers/AttestTest.js";
Expand Down Expand Up @@ -225,6 +229,14 @@ export { ActionEnvironment } from "./services/ActionEnvironment.js";
export { ActionLogger } from "./services/ActionLogger.js";
export { ActionOutputs } from "./services/ActionOutputs.js";
export { ActionState } from "./services/ActionState.js";
export type {
ArtifactItem,
DownloadOptions,
FindBy,
UploadOptions,
UploadResult,
} from "./services/Artifact.js";
export { Artifact } from "./services/Artifact.js";
export type { AttestationListEntry, ProvenanceAttestationInput, SbomAttestationInput } from "./services/Attest.js";
export { Attest } from "./services/Attest.js";
export { ChangesetAnalyzer } from "./services/ChangesetAnalyzer.js";
Expand Down
103 changes: 22 additions & 81 deletions src/layers/ActionCacheLive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,24 @@ import { statSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { BlobClient, BlockBlobClient } from "@azure/storage-blob";
import { HttpClient, HttpClientRequest, HttpClientResponse } from "@effect/platform";
import { Effect, Layer, Option, Redacted, Schedule, Schema } from "effect";
import { HttpClient } from "@effect/platform";
import { Effect, Layer, Option, Redacted } from "effect";
import { ActionCacheError } from "../errors/ActionCacheError.js";
import { ActionCache } from "../services/ActionCache.js";
import { UPLOAD_CHUNK_SIZE, UPLOAD_CONCURRENCY, UPLOAD_MAX_SINGLE_SHOT } from "./internal/azureUpload.js";
import { resolvePaths } from "./internal/globPaths.js";
import { CONFLICT, makeTwirpRetrySchedule, twirpCall as twirpCallShared } from "./internal/twirp.js";

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

const TWIRP_PREFIX = "twirp/github.actions.results.api.v1.CacheService";
const TWIRP_SERVICE = "github.actions.results.api.v1.CacheService";
const COMPRESSION_METHOD = "gzip";
const VERSION_SALT = "1.0";

// Azure upload config (matches actions/cache)
const UPLOAD_CHUNK_SIZE = 64 * 1024 * 1024; // 64 MiB
const UPLOAD_CONCURRENCY = 8;
const UPLOAD_MAX_SINGLE_SHOT = 128 * 1024 * 1024; // 128 MiB

// Retry config for Twirp RPC
const RETRY_SCHEDULE = Schedule.intersect(Schedule.exponential("3 seconds", 1.5), Schedule.recurs(4)).pipe(
Schedule.whileInput((error: ActionCacheError) => {
const reason = error.reason;
return (
reason.includes("HTTP 500") ||
reason.includes("HTTP 502") ||
reason.includes("HTTP 503") ||
reason.includes("HTTP 504") ||
reason.includes("ECONNRESET") ||
reason.includes("ECONNREFUSED") ||
reason.includes("ETIMEDOUT")
);
}),
);
// Retry config for Twirp RPC (shared with ArtifactLive via internal/twirp.ts).
const RETRY_SCHEDULE = makeTwirpRetrySchedule((error: ActionCacheError) => error.reason);

// ---------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -80,16 +64,11 @@ const getCacheEnv = (operation: "save" | "restore", key: string) =>
});

/**
* Sentinel value returned by twirpCall when the server responds with
* HTTP 409 (Conflict), indicating the resource already exists.
*/
const CONFLICT = Symbol.for("twirp/conflict");
type TwirpResult<T> = T | typeof CONFLICT;

/**
* Make a Twirp RPC call (POST with JSON body/response).
* Returns the CONFLICT sentinel on HTTP 409 instead of failing,
* allowing callers to treat "already exists" as a success.
* Make a Twirp RPC call against the cache service via the shared client.
* Returns the {@link CONFLICT} sentinel on HTTP 409 instead of failing,
* allowing callers to treat "already exists" as a success. The error `reason`
* substrings are preserved by `twirpCallShared` so the retry schedule and the
* existing test assertions continue to match.
*/
const twirpCall = <T>(
http: HttpClient.HttpClient,
Expand All @@ -99,54 +78,16 @@ const twirpCall = <T>(
body: Record<string, unknown>,
operation: "save" | "restore",
key: string,
): Effect.Effect<TwirpResult<T>, ActionCacheError> =>
Effect.gen(function* () {
const request = HttpClientRequest.post(`${baseUrl}${TWIRP_PREFIX}/${method}`).pipe(
// Unwrap the runtime token only here, at the request boundary (S9).
HttpClientRequest.bearerToken(Redacted.value(token)),
HttpClientRequest.bodyUnsafeJson(body),
);
// Transport faults surface as `<method> failed: <message>`; the message
// preserves the underlying `ECONNRESET`/`ETIMEDOUT` substring the retry
// schedule keys off.
const response = yield* http.execute(request).pipe(
Effect.mapError(
(cause) =>
new ActionCacheError({
key,
operation,
reason: `${method} failed: ${cause.message}`,
}),
),
);
if (response.status === 409) {
return CONFLICT;
}
if (response.status < 200 || response.status >= 300) {
// Preserve the exact reason the raw-`fetch` implementation produced
// (`<method> failed: HTTP <status> from <method>`): the leading
// `<method> failed` keeps existing assertions valid and the embedded
// `HTTP <status>` keeps the retry schedule's `reason.includes("HTTP
// 503")` predicate firing.
return yield* Effect.fail(
new ActionCacheError({
key,
operation,
reason: `${method} failed: HTTP ${response.status} from ${method}`,
}),
);
}
return (yield* HttpClientResponse.schemaBodyJson(Schema.Unknown)(response).pipe(
Effect.mapError(
(cause) =>
new ActionCacheError({
key,
operation,
reason: `${method} failed: ${cause.message ?? String(cause)}`,
}),
),
)) as T;
});
) =>
twirpCallShared<T, ActionCacheError>(
http,
baseUrl,
TWIRP_SERVICE,
token,
method,
body,
(reason) => new ActionCacheError({ key, operation, reason }),
);

/**
* Create a tar.gz archive of the given paths.
Expand Down
Loading
Loading