diff --git a/.changeset/ws4-artifact.md b/.changeset/ws4-artifact.md new file mode 100644 index 0000000..3f9777c --- /dev/null +++ b/.changeset/ws4-artifact.md @@ -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. diff --git a/src/errors/ArtifactError.ts b/src/errors/ArtifactError.ts new file mode 100644 index 0000000..18220ba --- /dev/null +++ b/src/errors/ArtifactError.ts @@ -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; +}> {} diff --git a/src/index.ts b/src/index.ts index 41e047a..8e95869 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -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"; @@ -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"; diff --git a/src/layers/ActionCacheLive.ts b/src/layers/ActionCacheLive.ts index e8abf4a..d04120f 100644 --- a/src/layers/ActionCacheLive.ts +++ b/src/layers/ActionCacheLive.ts @@ -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 @@ -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 | 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 = ( http: HttpClient.HttpClient, @@ -99,54 +78,16 @@ const twirpCall = ( body: Record, operation: "save" | "restore", key: string, -): Effect.Effect, 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 ` failed: `; 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 - // (` failed: HTTP from `): the leading - // ` failed` keeps existing assertions valid and the embedded - // `HTTP ` 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( + http, + baseUrl, + TWIRP_SERVICE, + token, + method, + body, + (reason) => new ActionCacheError({ key, operation, reason }), + ); /** * Create a tar.gz archive of the given paths. diff --git a/src/layers/ArtifactLive.test.ts b/src/layers/ArtifactLive.test.ts new file mode 100644 index 0000000..f48d025 --- /dev/null +++ b/src/layers/ArtifactLive.test.ts @@ -0,0 +1,512 @@ +import { Readable } from "node:stream"; +import { HttpClient, HttpClientResponse } from "@effect/platform"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ArtifactError } from "../errors/ArtifactError.js"; +import { Artifact } from "../services/Artifact.js"; +import { ArtifactLive } from "./ArtifactLive.js"; + +// --------------------------------------------------------------------------- +// Azure SDK mock (mirrors ActionCacheLive.test.ts) +// --------------------------------------------------------------------------- + +const mockUploadFile = vi.fn().mockResolvedValue(undefined); +const mockDownloadToFile = vi.fn().mockResolvedValue(undefined); + +vi.mock("@azure/storage-blob", () => ({ + BlockBlobClient: class MockBlockBlobClient { + uploadFile = mockUploadFile; + constructor(public url: string) {} + }, + BlobClient: class MockBlobClient { + downloadToFile = mockDownloadToFile; + constructor(public url: string) {} + }, +})); + +vi.mock("node:child_process", () => ({ + execFileSync: vi.fn(), +})); + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createReadStream: vi.fn(), + statSync: vi.fn(), + unlinkSync: vi.fn(), + mkdirSync: vi.fn(), + }; +}); + +import { execFileSync } from "node:child_process"; +import { createReadStream, mkdirSync, statSync, unlinkSync } from "node:fs"; + +const mockedExecFileSync = vi.mocked(execFileSync); +const mockedCreateReadStream = vi.mocked(createReadStream); +const mockedStatSync = vi.mocked(statSync); +const mockedUnlinkSync = vi.mocked(unlinkSync); +const mockedMkdirSync = vi.mocked(mkdirSync); + +// --------------------------------------------------------------------------- +// HttpClient (Twirp) mock +// --------------------------------------------------------------------------- + +interface TwirpReply { + readonly status: number; + readonly body?: unknown; +} + +interface CapturedTwirp { + readonly url: string; + readonly body: unknown; + readonly headers: Record; +} + +let twirpReplies: Array = []; +let twirpCaptured: Array = []; + +const mockHttpLayer: Layer.Layer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request, url) => + Effect.gen(function* () { + const headers: Record = {}; + for (const [k, v] of Object.entries(request.headers)) { + if (typeof v === "string") headers[k.toLowerCase()] = v; + } + const bodyText = yield* Effect.promise(async () => { + const b = request.body as { body?: unknown }; + if (b && typeof b === "object" && "body" in b && b.body instanceof Uint8Array) { + return new TextDecoder().decode(b.body); + } + return ""; + }); + const parsedBody = bodyText ? JSON.parse(bodyText) : undefined; + twirpCaptured.push({ url: url.toString(), body: parsedBody, headers }); + const reply = twirpReplies.shift() ?? { status: 500 }; + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(reply.body ?? null), { + status: reply.status, + headers: { "content-type": "application/json" }, + }), + ); + }), + ), +); + +const liveLayer = ArtifactLive.pipe(Layer.provide(mockHttpLayer)); + +const runExit = (effect: Effect.Effect) => + Effect.runPromise(Effect.exit(Effect.provide(effect, liveLayer))); + +const extractError = (exit: Exit.Exit): ArtifactError | undefined => { + if (Exit.isFailure(exit)) { + const option = Cause.failureOption(exit.cause); + if (Option.isSome(option)) return option.value; + } + return undefined; +}; + +const queue = (...replies: Array): void => { + twirpReplies.push(...replies); +}; + +// A synthetic runtime token whose scp claim carries the backend ids. +const b64url = (obj: unknown): string => + Buffer.from(JSON.stringify(obj)).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +const RUNTIME_TOKEN = `${b64url({ alg: "HS256" })}.${b64url({ scp: "Actions.Results:run-1:job-1" })}.sig`; + +beforeEach(() => { + twirpReplies = []; + twirpCaptured = []; +}); + +// --------------------------------------------------------------------------- +// listArtifacts +// --------------------------------------------------------------------------- + +describe("ArtifactLive", () => { + describe("env guards", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("fails with ArtifactError when env vars are missing", async () => { + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.listArtifacts())); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?._tag).toBe("ArtifactError"); + expect(error?.reason).toContain("ACTIONS_RESULTS_URL"); + }); + + it("fails when the runtime token lacks an Actions.Results scope", async () => { + vi.stubEnv("ACTIONS_RESULTS_URL", "https://results.example.com/"); + vi.stubEnv("ACTIONS_RUNTIME_TOKEN", `${b64url({ alg: "HS256" })}.${b64url({ scp: "Actions.Other:x" })}.sig`); + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.listArtifacts())); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?.reason).toContain("Actions.Results"); + }); + }); + + describe("with env vars set", () => { + beforeEach(() => { + vi.stubEnv("ACTIONS_RESULTS_URL", "https://results.example.com/"); + vi.stubEnv("ACTIONS_RUNTIME_TOKEN", RUNTIME_TOKEN); + mockedExecFileSync.mockReturnValue(Buffer.from("")); + mockedStatSync.mockReturnValue({ size: 1234 } as ReturnType); + mockedUnlinkSync.mockReturnValue(undefined); + mockedMkdirSync.mockReturnValue(undefined); + mockUploadFile.mockResolvedValue(undefined); + mockDownloadToFile.mockResolvedValue(undefined); + // createReadStream feeds the sha256 hasher: emit a couple of chunks. + mockedCreateReadStream.mockImplementation((() => + Readable.from([Buffer.from("zip-bytes-chunk")])) as unknown as typeof createReadStream); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); + }); + + describe("listArtifacts", () => { + it("maps a ListArtifacts Twirp response to ArtifactItem[]", async () => { + queue({ + status: 200, + body: { + artifacts: [ + { databaseId: "11", name: "logs", size: "100", createdAt: "2026-01-01T00:00:00Z" }, + { databaseId: "12", name: "dist", size: "200" }, + ], + }, + }); + + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.listArtifacts())); + expect(Exit.isSuccess(exit)).toBe(true); + if (Exit.isSuccess(exit)) { + expect(exit.value).toEqual([ + { id: 11, name: "logs", size: 100, createdAt: "2026-01-01T00:00:00Z" }, + { id: 12, name: "dist", size: 200 }, + ]); + } + expect(twirpCaptured[0]?.url).toContain("twirp/github.actions.results.api.v1.ArtifactService/ListArtifacts"); + expect(twirpCaptured[0]?.body).toMatchObject({ + workflowRunBackendId: "run-1", + workflowJobRunBackendId: "job-1", + }); + }); + + it("returns [] when the backend lists none", async () => { + queue({ status: 200, body: { artifacts: [] } }); + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.listArtifacts())); + expect(Exit.isSuccess(exit)).toBe(true); + if (Exit.isSuccess(exit)) { + expect(exit.value).toEqual([]); + } + }); + + it("fails with ArtifactError on a non-ok Twirp response", async () => { + queue({ status: 400 }); + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.listArtifacts())); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?.operation).toBe("list"); + expect(error?.reason).toContain("HTTP 400"); + }); + }); + + describe("getArtifact", () => { + it("returns Option.some(item) for a matching name", async () => { + queue({ + status: 200, + body: { artifacts: [{ databaseId: "5", name: "dist", size: "9" }] }, + }); + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.getArtifact("dist"))); + expect(Exit.isSuccess(exit)).toBe(true); + if (Exit.isSuccess(exit)) { + const opt = exit.value as Option.Option<{ id: number }>; + expect(Option.isSome(opt)).toBe(true); + if (Option.isSome(opt)) expect(opt.value.id).toBe(5); + } + }); + + it("returns Option.none() when no artifact matches the name", async () => { + queue({ + status: 200, + body: { artifacts: [{ databaseId: "5", name: "other", size: "9" }] }, + }); + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.getArtifact("dist"))); + expect(Exit.isSuccess(exit)).toBe(true); + if (Exit.isSuccess(exit)) { + expect(Option.isNone(exit.value as Option.Option)).toBe(true); + } + }); + }); + + describe("uploadArtifact", () => { + it("runs CreateArtifact → blob upload → FinalizeArtifact and returns {id,size}", async () => { + queue( + { status: 200, body: { ok: true, signedUploadUrl: "https://azure.example.com/up" } }, + { status: 200, body: { ok: true, artifactId: "42" } }, + ); + + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.uploadArtifact("dist", ["a.txt"], "/work"))); + expect(Exit.isSuccess(exit)).toBe(true); + if (Exit.isSuccess(exit)) { + expect(exit.value).toEqual({ id: 42, size: 1234 }); + } + expect(twirpCaptured[0]?.url).toContain("ArtifactService/CreateArtifact"); + // version 7 requires a mime_type (the backend 400s without it). + expect(twirpCaptured[0]?.body).toMatchObject({ name: "dist", version: 7, mimeType: "application/zip" }); + expect(mockUploadFile).toHaveBeenCalled(); + expect(twirpCaptured[1]?.url).toContain("ArtifactService/FinalizeArtifact"); + const finalizeBody = twirpCaptured[1]?.body as { name: string; size: string; hash: string }; + expect(finalizeBody.name).toBe("dist"); + expect(finalizeBody.size).toBe("1234"); + expect(finalizeBody.hash).toMatch(/^sha256:[0-9a-f]{64}$/); + }); + + it("forwards retentionDays as an expiresAt on FinalizeArtifact", async () => { + queue( + { status: 200, body: { ok: true, signedUploadUrl: "https://azure.example.com/up" } }, + { status: 200, body: { ok: true, artifactId: "42" } }, + ); + const before = Date.now(); + const exit = await runExit( + Effect.flatMap(Artifact, (svc) => svc.uploadArtifact("dist", ["a.txt"], "/work", { retentionDays: 5 })), + ); + expect(Exit.isSuccess(exit)).toBe(true); + const finalizeBody = twirpCaptured[1]?.body as { expiresAt?: string }; + expect(finalizeBody.expiresAt).toBeDefined(); + const expiresMs = Date.parse(finalizeBody.expiresAt as string); + // ~5 days out from now. + expect(expiresMs).toBeGreaterThan(before + 4 * 86_400_000); + expect(expiresMs).toBeLessThan(before + 6 * 86_400_000); + }); + + it("passes compressionLevel to the POSIX zip flags", async () => { + if (process.platform === "win32") return; // POSIX `zip` only + queue( + { status: 200, body: { ok: true, signedUploadUrl: "https://azure.example.com/up" } }, + { status: 200, body: { ok: true, artifactId: "42" } }, + ); + await runExit( + Effect.flatMap(Artifact, (svc) => svc.uploadArtifact("dist", ["a.txt"], "/work", { compressionLevel: 1 })), + ); + const zipCall = mockedExecFileSync.mock.calls.find((c) => c[0] === "zip"); + expect(zipCall).toBeDefined(); + expect((zipCall?.[1] as ReadonlyArray)[0]).toBe("-1"); + }); + + it("fails when no files are provided", async () => { + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.uploadArtifact("dist", [], "/work"))); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?.operation).toBe("upload"); + expect(error?.reason).toContain("No files provided"); + }); + + it("fails with 'artifact already exists' when CreateArtifact returns ok:false", async () => { + queue({ status: 200, body: { ok: false } }); + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.uploadArtifact("dist", ["a.txt"], "/work"))); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?.operation).toBe("upload"); + expect(error?.reason).toContain("already exists"); + }); + + it("fails with 'artifact already exists' when CreateArtifact 409s", async () => { + queue({ status: 409 }); + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.uploadArtifact("dist", ["a.txt"], "/work"))); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?.reason).toContain("already exists"); + }); + + it("cleans up the temp zip after a successful upload", async () => { + queue( + { status: 200, body: { ok: true, signedUploadUrl: "https://azure.example.com/up" } }, + { status: 200, body: { ok: true, artifactId: "42" } }, + ); + await runExit(Effect.flatMap(Artifact, (svc) => svc.uploadArtifact("dist", ["a.txt"], "/work"))); + expect(mockedUnlinkSync).toHaveBeenCalled(); + }); + + it("fails when blob upload rejects", async () => { + queue({ status: 200, body: { ok: true, signedUploadUrl: "https://azure.example.com/up" } }); + mockUploadFile.mockRejectedValueOnce(new Error("Azure upload timeout")); + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.uploadArtifact("dist", ["a.txt"], "/work"))); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?.reason).toContain("upload failed"); + expect(error?.reason).toContain("Azure upload timeout"); + }); + + it("fails when FinalizeArtifact returns ok:false", async () => { + queue( + { status: 200, body: { ok: true, signedUploadUrl: "https://azure.example.com/up" } }, + { status: 200, body: { ok: false } }, + ); + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.uploadArtifact("dist", ["a.txt"], "/work"))); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?.reason).toContain("FinalizeArtifact did not confirm success"); + }); + + it("fails when CreateArtifact returns ok but no upload URL", async () => { + queue({ status: 200, body: { ok: true } }); + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.uploadArtifact("dist", ["a.txt"], "/work"))); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?.reason).toContain("did not return a signed upload URL"); + }); + + it("fails when retentionDays is not positive", async () => { + const exit = await runExit( + Effect.flatMap(Artifact, (svc) => svc.uploadArtifact("dist", ["a.txt"], "/work", { retentionDays: 0 })), + ); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?.reason).toContain("retentionDays"); + }); + }); + + describe("downloadArtifact", () => { + it("resolves a signed URL, downloads, and unzips to the dest path", async () => { + queue( + { status: 200, body: { artifacts: [{ databaseId: "7", name: "dist", size: "9" }] } }, + { status: 200, body: { signedUrl: "https://azure.example.com/down" } }, + ); + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.downloadArtifact(7, { path: "/dest" }))); + expect(Exit.isSuccess(exit)).toBe(true); + if (Exit.isSuccess(exit)) { + expect(exit.value).toEqual({ downloadPath: "/dest" }); + } + expect(twirpCaptured[1]?.url).toContain("ArtifactService/GetSignedArtifactURL"); + expect(twirpCaptured[1]?.body).toMatchObject({ name: "dist" }); + expect(mockDownloadToFile).toHaveBeenCalled(); + // unzip shells out + expect(mockedExecFileSync).toHaveBeenCalled(); + }); + + it("fails with ArtifactError when GetSignedArtifactURL is non-ok", async () => { + queue({ status: 200, body: { artifacts: [{ databaseId: "7", name: "dist", size: "9" }] } }, { status: 400 }); + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.downloadArtifact(7))); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?.operation).toBe("download"); + expect(error?.reason).toContain("HTTP 400"); + }); + + it("cleans up the temp zip after extraction", async () => { + queue( + { status: 200, body: { artifacts: [{ databaseId: "7", name: "dist", size: "9" }] } }, + { status: 200, body: { signedUrl: "https://azure.example.com/down" } }, + ); + await runExit(Effect.flatMap(Artifact, (svc) => svc.downloadArtifact(7, { path: "/dest" }))); + expect(mockedUnlinkSync).toHaveBeenCalled(); + }); + + it("fails when the artifact id is not found in the run", async () => { + queue({ status: 200, body: { artifacts: [{ databaseId: "9", name: "other", size: "1" }] } }); + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.downloadArtifact(7))); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?.reason).toContain("not found"); + }); + + it("fails when GetSignedArtifactURL returns no signed URL", async () => { + queue( + { status: 200, body: { artifacts: [{ databaseId: "7", name: "dist", size: "9" }] } }, + { status: 200, body: {} }, + ); + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.downloadArtifact(7))); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?.reason).toContain("did not return a signed URL"); + }); + + it("downloads to a fresh temp dir when no path is given", async () => { + queue( + { status: 200, body: { artifacts: [{ databaseId: "7", name: "dist", size: "9" }] } }, + { status: 200, body: { signedUrl: "https://azure.example.com/down" } }, + ); + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.downloadArtifact(7))); + expect(Exit.isSuccess(exit)).toBe(true); + if (Exit.isSuccess(exit)) { + expect(exit.value.downloadPath).toContain("artifact-download-"); + } + }); + + it("fails when the blob download rejects", async () => { + queue( + { status: 200, body: { artifacts: [{ databaseId: "7", name: "dist", size: "9" }] } }, + { status: 200, body: { signedUrl: "https://azure.example.com/down" } }, + ); + mockDownloadToFile.mockRejectedValueOnce(new Error("Azure download timeout")); + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.downloadArtifact(7, { path: "/dest" }))); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?.reason).toContain("download failed"); + expect(error?.reason).toContain("Azure download timeout"); + }); + }); + + describe("findBy (cross-run/cross-repo)", () => { + const findBy = { + token: "gh-token", + workflowRunId: 99, + repositoryOwner: "octo", + repositoryName: "repo", + } as const; + + it("listArtifacts(findBy) fails as not yet implemented", async () => { + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.listArtifacts(findBy))); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?.reason).toContain("not yet implemented"); + }); + + it("getArtifact(name, findBy) fails as not yet implemented", async () => { + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.getArtifact("dist", findBy))); + expect(Exit.isFailure(exit)).toBe(true); + }); + + it("downloadArtifact(id, opts, findBy) fails as not yet implemented", async () => { + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.downloadArtifact(7, undefined, findBy))); + expect(Exit.isFailure(exit)).toBe(true); + }); + + it("deleteArtifact(name, findBy) fails as not yet implemented", async () => { + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.deleteArtifact("dist", findBy))); + expect(Exit.isFailure(exit)).toBe(true); + }); + }); + + describe("deleteArtifact", () => { + it("calls DeleteArtifact and returns the deleted id", async () => { + queue( + { status: 200, body: { artifacts: [{ databaseId: "3", name: "dist", size: "9" }] } }, + { status: 200, body: { ok: true, artifactId: "3" } }, + ); + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.deleteArtifact("dist"))); + expect(Exit.isSuccess(exit)).toBe(true); + if (Exit.isSuccess(exit)) { + expect(exit.value).toEqual({ id: 3 }); + } + expect(twirpCaptured[1]?.url).toContain("ArtifactService/DeleteArtifact"); + expect(twirpCaptured[1]?.body).toMatchObject({ name: "dist" }); + }); + + it("fails when the named artifact does not exist", async () => { + queue({ status: 200, body: { artifacts: [] } }); + const exit = await runExit(Effect.flatMap(Artifact, (svc) => svc.deleteArtifact("dist"))); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?.operation).toBe("delete"); + expect(error?.reason).toContain("not found"); + }); + }); + }); +}); diff --git a/src/layers/ArtifactLive.ts b/src/layers/ArtifactLive.ts new file mode 100644 index 0000000..76d7f4c --- /dev/null +++ b/src/layers/ArtifactLive.ts @@ -0,0 +1,566 @@ +import { execFileSync } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { createReadStream, mkdirSync, statSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { pipeline } from "node:stream/promises"; +import { BlobClient, BlockBlobClient } from "@azure/storage-blob"; +import { HttpClient } from "@effect/platform"; +import { Effect, Layer, Option, Redacted } from "effect"; +import { ArtifactError } from "../errors/ArtifactError.js"; +import type { ArtifactItem } from "../services/Artifact.js"; +import { Artifact } from "../services/Artifact.js"; +import { UPLOAD_CHUNK_SIZE, UPLOAD_CONCURRENCY, UPLOAD_MAX_SINGLE_SHOT } from "./internal/azureUpload.js"; +import type { BackendIds } from "./internal/backendIds.js"; +import { getBackendIdsFromEnv } from "./internal/backendIds.js"; +import { + CONFLICT, + isRetryableTwirpReason, + makeTwirpRetrySchedule, + twirpCall as twirpCallShared, +} from "./internal/twirp.js"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const TWIRP_SERVICE = "github.actions.results.api.v1.ArtifactService"; + +/** + * CreateArtifact `version` field. Confirmed `7` against `actions/toolkit` + * `main` (`artifact/src/internal/upload/upload-artifact.ts`, verified + * 2026-05-21) — NOT `4` as the WS4 spec guessed. + */ +const ARTIFACT_VERSION = 7; + +type ArtifactOperation = "upload" | "download" | "list" | "get" | "delete"; + +const RETRY_SCHEDULE = makeTwirpRetrySchedule((error: ArtifactError) => error.reason); + +// --------------------------------------------------------------------------- +// Env + Twirp +// --------------------------------------------------------------------------- + +/** + * Read the artifact protocol env vars (`ACTIONS_RESULTS_URL`, + * `ACTIONS_RUNTIME_TOKEN`) — the same two the cache layer uses — and decode the + * run/job backend ids from the runtime token's `scp` claim. + */ +const getArtifactEnv = (operation: ArtifactOperation, artifact: string) => + Effect.gen(function* () { + const env = yield* Effect.try({ + try: () => { + const resultsUrl = process.env.ACTIONS_RESULTS_URL; + const token = process.env.ACTIONS_RUNTIME_TOKEN; + if (!resultsUrl || !token) { + throw new Error( + `Missing required env vars: ${[!resultsUrl && "ACTIONS_RESULTS_URL", !token && "ACTIONS_RUNTIME_TOKEN"].filter(Boolean).join(", ")}`, + ); + } + const baseUrl = resultsUrl.endsWith("/") ? resultsUrl : `${resultsUrl}/`; + return { baseUrl, token }; + }, + catch: (error) => + new ArtifactError({ + operation, + artifact, + reason: error instanceof Error ? error.message : String(error), + }), + }); + const backendIds = yield* getBackendIdsFromEnv(artifact, operation); + // Hold the runtime token redacted; it is unwrapped only inside the shared + // Twirp request builder, at the request boundary (S9). + return { baseUrl: env.baseUrl, token: Redacted.make(env.token), backendIds }; + }); + +const twirpCall = ( + http: HttpClient.HttpClient, + baseUrl: string, + token: Redacted.Redacted, + method: string, + body: Record, + operation: ArtifactOperation, + artifact: string, +) => + twirpCallShared( + http, + baseUrl, + TWIRP_SERVICE, + token, + method, + body, + (reason) => new ArtifactError({ operation, artifact, reason, retryable: isRetryableTwirpReason(reason) }), + ); + +// --------------------------------------------------------------------------- +// Twirp response shapes (Twirp JSON; field names read defensively in both +// camelCase and snake_case since the protocol is reverse-engineered). +// --------------------------------------------------------------------------- + +interface CreateArtifactResponse { + readonly ok?: boolean; + readonly signedUploadUrl?: string; + readonly signed_upload_url?: string; +} + +interface FinalizeArtifactResponse { + readonly ok?: boolean; + readonly artifactId?: string; + readonly artifact_id?: string; +} + +// Distinct from FinalizeArtifactResponse (same shape today, but the delete RPC +// is a separate protocol message — avoid coupling the two if either diverges). +interface DeleteArtifactResponse { + readonly ok?: boolean; + readonly artifactId?: string; + readonly artifact_id?: string; +} + +interface ListArtifactsResponse { + readonly artifacts?: ReadonlyArray<{ + readonly databaseId?: string; + readonly database_id?: string; + readonly name?: string; + readonly size?: string; + readonly createdAt?: string; + readonly created_at?: string; + }>; +} + +interface GetSignedArtifactURLResponse { + readonly signedUrl?: string; + readonly signed_url?: string; +} + +const pickSignedUploadUrl = (r: CreateArtifactResponse): string | undefined => r.signedUploadUrl ?? r.signed_upload_url; +const pickArtifactId = (r: FinalizeArtifactResponse): string | undefined => r.artifactId ?? r.artifact_id; +const pickDeleteArtifactId = (r: DeleteArtifactResponse): string | undefined => r.artifactId ?? r.artifact_id; +const pickSignedDownloadUrl = (r: GetSignedArtifactURLResponse): string | undefined => r.signedUrl ?? r.signed_url; + +const toArtifactItem = (a: NonNullable[number]): ArtifactItem => { + const createdAt = a.createdAt ?? a.created_at; + return { + id: Number(a.databaseId ?? a.database_id ?? 0), + name: a.name ?? "", + size: Number(a.size ?? 0), + ...(createdAt !== undefined ? { createdAt } : {}), + }; +}; + +// --------------------------------------------------------------------------- +// Zip + hashing + cleanup +// --------------------------------------------------------------------------- + +/** + * Zip `files` (relative to `rootDirectory`) into a temp archive. + * + * Shells out to `zip` (POSIX) / PowerShell `System.IO.Compression.ZipFile` + * (Windows) to preserve the zero-CJS posture, following the `tar` precedent in + * `ActionCacheLive.createArchive` and the Windows fallback in + * `ToolInstallerLive.extractZip`. + */ +const createZip = (files: ReadonlyArray, rootDirectory: string, artifact: string, compressionLevel = 6) => + Effect.try({ + try: () => { + if (files.length === 0) { + throw new Error("No files provided to upload"); + } + const zipPath = join(tmpdir(), `artifact-${randomUUID()}.zip`); + // Store entries relative to `rootDirectory` so the archive (and any + // later extraction) preserves the artifact-relative layout rather than + // the absolute on-disk path. `zip`/Compress-Archive otherwise store the + // paths exactly as given (absolute paths minus the leading separator). + const relFiles = files.map((f) => relative(rootDirectory, f)); + if (process.platform === "win32") { + const fileList = relFiles.map((f) => `'${f.replace(/'/g, "''")}'`).join(","); + const psCommand = `Add-Type -AssemblyName System.IO.Compression.FileSystem; Compress-Archive -Path ${fileList} -DestinationPath '${zipPath.replace(/'/g, "''")}' -Force`; + try { + execFileSync("pwsh", ["-NoProfile", "-NonInteractive", "-Command", psCommand], { + cwd: rootDirectory, + stdio: "pipe", + }); + } catch { + execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", psCommand], { + cwd: rootDirectory, + stdio: "pipe", + }); + } + } else { + // -q quiet, -r recurse, - zlib level (0-9; POSIX `zip` only — + // Compress-Archive has no numeric equivalent). Clamp to a valid flag. + const level = Math.min(9, Math.max(0, Math.trunc(compressionLevel))); + execFileSync("zip", [`-${level}`, "-qr", zipPath, ...relFiles], { cwd: rootDirectory, stdio: "pipe" }); + } + return zipPath; + }, + catch: (error) => + new ArtifactError({ + operation: "upload", + artifact, + reason: `Failed to create zip: ${error instanceof Error ? error.message : String(error)}`, + }), + }); + +/** Extract a zip archive to `targetDir`. */ +const extractZip = (zipPath: string, targetDir: string, artifact: string) => + Effect.try({ + try: () => { + mkdirSync(targetDir, { recursive: true }); + if (process.platform === "win32") { + const psCommand = `Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory('${zipPath.replace(/'/g, "''")}', '${targetDir.replace(/'/g, "''")}')`; + try { + execFileSync("pwsh", ["-NoProfile", "-NonInteractive", "-Command", psCommand], { stdio: "pipe" }); + } catch { + execFileSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", psCommand], { stdio: "pipe" }); + } + } else { + execFileSync("unzip", ["-oq", zipPath, "-d", targetDir], { stdio: "pipe" }); + } + }, + catch: (error) => + new ArtifactError({ + operation: "download", + artifact, + reason: `Failed to extract zip: ${error instanceof Error ? error.message : String(error)}`, + }), + }); + +/** + * Compute the SHA-256 hex digest over the zip file. + * + * @remarks + * The v2 backend's `FinalizeArtifact` expects `hash: "sha256:"` over the + * uploaded bytes — NOT a CRC64 (confirmed against `actions/toolkit` + * `artifact/src/internal/upload/blob-upload.ts`, which hashes the upload stream + * via `crypto.createHash('sha256')` and `.setEncoding('hex')`). Because the + * Azure SDK upload is opaque, we hash the same on-disk zip independently. + */ +const sha256OfFile = (filePath: string, artifact: string) => + Effect.tryPromise({ + try: async () => { + const hash = createHash("sha256"); + await pipeline(createReadStream(filePath), hash); + return hash.digest("hex"); + }, + catch: (error) => + new ArtifactError({ + operation: "upload", + artifact, + reason: `Failed to hash zip: ${error instanceof Error ? error.message : String(error)}`, + }), + }); + +const cleanupFile = (filePath: string) => + Effect.sync(() => { + try { + unlinkSync(filePath); + } catch { + // Ignore cleanup errors. + } + }); + +const findByName = (artifacts: ReadonlyArray, name: string): Option.Option => + Option.fromNullable(artifacts.find((a) => a.name === name)); + +// --------------------------------------------------------------------------- +// Live layer +// --------------------------------------------------------------------------- + +/** + * Live implementation of {@link Artifact} using the V2 Twirp artifact protocol + * and Azure Blob Storage for uploads/downloads. + * + * @remarks + * Requires {@link HttpClient.HttpClient} for the Twirp RPCs; the + * `ActionsRuntime.Default` / `Action.run` path provides it via + * `FetchHttpClient.layer`. The `findBy` (cross-run/cross-repo REST) path is not + * yet implemented and fails with a clear `ArtifactError`. + * + * The artifact backend is an internal GitHub protocol reverse-engineered from + * `actions/toolkit`; it must be validated against a live GitHub-hosted runner + * before relying on it. + * + * @public + */ +export const ArtifactLive: Layer.Layer = Layer.effect( + Artifact, + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient; + + const findByUnsupported = (operation: ArtifactOperation, artifact: string) => + Effect.fail( + new ArtifactError({ + operation, + artifact, + reason: "Cross-run/cross-repo findBy via the public REST API is not yet implemented", + }), + ); + + const listInternal = ( + baseUrl: string, + token: Redacted.Redacted, + backendIds: BackendIds, + artifact: string, + ) => + Effect.gen(function* () { + const result = yield* twirpCall( + http, + baseUrl, + token, + "ListArtifacts", + { ...backendIds }, + "list", + artifact, + ).pipe(Effect.retry(RETRY_SCHEDULE)); + if (result === CONFLICT) { + return [] as ReadonlyArray; + } + return (result.artifacts ?? []).map(toArtifactItem); + }); + + return { + uploadArtifact: (name, files, rootDirectory, options) => + Effect.gen(function* () { + if (options?.retentionDays !== undefined && options.retentionDays <= 0) { + return yield* Effect.fail( + new ArtifactError({ operation: "upload", artifact: name, reason: "retentionDays must be positive" }), + ); + } + const { baseUrl, token, backendIds } = yield* getArtifactEnv("upload", name); + + return yield* Effect.acquireUseRelease( + createZip(files, rootDirectory, name, options?.compressionLevel), + (zipPath) => + Effect.gen(function* () { + // Step 1: CreateArtifact. + const createResult = yield* twirpCall( + http, + baseUrl, + token, + "CreateArtifact", + { ...backendIds, name, version: ARTIFACT_VERSION, mimeType: "application/zip" }, + "upload", + name, + ).pipe(Effect.retry(RETRY_SCHEDULE)); + + // v2 forbids re-uploading the same name in a run; the backend + // signals this via HTTP 409 (CONFLICT) or ok:false. + if (createResult === CONFLICT || !createResult.ok) { + return yield* Effect.fail( + new ArtifactError({ + operation: "upload", + artifact: name, + reason: "CreateArtifact failed: artifact already exists or was rejected", + }), + ); + } + const uploadUrl = pickSignedUploadUrl(createResult); + if (!uploadUrl) { + return yield* Effect.fail( + new ArtifactError({ + operation: "upload", + artifact: name, + reason: "CreateArtifact did not return a signed upload URL", + }), + ); + } + + // Step 2: upload the zip to the Azure block blob. + yield* Effect.tryPromise({ + try: async () => { + const client = new BlockBlobClient(uploadUrl); + await client.uploadFile(zipPath, { + blockSize: UPLOAD_CHUNK_SIZE, + concurrency: UPLOAD_CONCURRENCY, + maxSingleShotSize: UPLOAD_MAX_SINGLE_SHOT, + }); + }, + catch: (error) => + new ArtifactError({ + operation: "upload", + artifact: name, + reason: `Artifact upload failed: ${error instanceof Error ? error.message : String(error)}`, + }), + }); + + // Step 3: size + sha256, then FinalizeArtifact. + const size = yield* Effect.try({ + try: () => statSync(zipPath).size, + catch: (error) => + new ArtifactError({ + operation: "upload", + artifact: name, + reason: `Failed to stat zip: ${error instanceof Error ? error.message : String(error)}`, + }), + }); + const sha256 = yield* sha256OfFile(zipPath, name); + + const finalizeResult = yield* twirpCall( + http, + baseUrl, + token, + "FinalizeArtifact", + { + ...backendIds, + name, + size: String(size), + hash: `sha256:${sha256}`, + // Forward retentionDays as the backend's `expiresAt` (ISO), matching + // @actions/artifact; omitted leaves the repo-default retention. + ...(options?.retentionDays !== undefined + ? { expiresAt: new Date(Date.now() + options.retentionDays * 86_400_000).toISOString() } + : {}), + }, + "upload", + name, + ).pipe(Effect.retry(RETRY_SCHEDULE)); + + if (finalizeResult === CONFLICT || !finalizeResult.ok) { + return yield* Effect.fail( + new ArtifactError({ + operation: "upload", + artifact: name, + reason: "FinalizeArtifact did not confirm success", + }), + ); + } + const id = Number(pickArtifactId(finalizeResult) ?? 0); + return { id, size }; + }), + (zipPath) => cleanupFile(zipPath), + ); + }), + + listArtifacts: (findBy) => + findBy + ? findByUnsupported("list", "*") + : Effect.gen(function* () { + const { baseUrl, token, backendIds } = yield* getArtifactEnv("list", "*"); + return yield* listInternal(baseUrl, token, backendIds, "*"); + }), + + getArtifact: (name, findBy) => + findBy + ? findByUnsupported("get", name) + : Effect.gen(function* () { + const { baseUrl, token, backendIds } = yield* getArtifactEnv("get", name); + const artifacts = yield* listInternal(baseUrl, token, backendIds, name); + return findByName(artifacts, name); + }), + + downloadArtifact: (artifactId, options, findBy) => + findBy + ? findByUnsupported("download", String(artifactId)) + : Effect.gen(function* () { + const idStr = String(artifactId); + const { baseUrl, token, backendIds } = yield* getArtifactEnv("download", idStr); + + // Resolve the artifact name from its id via ListArtifacts. + const artifacts = yield* listInternal(baseUrl, token, backendIds, idStr); + const match = artifacts.find((a) => a.id === artifactId); + if (!match) { + return yield* Effect.fail( + new ArtifactError({ + operation: "download", + artifact: idStr, + reason: `Artifact not found in the current run: id ${artifactId}`, + }), + ); + } + + const signedResult = yield* twirpCall( + http, + baseUrl, + token, + "GetSignedArtifactURL", + { ...backendIds, name: match.name }, + "download", + idStr, + ).pipe(Effect.retry(RETRY_SCHEDULE)); + if (signedResult === CONFLICT) { + return yield* Effect.fail( + new ArtifactError({ + operation: "download", + artifact: idStr, + reason: "GetSignedArtifactURL returned a conflict", + }), + ); + } + const downloadUrl = pickSignedDownloadUrl(signedResult); + if (!downloadUrl) { + return yield* Effect.fail( + new ArtifactError({ + operation: "download", + artifact: idStr, + reason: "GetSignedArtifactURL did not return a signed URL", + }), + ); + } + + const targetDir = options?.path ?? join(tmpdir(), `artifact-download-${randomUUID()}`); + const zipPath = join(tmpdir(), `artifact-download-${randomUUID()}.zip`); + + yield* Effect.acquireUseRelease( + Effect.tryPromise({ + try: async () => { + const client = new BlobClient(downloadUrl); + await client.downloadToFile(zipPath); + return zipPath; + }, + catch: (error) => + new ArtifactError({ + operation: "download", + artifact: idStr, + reason: `Artifact download failed: ${error instanceof Error ? error.message : String(error)}`, + }), + }), + (downloadedZip) => extractZip(downloadedZip, targetDir, idStr), + (downloadedZip) => cleanupFile(downloadedZip), + ); + + return { downloadPath: targetDir }; + }), + + deleteArtifact: (name, findBy) => + findBy + ? findByUnsupported("delete", name) + : Effect.gen(function* () { + const { baseUrl, token, backendIds } = yield* getArtifactEnv("delete", name); + + const artifacts = yield* listInternal(baseUrl, token, backendIds, name); + const match = findByName(artifacts, name); + if (Option.isNone(match)) { + return yield* Effect.fail( + new ArtifactError({ + operation: "delete", + artifact: name, + reason: `Artifact not found in the current run: ${name}`, + }), + ); + } + + const deleteResult = yield* twirpCall( + http, + baseUrl, + token, + "DeleteArtifact", + { ...backendIds, name }, + "delete", + name, + ).pipe(Effect.retry(RETRY_SCHEDULE)); + if (deleteResult === CONFLICT) { + return yield* Effect.fail( + new ArtifactError({ + operation: "delete", + artifact: name, + reason: "DeleteArtifact returned a conflict", + }), + ); + } + const id = Number(pickDeleteArtifactId(deleteResult) ?? match.value.id); + return { id }; + }), + }; + }), +); diff --git a/src/layers/ArtifactTest.test.ts b/src/layers/ArtifactTest.test.ts new file mode 100644 index 0000000..097c602 --- /dev/null +++ b/src/layers/ArtifactTest.test.ts @@ -0,0 +1,92 @@ +import { Effect, Option } from "effect"; +import { describe, expect, it } from "vitest"; +import { Artifact } from "../services/Artifact.js"; +import { ArtifactTest } from "./ArtifactTest.js"; + +const run = (state: ReturnType, effect: Effect.Effect) => + Effect.runPromise(Effect.provide(effect, ArtifactTest.layer(state))); + +describe("ArtifactTest round-trip", () => { + it("upload then list returns the uploaded artifact", async () => { + const state = ArtifactTest.empty(); + const result = await run( + state, + Effect.gen(function* () { + const svc = yield* Artifact; + yield* svc.uploadArtifact("dist", ["a.txt", "b.txt"], "/work"); + return yield* svc.listArtifacts(); + }), + ); + expect(result).toHaveLength(1); + expect(result[0]?.name).toBe("dist"); + expect(result[0]?.size).toBe(2); + expect(state.uploaded.get("dist")).toEqual(["a.txt", "b.txt"]); + }); + + it("upload then getArtifact(name) returns it; unknown name → none", async () => { + const state = ArtifactTest.empty(); + const [hit, miss] = await run( + state, + Effect.gen(function* () { + const svc = yield* Artifact; + const { id } = yield* svc.uploadArtifact("dist", ["a.txt"], "/work"); + const found = yield* svc.getArtifact("dist"); + const notFound = yield* svc.getArtifact("nope"); + return [found, notFound, id] as const; + }), + ); + expect(Option.isSome(hit)).toBe(true); + if (Option.isSome(hit)) expect(hit.value.name).toBe("dist"); + expect(Option.isNone(miss)).toBe(true); + }); + + it("assigns incrementing ids across uploads", async () => { + const state = ArtifactTest.empty(); + const ids = await run( + state, + Effect.gen(function* () { + const svc = yield* Artifact; + const a = yield* svc.uploadArtifact("one", ["x"], "/work"); + const b = yield* svc.uploadArtifact("two", ["y"], "/work"); + return [a.id, b.id] as const; + }), + ); + expect(ids[0]).toBe(1); + expect(ids[1]).toBe(2); + }); + + it("delete removes it from subsequent list", async () => { + const state = ArtifactTest.empty(); + const [deleted, after] = await run( + state, + Effect.gen(function* () { + const svc = yield* Artifact; + const { id } = yield* svc.uploadArtifact("dist", ["a.txt"], "/work"); + const del = yield* svc.deleteArtifact("dist"); + const list = yield* svc.listArtifacts(); + return [del.id === id, list] as const; + }), + ); + expect(deleted).toBe(true); + expect(after).toEqual([]); + }); + + it("deleteArtifact fails for an unknown name", async () => { + const state = ArtifactTest.empty(); + const exit = await Effect.runPromise( + Effect.exit( + Effect.flatMap(Artifact, (svc) => svc.deleteArtifact("nope")).pipe(Effect.provide(ArtifactTest.layer(state))), + ), + ); + expect(exit._tag).toBe("Failure"); + }); + + it("downloadArtifact returns the requested path", async () => { + const state = ArtifactTest.empty(); + const result = await run( + state, + Effect.flatMap(Artifact, (svc) => svc.downloadArtifact(7, { path: "/dest" })), + ); + expect(result.downloadPath).toBe("/dest"); + }); +}); diff --git a/src/layers/ArtifactTest.ts b/src/layers/ArtifactTest.ts new file mode 100644 index 0000000..2ec3ebe --- /dev/null +++ b/src/layers/ArtifactTest.ts @@ -0,0 +1,82 @@ +import { Effect, Layer, Option } from "effect"; +import { ArtifactError } from "../errors/ArtifactError.js"; +import type { ArtifactItem } from "../services/Artifact.js"; +import { Artifact } from "../services/Artifact.js"; + +/** + * In-memory artifact state for testing. + * + * @remarks + * `artifacts` maps artifact name → metadata; `uploaded` records the file list + * each upload carried so a test can assert what was uploaded without touching + * the network or disk. `nextId` is the auto-incrementing artifact id. + * + * @public + */ +export interface ArtifactTestState { + readonly artifacts: Map; + readonly uploaded: Map>; + nextId: number; +} + +const makeTestArtifact = (state: ArtifactTestState): typeof Artifact.Service => ({ + uploadArtifact: (name, files, _rootDirectory, _options) => + Effect.sync(() => { + const id = state.nextId++; + const size = files.length; + state.artifacts.set(name, { id, name, size, createdAt: new Date(0).toISOString() }); + state.uploaded.set(name, [...files]); + return { id, size }; + }), + + listArtifacts: (_findBy) => Effect.sync(() => [...state.artifacts.values()]), + + getArtifact: (name, _findBy) => + Effect.sync((): Option.Option => { + const item = state.artifacts.get(name); + return item ? Option.some(item) : Option.none(); + }), + + downloadArtifact: (artifactId, options, _findBy) => + Effect.sync(() => ({ downloadPath: options?.path ?? `/tmp/artifact-${artifactId}` })), + + deleteArtifact: (name, _findBy) => + Effect.gen(function* () { + const item = state.artifacts.get(name); + if (!item) { + return yield* Effect.fail( + new ArtifactError({ + operation: "delete", + artifact: name, + reason: `Artifact not found: ${name}`, + }), + ); + } + state.artifacts.delete(name); + state.uploaded.delete(name); + return { id: item.id }; + }), +}); + +/** + * Test implementation for {@link Artifact}. + * + * @example + * ```ts + * const state = ArtifactTest.empty(); + * const layer = ArtifactTest.layer(state); + * ``` + * + * @public + */ +export const ArtifactTest = { + /** Create a fresh empty test state container. */ + empty: (): ArtifactTestState => ({ + artifacts: new Map(), + uploaded: new Map(), + nextId: 1, + }), + + /** Create a test layer from the given state. */ + layer: (state: ArtifactTestState): Layer.Layer => Layer.succeed(Artifact, makeTestArtifact(state)), +} as const; diff --git a/src/layers/internal/azureUpload.ts b/src/layers/internal/azureUpload.ts new file mode 100644 index 0000000..c40819d --- /dev/null +++ b/src/layers/internal/azureUpload.ts @@ -0,0 +1,20 @@ +/** + * Azure Block Blob upload tuning shared by the cache and artifact layers. + * + * @remarks + * Both `ActionCacheLive` and `ArtifactLive` upload to Azure Block Blob storage + * with the same `BlockBlobClient.uploadFile` block-size / concurrency settings + * (matching `actions/cache` and `actions/artifact`). Centralized here so a + * future tuning flows through to both rather than drifting. + * + * @internal + */ + +/** Block size for `BlockBlobClient.uploadFile` (64 MiB). */ +export const UPLOAD_CHUNK_SIZE = 64 * 1024 * 1024; + +/** Parallel block uploads for `BlockBlobClient.uploadFile`. */ +export const UPLOAD_CONCURRENCY = 8; + +/** Single-shot upload ceiling; larger payloads are chunked (128 MiB). */ +export const UPLOAD_MAX_SINGLE_SHOT = 128 * 1024 * 1024; diff --git a/src/layers/internal/backendIds.test.ts b/src/layers/internal/backendIds.test.ts new file mode 100644 index 0000000..f06ced2 --- /dev/null +++ b/src/layers/internal/backendIds.test.ts @@ -0,0 +1,90 @@ +import { Effect, Exit } from "effect"; +import { afterEach, describe, expect, it } from "vitest"; +import type { ArtifactError } from "../../errors/ArtifactError.js"; +import { getBackendIdsFromToken } from "./backendIds.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Build a synthetic (unsigned) JWT carrying the given payload. */ +const makeToken = (payload: Record): string => { + const b64url = (obj: unknown): string => + Buffer.from(JSON.stringify(obj)).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + return `${b64url({ alg: "HS256", typ: "JWT" })}.${b64url(payload)}.signature`; +}; + +const run = (effect: Effect.Effect) => Effect.runPromise(Effect.exit(effect)); + +const extractError = (exit: Exit.Exit): ArtifactError | undefined => + Exit.isFailure(exit) ? (exit.cause as { error?: ArtifactError }).error : undefined; + +afterEach(() => { + delete process.env.ACTIONS_RUNTIME_TOKEN; +}); + +describe("getBackendIdsFromToken", () => { + it("extracts run + job backend ids from a synthetic scp claim", async () => { + const token = makeToken({ + scp: "Actions.Results:run-backend-id:job-backend-id Actions.UploadArtifacts:Other", + }); + const exit = await run(getBackendIdsFromToken(token, "test-artifact", "upload")); + expect(Exit.isSuccess(exit)).toBe(true); + if (Exit.isSuccess(exit)) { + expect(exit.value).toEqual({ + workflowRunBackendId: "run-backend-id", + workflowJobRunBackendId: "job-backend-id", + }); + } + }); + + it("fails when the Actions.Results scope is absent", async () => { + const token = makeToken({ scp: "Actions.UploadArtifacts:Create Actions.Generic:Read" }); + const exit = await run(getBackendIdsFromToken(token, "test-artifact", "upload")); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?._tag).toBe("ArtifactError"); + expect(error?.reason).toContain("Actions.Results"); + }); + + it("fails when the scp claim is missing entirely", async () => { + const token = makeToken({ sub: "no-scope-here" }); + const exit = await run(getBackendIdsFromToken(token, "test-artifact", "list")); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?.reason).toContain("scp"); + }); + + it("fails when the token is not a 3-segment JWT", async () => { + const exit = await run(getBackendIdsFromToken("not-a-jwt", "test-artifact", "upload")); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?._tag).toBe("ArtifactError"); + }); +}); + +describe("getBackendIdsFromEnv", () => { + it("reads ACTIONS_RUNTIME_TOKEN and decodes the backend ids", async () => { + const { getBackendIdsFromEnv } = await import("./backendIds.js"); + process.env.ACTIONS_RUNTIME_TOKEN = makeToken({ + scp: "Actions.Results:env-run-id:env-job-id", + }); + const exit = await run(getBackendIdsFromEnv("test-artifact", "upload")); + expect(Exit.isSuccess(exit)).toBe(true); + if (Exit.isSuccess(exit)) { + expect(exit.value).toEqual({ + workflowRunBackendId: "env-run-id", + workflowJobRunBackendId: "env-job-id", + }); + } + }); + + it("fails when ACTIONS_RUNTIME_TOKEN is unset", async () => { + const { getBackendIdsFromEnv } = await import("./backendIds.js"); + delete process.env.ACTIONS_RUNTIME_TOKEN; + const exit = await run(getBackendIdsFromEnv("test-artifact", "upload")); + expect(Exit.isFailure(exit)).toBe(true); + const error = extractError(exit as Exit.Exit); + expect(error?.reason).toContain("ACTIONS_RUNTIME_TOKEN"); + }); +}); diff --git a/src/layers/internal/backendIds.ts b/src/layers/internal/backendIds.ts new file mode 100644 index 0000000..a99ea99 --- /dev/null +++ b/src/layers/internal/backendIds.ts @@ -0,0 +1,107 @@ +import { Effect } from "effect"; +import { ArtifactError } from "../../errors/ArtifactError.js"; + +/** + * Backend identifiers decoded from the GitHub Actions runtime token's `scp` + * claim. The artifact Twirp API requires both on every upload/list/get/delete + * call to scope the operation to the current run + job. + * + * @internal + */ +export interface BackendIds { + readonly workflowRunBackendId: string; + readonly workflowJobRunBackendId: string; +} + +type ArtifactOperation = "upload" | "download" | "list" | "get" | "delete"; + +/** + * Decode the base64url-encoded payload segment of a JWT. + * + * Mirrors `slsa.ts`'s `base64UrlDecode` — we do not verify the signature; the + * token came from the runner over TLS and we read only the `scp` claim. + */ +const base64UrlDecode = (segment: string): string => { + const padded = segment + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(Math.ceil(segment.length / 4) * 4, "="); + return Buffer.from(padded, "base64").toString("utf-8"); +}; + +/** + * Decode `workflowRunBackendId` / `workflowJobRunBackendId` from a GitHub + * Actions runtime token (`ACTIONS_RUNTIME_TOKEN`). + * + * @remarks + * The runtime token is a JWT whose `scp` (scope) claim is a space-separated + * list. The artifact backend IDs live in the scope beginning with + * `Actions.Results:`, formatted `Actions.Results::`. + * This mirrors `@actions/artifact`'s `getBackendIdsFromToken` + * (`artifact/src/internal/shared/util.ts`). The runtime token is NOT an OIDC + * token, so it lacks the claims `slsa.decodeJwtClaims` requires — we reuse only + * the base64url payload-decoding technique, not that validator. + * + * @internal + */ +export const getBackendIdsFromToken = ( + token: string, + artifact: string, + operation: ArtifactOperation, +): Effect.Effect => + Effect.try({ + try: () => { + const parts = token.split("."); + if (parts.length !== 3) { + throw new Error(`Expected a 3-segment JWT, got ${parts.length}`); + } + const payload = JSON.parse(base64UrlDecode(parts[1])) as Record; + const scp = payload.scp; + if (typeof scp !== "string") { + throw new Error("Runtime token is missing a string `scp` claim"); + } + for (const scope of scp.split(" ")) { + const scopeParts = scope.split(":"); + if (scopeParts[0] !== "Actions.Results") { + continue; + } + if (scopeParts.length !== 3 || !scopeParts[1] || !scopeParts[2]) { + throw new Error(`Malformed Actions.Results scope: "${scope}"`); + } + return { + workflowRunBackendId: scopeParts[1], + workflowJobRunBackendId: scopeParts[2], + }; + } + throw new Error("Runtime token has no `Actions.Results` scope"); + }, + catch: (error) => + new ArtifactError({ + operation, + artifact, + reason: error instanceof Error ? error.message : String(error), + }), + }); + +/** + * Read `ACTIONS_RUNTIME_TOKEN` from the environment and decode the backend IDs. + * + * @internal + */ +export const getBackendIdsFromEnv = ( + artifact: string, + operation: ArtifactOperation, +): Effect.Effect => + Effect.suspend(() => { + const token = process.env.ACTIONS_RUNTIME_TOKEN; + if (!token) { + return Effect.fail( + new ArtifactError({ + operation, + artifact, + reason: "Missing required env var: ACTIONS_RUNTIME_TOKEN", + }), + ); + } + return getBackendIdsFromToken(token, artifact, operation); + }); diff --git a/src/layers/internal/twirp.test.ts b/src/layers/internal/twirp.test.ts new file mode 100644 index 0000000..b540ef8 --- /dev/null +++ b/src/layers/internal/twirp.test.ts @@ -0,0 +1,164 @@ +import { HttpClient, HttpClientError, HttpClientResponse } from "@effect/platform"; +import { Data, Duration, Effect, Exit, Fiber, Layer, Redacted, TestClock, TestContext } from "effect"; +import { beforeEach, describe, expect, it } from "vitest"; +import { CONFLICT, makeTwirpRetrySchedule, twirpCall } from "./twirp.js"; + +// --------------------------------------------------------------------------- +// Test error type (twirp.ts is generic over the error channel) +// --------------------------------------------------------------------------- + +class TestError extends Data.TaggedError("TestError")<{ readonly reason: string }> {} + +const onError = (reason: string): TestError => new TestError({ reason }); + +// --------------------------------------------------------------------------- +// Mock HttpClient (same shape ActionCacheLive.test.ts uses) +// --------------------------------------------------------------------------- + +interface Reply { + readonly status: number; + readonly body?: unknown; + readonly transportError?: string; +} + +interface Captured { + readonly url: string; + readonly body: unknown; + readonly headers: Record; +} + +let replies: Array = []; +let captured: Array = []; + +const mockHttp: Layer.Layer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request, url) => + Effect.gen(function* () { + const headers: Record = {}; + for (const [k, v] of Object.entries(request.headers)) { + if (typeof v === "string") headers[k.toLowerCase()] = v; + } + const bodyText = yield* Effect.promise(async () => { + const b = request.body as { body?: unknown }; + if (b && typeof b === "object" && "body" in b && b.body instanceof Uint8Array) { + return new TextDecoder().decode(b.body); + } + return ""; + }); + const parsedBody = bodyText ? JSON.parse(bodyText) : undefined; + captured.push({ url: url.toString(), body: parsedBody, headers }); + + const reply = replies.shift() ?? { status: 500 }; + if (reply.transportError !== undefined) { + return yield* Effect.fail( + new HttpClientError.RequestError({ + request, + reason: "Transport", + cause: new Error(reply.transportError), + description: reply.transportError, + }), + ); + } + const noBody = reply.status === 204 || reply.status === 304; + return HttpClientResponse.fromWeb( + request, + new Response(noBody ? null : JSON.stringify(reply.body ?? null), { + status: reply.status, + headers: { "content-type": "application/json" }, + }), + ); + }), + ), +); + +const run = (effect: Effect.Effect) => + Effect.runPromise(Effect.exit(Effect.provide(effect, mockHttp))); + +beforeEach(() => { + replies = []; + captured = []; +}); + +const call = (method: string, body: Record = {}) => + Effect.gen(function* () { + const http = yield* HttpClient.HttpClient; + return yield* twirpCall( + http, + "https://results.example.com/", + "github.actions.results.api.v1.ArtifactService", + Redacted.make("test-token"), + method, + body, + onError, + ); + }); + +describe("twirpCall", () => { + it("posts to /twirp// with bearer auth", async () => { + replies = [{ status: 200, body: { ok: true } }]; + const exit = await run(call("CreateArtifact", { name: "x" })); + expect(Exit.isSuccess(exit)).toBe(true); + expect(captured[0]?.url).toContain("twirp/github.actions.results.api.v1.ArtifactService/CreateArtifact"); + expect(captured[0]?.headers.authorization).toBe("Bearer test-token"); + expect(captured[0]?.body).toMatchObject({ name: "x" }); + // The runtime token must not leak into the request body. + expect(JSON.stringify(captured[0]?.body)).not.toContain("test-token"); + }); + + it("returns the CONFLICT sentinel on HTTP 409", async () => { + replies = [{ status: 409 }]; + const exit = await run(call("CreateArtifact")); + expect(Exit.isSuccess(exit)).toBe(true); + if (Exit.isSuccess(exit)) { + expect(exit.value).toBe(CONFLICT); + } + }); + + it("fails on a non-ok status, preserving the `HTTP ` substring", async () => { + replies = [{ status: 400 }]; + const exit = await run(call("ListArtifacts")); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const reason = (exit.cause as { error?: TestError }).error?.reason ?? ""; + expect(reason).toContain("ListArtifacts failed"); + expect(reason).toContain("HTTP 400"); + } + }); + + it("preserves the transport-fault message (ECONNRESET) for the retry schedule", async () => { + replies = [{ status: 0, transportError: "read ECONNRESET" }]; + const exit = await run(call("ListArtifacts")); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const reason = (exit.cause as { error?: TestError }).error?.reason ?? ""; + expect(reason).toContain("ListArtifacts failed"); + expect(reason).toContain("ECONNRESET"); + } + }); +}); + +describe("makeTwirpRetrySchedule", () => { + const retryingCall = (method: string) => + call(method).pipe(Effect.retry(makeTwirpRetrySchedule((e: TestError) => e.reason))); + + const runWithClock = (effect: Effect.Effect) => + Effect.gen(function* () { + const fiber = yield* Effect.fork(Effect.provide(effect, mockHttp)); + yield* TestClock.adjust(Duration.seconds(120)); + return yield* Fiber.join(fiber); + }).pipe(Effect.exit, Effect.provide(TestContext.TestContext), Effect.runPromise); + + it("retries on HTTP 503 then succeeds", async () => { + replies = [{ status: 503 }, { status: 503 }, { status: 200, body: { ok: true } }]; + const exit = await runWithClock(retryingCall("CreateArtifact")); + expect(exit._tag).toBe("Success"); + expect(captured).toHaveLength(3); + }); + + it("gives up after exhausting the retry budget on persistent 503", async () => { + replies = [{ status: 503 }, { status: 503 }, { status: 503 }, { status: 503 }, { status: 503 }]; + const exit = await runWithClock(retryingCall("CreateArtifact")); + expect(exit._tag).toBe("Failure"); + expect(captured).toHaveLength(5); + }); +}); diff --git a/src/layers/internal/twirp.ts b/src/layers/internal/twirp.ts new file mode 100644 index 0000000..174fbb6 --- /dev/null +++ b/src/layers/internal/twirp.ts @@ -0,0 +1,118 @@ +import type { HttpClient } from "@effect/platform"; +import { HttpClientRequest, HttpClientResponse } from "@effect/platform"; +import { Effect, Redacted, Schedule, Schema } from "effect"; + +/** + * Shared Twirp RPC plumbing for the GitHub Actions results backend. + * + * @remarks + * The cache (`ActionCacheLive`) and artifact (`ArtifactLive`) services both + * speak Twirp over HTTP to the same `ACTIONS_RESULTS_URL` backend, differing + * only in the service segment of the path + * (`github.actions.results.api.v1.CacheService` vs `…ArtifactService`). This + * module extracts the `twirpCall` helper, the `CONFLICT` sentinel, and the + * retry schedule so both layers share one source of truth. + * + * It is generic over the error channel `E` so each caller maps transport / + * HTTP failures into its own `Data.TaggedError`. The error `reason` strings are + * preserved verbatim (` failed: HTTP from ` and + * ` failed: `) so the retry schedule's + * `reason.includes("HTTP 503")` / `reason.includes("ECONNRESET")` predicates + * keep firing and existing `ActionCacheLive` assertions stay valid. + * + * @internal + */ + +/** + * Sentinel returned by {@link twirpCall} when the server responds with HTTP 409 + * (Conflict), indicating the resource already exists. Callers may treat this as + * a success. + * + * @internal + */ +export const CONFLICT = Symbol.for("twirp/conflict"); + +/** Result of a Twirp call: the decoded body, or the {@link CONFLICT} sentinel. */ +export type TwirpResult = T | typeof CONFLICT; + +/** + * Make a Twirp RPC call (POST with a JSON body/response) against + * `twirp//`. + * + * Returns {@link CONFLICT} on HTTP 409 instead of failing. Maps transport faults + * and non-2xx statuses into `E` via `onError`, preserving the substrings the + * retry schedule keys off. + * + * @internal + */ +export const twirpCall = ( + http: HttpClient.HttpClient, + baseUrl: string, + service: string, + token: Redacted.Redacted, + method: string, + body: Record, + onError: (reason: string) => E, +): Effect.Effect, E> => + Effect.gen(function* () { + const request = HttpClientRequest.post(`${baseUrl}twirp/${service}/${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 ` failed: `; the message + // preserves the underlying `ECONNRESET`/`ETIMEDOUT` substring the retry + // schedule keys off. + const response = yield* http + .execute(request) + .pipe(Effect.mapError((cause) => onError(`${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 + // (` failed: HTTP from `): the leading + // ` failed` keeps existing assertions valid and the embedded + // `HTTP ` keeps the retry schedule's `reason.includes("HTTP + // 503")` predicate firing. + return yield* Effect.fail(onError(`${method} failed: HTTP ${response.status} from ${method}`)); + } + return (yield* HttpClientResponse.schemaBodyJson(Schema.Unknown)(response).pipe( + Effect.mapError((cause) => onError(`${method} failed: ${cause.message ?? String(cause)}`)), + )) as T; + }); + +/** + * Reason substrings that mark a Twirp failure as retryable (transient 5xx and + * network faults). + * + * @internal + */ +const RETRYABLE_REASONS = [ + "HTTP 500", + "HTTP 502", + "HTTP 503", + "HTTP 504", + "ECONNRESET", + "ECONNREFUSED", + "ETIMEDOUT", +] as const; + +/** + * Whether a failure `reason` string marks a transient/retryable Twirp fault. + * + * @internal + */ +export const isRetryableTwirpReason = (reason: string): boolean => + RETRYABLE_REASONS.some((needle) => reason.includes(needle)); + +/** + * Build the shared Twirp retry schedule (exponential backoff capped at 4 + * retries) gated on the caller's error `reason` string. + * + * @internal + */ +export const makeTwirpRetrySchedule = (reasonOf: (error: E) => string): Schedule.Schedule => + Schedule.intersect(Schedule.exponential("3 seconds", 1.5), Schedule.recurs(4)).pipe( + Schedule.whileInput((error: E) => isRetryableTwirpReason(reasonOf(error))), + ); diff --git a/src/services/Artifact.ts b/src/services/Artifact.ts new file mode 100644 index 0000000..c44f58d --- /dev/null +++ b/src/services/Artifact.ts @@ -0,0 +1,130 @@ +import type { Effect, Option } from "effect"; +import { Context } from "effect"; +import type { ArtifactError } from "../errors/ArtifactError.js"; + +/** + * Metadata for a single artifact returned by the backend. + * + * @public + */ +export interface ArtifactItem { + readonly id: number; + readonly name: string; + readonly size: number; + readonly createdAt?: string; +} + +/** + * Options for {@link Artifact.uploadArtifact}. + * + * @public + */ +export interface UploadOptions { + /** + * Number of days to retain the artifact (1–90, or the repository max). + * Default: the repository retention setting. + */ + readonly retentionDays?: number; + /** + * zlib compression level 0–9 passed to POSIX `zip` (`-0`…`-9`). Default 6, + * matching `@actions/artifact`. Out-of-range values are clamped. No effect on + * Windows, where `Compress-Archive` has no numeric level. + */ + readonly compressionLevel?: number; +} + +/** + * Result of a successful {@link Artifact.uploadArtifact}. + * + * @public + */ +export interface UploadResult { + readonly id: number; + readonly size: number; +} + +/** + * Options for {@link Artifact.downloadArtifact}. + * + * @public + */ +export interface DownloadOptions { + /** Destination directory. Default: a fresh temp dir. */ + readonly path?: string; +} + +/** + * Cross-run / cross-repo lookup parameters. + * + * @remarks + * When supplied, list/get/download/delete route through the public REST API + * (`GET /repos/{owner}/{repo}/actions/...`) instead of the same-run Twirp + * backend, and require a `GITHUB_TOKEN` with `actions:read`. + * + * @public + */ +export interface FindBy { + readonly token: string; + readonly workflowRunId: number; + readonly repositoryOwner: string; + readonly repositoryName: string; +} + +/** + * Service for uploading, listing, downloading, and deleting GitHub Actions + * artifacts (`@actions/artifact` v2 parity). + * + * @remarks + * `ArtifactLive` speaks the GitHub Actions results backend (Twirp + * `github.actions.results.api.v1.ArtifactService` + Azure Block Blob) using the + * runner-provided `ACTIONS_RESULTS_URL` / `ACTIONS_RUNTIME_TOKEN`. The artifact + * backend is an internal GitHub protocol and may change without notice; the + * implementation mirrors the already-shipped V2 cache layer. `ArtifactTest` is + * an in-memory namespace layer. No dependency on `@actions/artifact`. + * + * **Must run inside an action, not a `run:` step.** The runner injects + * `ACTIONS_RESULTS_URL` / `ACTIONS_RUNTIME_TOKEN` only into action + * (`uses:`) execution contexts — they are absent from `run:` shell/script + * steps. Like `@actions/artifact`, this service therefore works only when the + * code is invoked from within a bundled JS action (e.g. one produced by + * `@savvy-web/github-action-builder`), where those vars are present. The same + * constraint applies to `ActionCache`, which reads the same two vars. + * + * @public + */ +export class Artifact extends Context.Tag("github-action-effects/Artifact")< + Artifact, + { + /** + * Zip `files` (relative to `rootDirectory`) and upload them as a named + * artifact. v2 forbids re-uploading the same name in a run; a conflict is + * surfaced as an {@link ArtifactError} (`operation: "upload"`). + */ + readonly uploadArtifact: ( + name: string, + files: ReadonlyArray, + rootDirectory: string, + options?: UploadOptions, + ) => Effect.Effect; + + /** List artifacts for the current run (or a `findBy` run/repo). */ + readonly listArtifacts: (findBy?: FindBy) => Effect.Effect, ArtifactError>; + + /** + * Look up a single artifact by name. Returns `Option.none()` on miss (the + * toolkit throws `ArtifactNotFoundError`; `Option` is the idiomatic Effect + * modeling and matches `ActionCache.restore`). + */ + readonly getArtifact: (name: string, findBy?: FindBy) => Effect.Effect, ArtifactError>; + + /** Download an artifact by id, returning the directory it was unzipped to. */ + readonly downloadArtifact: ( + artifactId: number, + options?: DownloadOptions, + findBy?: FindBy, + ) => Effect.Effect<{ readonly downloadPath: string }, ArtifactError>; + + /** Delete an artifact by name, returning the deleted artifact's id. */ + readonly deleteArtifact: (name: string, findBy?: FindBy) => Effect.Effect<{ readonly id: number }, ArtifactError>; + } +>() {} diff --git a/src/testing.ts b/src/testing.ts index 6590169..948a064 100644 --- a/src/testing.ts +++ b/src/testing.ts @@ -14,6 +14,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"; @@ -63,6 +64,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"; @@ -218,6 +222,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";