@effected/github-actions 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,22 @@
1
+ import { ActionEnvironment, ActionEnvironmentError, GitHubContext, RunnerContext } from "./ActionEnvironment.js";
2
+ import { ActionInput } from "./ActionInput.js";
3
+ import { WorkflowCommand } from "./WorkflowCommand.js";
4
+ import { ActionLogger } from "./ActionLogger.js";
5
+ import { ActionOutputError, ActionOutputs } from "./ActionOutputs.js";
6
+ import { ActionState, ActionStateError } from "./ActionState.js";
7
+ import { Action, ActionRuntime, describeCause } from "./Action.js";
8
+ import { BlobTransferError } from "./BlobTransfer.js";
9
+ import { ActionCache, ActionCacheError } from "./ActionCache.js";
10
+ import { Artifact, ArtifactError } from "./Artifact.js";
11
+ import { BlobEnvelope, BlobEnvelopeError } from "./BlobEnvelope.js";
12
+ import { Secret } from "./Secret.js";
13
+ import { BlobStore, BlobStoreError } from "./BlobStore.js";
14
+ import { GitHubCacheBlobStore } from "./BlobStore.githubCache.js";
15
+ import { CacheKey, CacheKeyError } from "./CacheKey.js";
16
+ import { DetachedProcess, DetachedProcessError, ProcessId } from "./DetachedProcess.js";
17
+ import { DryRun } from "./DryRun.js";
18
+ import { GitHubToken, GitHubTokenError } from "./GitHubToken.js";
19
+ import { OidcClaims, OidcTokenError, OidcTokenIssuer } from "./OidcTokenIssuer.js";
20
+ import { ToolInstaller, ToolInstallerError } from "./ToolInstaller.js";
21
+
22
+ export { Action, ActionCache, ActionCacheError, ActionEnvironment, ActionEnvironmentError, ActionInput, ActionLogger, ActionOutputError, ActionOutputs, ActionRuntime, ActionState, ActionStateError, Artifact, ArtifactError, BlobEnvelope, BlobEnvelopeError, BlobStore, BlobStoreError, BlobTransferError, CacheKey, CacheKeyError, DetachedProcess, DetachedProcessError, DryRun, GitHubCacheBlobStore, GitHubContext, GitHubToken, GitHubTokenError, OidcClaims, OidcTokenError, OidcTokenIssuer, ProcessId, RunnerContext, Secret, ToolInstaller, ToolInstallerError, WorkflowCommand, describeCause };
@@ -0,0 +1,98 @@
1
+ import { Effect, Option, Redacted, Result } from "effect";
2
+
3
+ //#region src/internal/actionsResults.ts
4
+ /**
5
+ * Reading the runner's *results backend* coordinates — the two variables the
6
+ * cache, artifact and blob-store protocols all speak through.
7
+ *
8
+ * @remarks
9
+ * Shared by `ActionCache`, `Artifact` and `BlobStore.githubCache`, and
10
+ * deliberately free of `@azure/storage-blob`: an internal helper is exactly how
11
+ * a heavy import leaks into a light module's graph, so the one piece those
12
+ * three modules share is the piece with no dependencies.
13
+ *
14
+ * @internal
15
+ */
16
+ /** The variable naming the backend. Absent outside a `uses:` step. */
17
+ const RESULTS_URL = "ACTIONS_RESULTS_URL";
18
+ /** The variable holding the run-scoped credential. Absent outside a `uses:` step. */
19
+ const RUNTIME_TOKEN = "ACTIONS_RUNTIME_TOKEN";
20
+ /**
21
+ * Decode a JWT payload segment.
22
+ *
23
+ * @remarks
24
+ * No signature verification, deliberately and for the same reason
25
+ * `OidcTokenIssuer` gives: the token was handed to this process by the runner
26
+ * that started it, and the claim read here scopes a request rather than
27
+ * authorizing one.
28
+ */
29
+ const payloadOf = (token) => {
30
+ const segments = token.split(".");
31
+ if (segments.length !== 3) throw new Error(`expected a three-segment JWT, got ${segments.length}`);
32
+ const json = Buffer.from(segments[1] ?? "", "base64url").toString("utf8");
33
+ return JSON.parse(json);
34
+ };
35
+ /**
36
+ * The run and job ids the artifact protocol needs, from the runtime token's
37
+ * `scp` claim.
38
+ *
39
+ * @remarks
40
+ * The scope is space-separated and the interesting entry is
41
+ * `Actions.Results:<run>:<job>`. Exported so its own failures are tested
42
+ * directly rather than through four RPC round trips.
43
+ *
44
+ * @internal
45
+ */
46
+ const backendIdsFrom = (token) => {
47
+ let payload;
48
+ try {
49
+ payload = payloadOf(token);
50
+ } catch (cause) {
51
+ return Result.fail(`the runtime token is not a readable JWT: ${cause instanceof Error ? cause.message : cause}`);
52
+ }
53
+ const scope = payload.scp;
54
+ if (typeof scope !== "string") return Result.fail("the runtime token carries no `scp` claim");
55
+ for (const entry of scope.split(" ")) {
56
+ const parts = entry.split(":");
57
+ if (parts[0] !== "Actions.Results") continue;
58
+ const run = parts[1];
59
+ const job = parts[2];
60
+ if (parts.length !== 3 || run === void 0 || run === "" || job === void 0 || job === "") return Result.fail(`the runtime token's Actions.Results scope is malformed: "${entry}"`);
61
+ return Result.succeed({
62
+ workflowRunBackendId: run,
63
+ workflowJobRunBackendId: job
64
+ });
65
+ }
66
+ return Result.fail("the runtime token carries no `Actions.Results` scope");
67
+ };
68
+ /**
69
+ * Read the results-backend coordinates from the runner environment.
70
+ *
71
+ * @remarks
72
+ * Fails with the *name* of whichever variable is missing. Both are injected
73
+ * only into `uses:` steps, so their absence is the single most common way one
74
+ * of these three services is misused — a caller that reads the name can say so.
75
+ *
76
+ * Takes the environment **shape**, not the service: the layer resolves
77
+ * `ActionEnvironment` once at construction and every member's `R` stays
78
+ * `never`, which is the same fix `ActionEnvironment.payload` got. The variables
79
+ * themselves are read per call, because resolving them at construction would
80
+ * make merely *composing* the layer fail outside Actions — including for an
81
+ * action that never touches the cache.
82
+ *
83
+ * @internal
84
+ */
85
+ const resultsBackend = (env) => Effect.gen(function* () {
86
+ const url = yield* env.getOptional(RESULTS_URL);
87
+ const token = yield* env.getOptional(RUNTIME_TOKEN);
88
+ if (Option.isNone(url)) return yield* Effect.fail(RESULTS_URL);
89
+ if (Option.isNone(token)) return yield* Effect.fail(RUNTIME_TOKEN);
90
+ return {
91
+ baseUrl: url.value.endsWith("/") ? url.value : `${url.value}/`,
92
+ token: Redacted.make(token.value),
93
+ backendIds: backendIdsFrom(token.value)
94
+ };
95
+ });
96
+
97
+ //#endregion
98
+ export { RESULTS_URL, RUNTIME_TOKEN, backendIdsFrom, resultsBackend };
@@ -0,0 +1,117 @@
1
+ import { createHash, createHmac } from "node:crypto";
2
+
3
+ //#region src/internal/sigv4.ts
4
+ /**
5
+ * AWS Signature Version 4, for S3-compatible object stores.
6
+ *
7
+ * @remarks
8
+ * **Pure**, and about a hundred lines, which is the whole argument for not
9
+ * taking an `@aws-sdk/*` dependency to obtain it. Signing is a specified
10
+ * algorithm over strings and HMACs; the SDK around it exists to manage
11
+ * credentials, retries and a service catalogue this package does not want.
12
+ *
13
+ * `node:crypto` rather than core `Crypto` because core's `Crypto` is
14
+ * random-number generation only at beta.101 — no digest, no HMAC. This package
15
+ * is the one place in the kit where a `node:` import is sanctioned, and this is
16
+ * one of the four reasons.
17
+ *
18
+ * @internal
19
+ */
20
+ const ALGORITHM = "AWS4-HMAC-SHA256";
21
+ const sha256Hex = (value) => createHash("sha256").update(value).digest("hex");
22
+ const hmac = (key, value) => new Uint8Array(createHmac("sha256", key).update(value).digest());
23
+ /**
24
+ * Percent-encode one path segment the way SigV4 requires.
25
+ *
26
+ * @remarks
27
+ * `encodeURIComponent` leaves `!'()*` alone and AWS does not, so a key
28
+ * containing any of them signs one string and requests another — which fails as
29
+ * an opaque `SignatureDoesNotMatch` rather than as anything that names the
30
+ * character. The unreserved set is exactly `A-Za-z0-9-_.~`.
31
+ */
32
+ const uriEncode = (value) => encodeURIComponent(value).replace(/[!'()*]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
33
+ /** `20130524T000000Z` and `20130524`, the two forms the algorithm uses. */
34
+ const timestamps = (now) => {
35
+ const amzDate = `${now.toISOString().replace(/[:-]|\.\d{3}/g, "").slice(0, 15)}Z`;
36
+ return {
37
+ amzDate,
38
+ dateStamp: amzDate.slice(0, 8)
39
+ };
40
+ };
41
+ /**
42
+ * Assemble the headers that will be signed, and the canonical request over them.
43
+ *
44
+ * @remarks
45
+ * Exported separately from {@link sign} so a test can pin it against AWS's
46
+ * **own documented** canonical request rather than against this
47
+ * implementation's output. That distinction is not pedantry: the first draft of
48
+ * this module was checked against a remembered signature constant that turned
49
+ * out to be from a different example, and the only thing that settled which side
50
+ * was wrong was reproducing a value AWS publishes.
51
+ */
52
+ const canonicalize = (request, credentials) => {
53
+ const { amzDate, dateStamp } = timestamps(request.now);
54
+ const payloadHash = sha256Hex(request.body);
55
+ const headers = {
56
+ ...request.headers,
57
+ host: request.host,
58
+ "x-amz-date": amzDate,
59
+ "x-amz-content-sha256": payloadHash,
60
+ ...credentials.sessionToken === void 0 ? {} : { "x-amz-security-token": credentials.sessionToken }
61
+ };
62
+ const lowered = new Map(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value]));
63
+ const canonicalNames = [...lowered.keys()].sort();
64
+ const canonicalHeaders = canonicalNames.map((name) => `${name}:${(lowered.get(name) ?? "").trim().replace(/\s+/g, " ")}\n`).join("");
65
+ const signedHeaders = canonicalNames.join(";");
66
+ const canonicalPath = `/${request.path.split("/").filter((segment) => segment !== "").map(uriEncode).join("/")}`;
67
+ return {
68
+ headers,
69
+ signedHeaders,
70
+ amzDate,
71
+ dateStamp,
72
+ canonicalRequest: [
73
+ request.method,
74
+ canonicalPath,
75
+ "",
76
+ canonicalHeaders,
77
+ signedHeaders,
78
+ payloadHash
79
+ ].join("\n")
80
+ };
81
+ };
82
+ /**
83
+ * Derive a signing key.
84
+ *
85
+ * @remarks
86
+ * Four nested HMACs over date, region, service and the literal `aws4_request`.
87
+ * Exported so a test can reproduce AWS's documented derivation example.
88
+ */
89
+ const signingKey = (secretAccessKey, dateStamp, region, service) => hmac(hmac(hmac(hmac(`AWS4${secretAccessKey}`, dateStamp), region), service), "aws4_request");
90
+ /**
91
+ * Sign a request, returning the headers to send.
92
+ *
93
+ * @remarks
94
+ * The returned record is the caller's headers **plus** everything signing added
95
+ * — `host`, `x-amz-date`, `x-amz-content-sha256`, the optional
96
+ * `x-amz-security-token` and `authorization` — so a caller sends exactly what
97
+ * was signed rather than assembling a second, subtly different set.
98
+ */
99
+ const sign = (request, credentials) => {
100
+ const { headers, canonicalRequest, signedHeaders, amzDate, dateStamp } = canonicalize(request, credentials);
101
+ const scope = `${dateStamp}/${credentials.region}/${credentials.service}/aws4_request`;
102
+ const stringToSign = [
103
+ ALGORITHM,
104
+ amzDate,
105
+ scope,
106
+ sha256Hex(canonicalRequest)
107
+ ].join("\n");
108
+ const key = signingKey(credentials.secretAccessKey, dateStamp, credentials.region, credentials.service);
109
+ const signature = Buffer.from(hmac(key, stringToSign)).toString("hex");
110
+ return {
111
+ ...headers,
112
+ authorization: `${ALGORITHM} Credential=${credentials.accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
113
+ };
114
+ };
115
+
116
+ //#endregion
117
+ export { canonicalize, sign, signingKey, uriEncode };
@@ -0,0 +1,97 @@
1
+ import { Effect, Schedule } from "effect";
2
+ import { HttpClientRequest } from "effect/unstable/http";
3
+
4
+ //#region src/internal/twirp.ts
5
+ /**
6
+ * Returned instead of a failure when the backend answers HTTP 409.
7
+ *
8
+ * @remarks
9
+ * A conflict means "this already exists", which is a success for a save and a
10
+ * miss for a lookup — two different answers that the *caller* has to choose
11
+ * between, so the transport hands back a sentinel rather than deciding.
12
+ *
13
+ * @internal
14
+ */
15
+ const CONFLICT = Symbol.for("@effected/github-actions/twirp/conflict");
16
+ /**
17
+ * Whether retrying could plausibly help.
18
+ *
19
+ * @remarks
20
+ * A transport fault never completed, so nothing was observed; `408`, `429` and
21
+ * `5xx` are the backend saying "later". Everything else — a `400`, a `403`, a
22
+ * body that is not JSON — is the backend saying "never", and retrying it four
23
+ * times only makes a broken call take half a minute to fail.
24
+ *
25
+ * @internal
26
+ */
27
+ const isRetryable = (failure) => {
28
+ if (failure.kind === "transport") return true;
29
+ if (failure.kind === "malformed" || failure.status === void 0) return false;
30
+ return failure.status >= 500 || failure.status === 408 || failure.status === 429;
31
+ };
32
+ /** How many times a retryable RPC is retried before giving up. */
33
+ const RETRIES = 4;
34
+ /**
35
+ * A Twirp RPC: `POST <baseUrl>twirp/<service>/<method>` with a JSON body.
36
+ *
37
+ * @remarks
38
+ * Retry is applied **here**, not by each caller, so no protocol can be shipped
39
+ * without it. A non-retryable failure never sleeps, which is what keeps the
40
+ * ordinary failure tests clock-free.
41
+ *
42
+ * @internal
43
+ */
44
+ const twirpCall = (options) => {
45
+ const { baseUrl, body, http, method, service, token } = options;
46
+ return Effect.gen(function* () {
47
+ const request = HttpClientRequest.post(`${baseUrl}twirp/${service}/${method}`).pipe(HttpClientRequest.bearerToken(token), HttpClientRequest.bodyJsonUnsafe(body));
48
+ const response = yield* http.execute(request).pipe(Effect.mapError((cause) => ({
49
+ method,
50
+ kind: "transport",
51
+ cause
52
+ })));
53
+ if (response.status === 409) return CONFLICT;
54
+ if (response.status < 200 || response.status >= 300) return yield* Effect.fail({
55
+ method,
56
+ kind: "status",
57
+ status: response.status
58
+ });
59
+ return yield* response.json.pipe(Effect.mapError((cause) => ({
60
+ method,
61
+ kind: "malformed",
62
+ cause
63
+ })));
64
+ }).pipe(Effect.retry({
65
+ schedule: Schedule.exponential("3 seconds", 1.5),
66
+ times: RETRIES,
67
+ while: isRetryable
68
+ }));
69
+ };
70
+ /**
71
+ * Read a Twirp JSON field under either spelling.
72
+ *
73
+ * @remarks
74
+ * The backend is an internal GitHub protocol reverse-engineered from
75
+ * `actions/toolkit`, and the two halves of it do not agree: protobuf JSON emits
76
+ * `signedUploadUrl` while the cache RPCs have been observed emitting
77
+ * `signed_upload_url`. Reading both costs one function and removes a class of
78
+ * failure that presents as "the cache silently never hits".
79
+ *
80
+ * @internal
81
+ */
82
+ const field = (body, name) => {
83
+ if (typeof body !== "object" || body === null) return;
84
+ const record = body;
85
+ const snake = name.replaceAll(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
86
+ return record[name] ?? record[snake];
87
+ };
88
+ /** {@link field}, as a string, or `undefined` when absent or empty. @internal */
89
+ const stringField = (body, name) => {
90
+ const value = field(body, name);
91
+ return typeof value === "string" && value !== "" ? value : void 0;
92
+ };
93
+ /** Whether a Twirp response carries `ok: true`. @internal */
94
+ const isOk = (body) => field(body, "ok") === true;
95
+
96
+ //#endregion
97
+ export { CONFLICT, field, isOk, isRetryable, stringField, twirpCall };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@effected/github-actions",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "The GitHub Actions runtime: env, inputs, outputs, state, logging, cache, artifacts, blob storage, OIDC and the token bridge.",
6
+ "keywords": [
7
+ "github-actions",
8
+ "actions",
9
+ "runtime",
10
+ "cache",
11
+ "oidc",
12
+ "effect",
13
+ "effected"
14
+ ],
15
+ "homepage": "https://github.com/spencerbeggs/effected/tree/main/packages/github-actions#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/spencerbeggs/effected/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/spencerbeggs/effected.git",
22
+ "directory": "packages/github-actions"
23
+ },
24
+ "license": "MIT",
25
+ "author": {
26
+ "name": "C. Spencer Beggs",
27
+ "email": "spencer@beggs.codes",
28
+ "url": "https://spencerbeg.gs"
29
+ },
30
+ "sideEffects": false,
31
+ "type": "module",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./index.d.ts",
35
+ "import": "./index.js",
36
+ "default": "./index.js"
37
+ },
38
+ "./package.json": "./package.json"
39
+ },
40
+ "dependencies": {
41
+ "@azure/storage-blob": "^12.33.0",
42
+ "@effected/github": "~0.1.0",
43
+ "@effected/glob": "~0.2.1"
44
+ },
45
+ "peerDependencies": {
46
+ "@effect/platform-node": "4.0.0-beta.101",
47
+ "effect": "4.0.0-beta.101"
48
+ },
49
+ "engines": {
50
+ "node": ">=24.11.0"
51
+ }
52
+ }
@@ -0,0 +1,11 @@
1
+ // This file is read by tools that parse documentation comments conforming to the TSDoc standard.
2
+ // It should be published with your NPM package. It should not be tracked by Git.
3
+ {
4
+ "tsdocVersion": "0.12",
5
+ "toolPackages": [
6
+ {
7
+ "packageName": "@microsoft/api-extractor",
8
+ "packageVersion": "7.58.12"
9
+ }
10
+ ]
11
+ }