@effected/github-actions 0.1.0 → 0.3.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/ActionCache.js +128 -17
- package/ActionInput.js +61 -8
- package/ActionLogger.js +7 -3
- package/ActionOutputs.js +71 -2
- package/ActionState.js +33 -6
- package/ActionsIdentityToken.js +61 -0
- package/ActionsProvenance.js +103 -0
- package/Artifact.js +2 -2
- package/BlobStore.githubCache.js +2 -1
- package/CacheKey.js +106 -8
- package/CheckDocument.js +193 -0
- package/CheckState.js +75 -0
- package/GitHubMarkdown.js +197 -0
- package/ManagedDocument.js +281 -0
- package/PackageManagerInstaller.js +494 -0
- package/README.md +241 -0
- package/Secret.js +34 -0
- package/ToolInstaller.js +98 -52
- package/index.d.ts +1612 -230
- package/index.js +9 -2
- package/package.json +7 -3
package/README.md
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# @effected/github-actions
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@effected/github-actions)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
|
|
8
|
+
The GitHub Actions runtime for [Effect](https://effect.website) v4: the services an action needs to talk to the runner it is executing inside. `Action.run` composes the runtime, runs your program, renders a failure as an `::error::` annotation and sets the exit code. `ActionInput` reads workflow inputs as typed `Config` values — string, boolean, integer, redacted secret, multiline list, `key=value` pairs, or a schema-decoded JSON blob — with one absence rule shared by every accessor: unset and `""` are both missing data. `ActionOutputs`, `ActionState`, `ActionLogger` and a fiber-local `ActionEnvironment` round out the runner surface, alongside `ActionCache`, `Artifact`, a metadata-carrying `BlobStore` (S3-compatible or the runner's own cache), `OidcTokenIssuer` and `ToolInstaller` for the heavier protocols. A reporting suite — `GitHubMarkdown`, `ManagedDocument`, `CheckState` and `CheckDocument` — covers the other direction: rendering what a run did onto a pull request comment, a check run or the job summary. No `@actions/*` dependency anywhere — the cache, artifact and tool-cache protocols are implemented directly against their HTTP APIs.
|
|
9
|
+
|
|
10
|
+
> **Pre-release.** This package is part of the `@effected/*` kit, in pre-`1.0.0`
|
|
11
|
+
> development against a single pinned Effect v4 beta. Packages graduate to
|
|
12
|
+
> `1.0.0` once Effect `4.0.0` ships. To hold your own `effect` versions at
|
|
13
|
+
> exactly the ones the kit is built and tested against, install
|
|
14
|
+
> [`@effected/pnpm-plugin-effect`](https://www.npmjs.com/package/@effected/pnpm-plugin-effect).
|
|
15
|
+
>
|
|
16
|
+
> **Stability: unstable.** This package's API surface is not yet considered
|
|
17
|
+
> complete and may change across `0.x` releases. Pin an exact version — even a
|
|
18
|
+
> package marked *stable* before `1.0.0` can introduce a breaking change by
|
|
19
|
+
> accident, and an exact pin turns that into a type-check error rather than a
|
|
20
|
+
> runtime surprise. Full policy: [release strategy](https://github.com/spencerbeggs/effected#release-strategy).
|
|
21
|
+
|
|
22
|
+
## Why @effected/github-actions
|
|
23
|
+
|
|
24
|
+
`@actions/core` and its siblings (`@actions/cache`, `@actions/artifact`, `@actions/tool-cache`) are the standard way to write an action, and each one owns its own ad hoc contract: inputs are read with `getInput`, which returns `""` for both "unset" and "set to empty" with no way to tell them apart at the type level; outputs, masking and workflow commands are separate globals; and the cache and artifact clients bring their own HTTP stacks. This package answers the same questions as one coherent set of Effect services instead, with the absence rule stated once (`Config.withDefault` is the idiom for every optional input) and every mutation running through the same `R`-typed services a test can swap out.
|
|
25
|
+
|
|
26
|
+
It is also the **one place in the kit** where `@effect/platform-node` is a required peer, deliberately: a GitHub Action always compiles into a Node process on a GitHub-provided runner, so there is no second platform to abstract over. Everything that *can* go through a core contract still does — `ToolInstaller` downloads over `HttpClient` and extracts over `ChildProcessSpawner`, `CacheKey` reads over `FileSystem` — and the four sanctioned `node:` imports (`node:crypto` for digests, `process.kill` for reaping a bare pid, `node:child_process` for a detached spawn, and `node:zlib`/`node:stream` in the cache and artifact codecs) cover exactly what core cannot do yet.
|
|
27
|
+
|
|
28
|
+
The line against [`@effected/github`](https://www.npmjs.com/package/@effected/github) is exact: that package talks to the GitHub API, this one talks to the runner. They meet at two seams — the token bridge (`GitHubToken`) and the `Logger` that maps `Effect.log*` onto workflow commands — and nothing here reads `process.env.GITHUB_REPOSITORY` on `github`'s behalf.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install @effected/github-actions effect @effect/platform-node
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pnpm add @effected/github-actions effect @effect/platform-node
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Requires Node.js >=24.11.0. `effect` v4 and `@effect/platform-node` are both peer dependencies.
|
|
41
|
+
|
|
42
|
+
All `@effected/*` packages are ESM-only: the exports maps publish only `import` conditions, so `require()` — including tools that resolve in CJS mode — fails with Node's `ERR_PACKAGE_PATH_NOT_EXPORTED` rather than loading a CJS build that does not exist. Import from an ES module.
|
|
43
|
+
|
|
44
|
+
## Quick start
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { Action, ActionInput, ActionOutputs } from "@effected/github-actions";
|
|
48
|
+
import { Config, Effect } from "effect";
|
|
49
|
+
|
|
50
|
+
const program = Effect.gen(function* () {
|
|
51
|
+
const name = yield* ActionInput.string("name").pipe(Config.withDefault("world"));
|
|
52
|
+
const outputs = yield* ActionOutputs;
|
|
53
|
+
yield* outputs.set("greeting", `Hello, ${name}!`);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await Action.run(program);
|
|
57
|
+
// exit code reflects success/failure; failures render as an ::error:: annotation
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`Action.run` provides `ActionServices` (environment, logger, outputs, state, the Node platform and an `HttpClient`) by default. A capability with a heavier dependency — the cache, artifacts, the blob store — is one extra line: `Action.run(program, { layer: ActionCache.layer })`.
|
|
61
|
+
|
|
62
|
+
## Reading inputs
|
|
63
|
+
|
|
64
|
+
Every accessor shares one absence rule: the runner writes `""` for an input the workflow omitted, and this package reads that the same as unset — **missing data**, not an empty value. An optional input needs `Config.withDefault` (or `Config.option`) at the call site:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { ActionInput } from "@effected/github-actions";
|
|
68
|
+
import { Config, Effect } from "effect";
|
|
69
|
+
|
|
70
|
+
const program = Effect.gen(function* () {
|
|
71
|
+
const dryRun = yield* ActionInput.boolean("dry-run").pipe(Config.withDefault(false));
|
|
72
|
+
const paths = yield* ActionInput.list("paths").pipe(Config.withDefault([]));
|
|
73
|
+
return { dryRun, paths };
|
|
74
|
+
});
|
|
75
|
+
// dryRun: boolean; paths: readonly string[] — [] when the input was omitted
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`list` accepts a JSON array, a bullet list, or comma- and newline-separated values, with full-line `#` comments dropped — whichever shape a workflow author reaches for first. A **present but malformed** value (`dry-run: yes` instead of a YAML 1.2 boolean) is a different class from an absent one: it fails carrying its `actual` and is never silently swallowed by a default.
|
|
79
|
+
|
|
80
|
+
## The token bridge
|
|
81
|
+
|
|
82
|
+
`GitHubToken` shapes GitHub App authentication like the three-phase workflow it runs inside: mint in `pre`, use in `main`, revoke in `post`. Nothing survives between phases except what `GITHUB_STATE` carries, so an installation token — which lives about an hour — is persisted rather than held in a `Scope`:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
import { GitHubToken } from "@effected/github-actions";
|
|
86
|
+
import { Effect, Redacted } from "effect";
|
|
87
|
+
|
|
88
|
+
// pre:
|
|
89
|
+
const pre = GitHubToken.provision({
|
|
90
|
+
appId: "123456",
|
|
91
|
+
privateKey: Redacted.make(process.env.APP_PRIVATE_KEY as string),
|
|
92
|
+
owner: "acme",
|
|
93
|
+
required: { contents: "write", pull_requests: "write" },
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// main: bind once — layers memoize by reference
|
|
97
|
+
const ClientLayer = GitHubToken.clientLayer();
|
|
98
|
+
|
|
99
|
+
// post:
|
|
100
|
+
const post = GitHubToken.dispose();
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`GitHubToken.read` fails typed with `reason: "expired"` rather than handing back a token GitHub will answer with a bare 401 — the credential that could re-mint one is the app's private key, and persisting that through a plaintext `GITHUB_STATE` file would trade a one-hour token for a permanent one.
|
|
104
|
+
|
|
105
|
+
## Secrets
|
|
106
|
+
|
|
107
|
+
`Secret` is the only place in this package a `Redacted` value becomes a plain string, and masking and declassification are the same call — every member registers the value with the runner's `::add-mask::` filter *before* returning plaintext:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { Secret } from "@effected/github-actions";
|
|
111
|
+
import { Effect } from "effect";
|
|
112
|
+
|
|
113
|
+
declare const token: import("effect/Redacted").Redacted<string>;
|
|
114
|
+
|
|
115
|
+
const program = Effect.gen(function* () {
|
|
116
|
+
const env = yield* Secret.forChildEnv({ MY_TOKEN: token });
|
|
117
|
+
// every value in `env` is already masked in the runner log
|
|
118
|
+
return env;
|
|
119
|
+
});
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## The runner's cache, artifacts and OIDC
|
|
123
|
+
|
|
124
|
+
`ActionCache` and `Artifact` speak the Actions Twirp v2 protocol directly — no `@actions/cache` or `@actions/artifact` dependency, and both are excluded from `ActionRuntime.layer` on purpose, so a consumer that only sets an output never links `@azure/storage-blob`:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
import { ActionCache, CacheKey } from "@effected/github-actions";
|
|
128
|
+
import { Effect, Option } from "effect";
|
|
129
|
+
|
|
130
|
+
const program = Effect.gen(function* () {
|
|
131
|
+
const cache = yield* ActionCache;
|
|
132
|
+
const key = CacheKey.of("Linux", "pnpm-store", "abc123");
|
|
133
|
+
const hit = yield* cache.restore(["~/.pnpm-store"], key);
|
|
134
|
+
if (Option.isNone(hit)) {
|
|
135
|
+
yield* cache.save(["~/.pnpm-store"], key);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
await Action.run(program, { layer: ActionCache.layer });
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`CacheKey.hashFiles` is byte-compatible with `@actions/glob`'s `hashFiles`, and `OidcTokenIssuer.claims` decodes the runner's OIDC token for a SLSA provenance predicate without verifying its signature — the token arrived over TLS from the runner's own token service, so the transport is the trust boundary and nothing here branches on the claims for authorization.
|
|
143
|
+
|
|
144
|
+
`ActionsProvenance.capture` turns those claims into an `@effected/sbom` `SlsaProvenance` predicate, and `ActionsIdentityToken.layer` serves that package's narrow `IdentityToken` contract from the same issuer — the inversion that keeps the Actions runtime out of every consumer that only wants to emit an SBOM:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { ActionsIdentityToken, ActionsProvenance, OidcTokenIssuer } from "@effected/github-actions";
|
|
148
|
+
import { SigstoreSigner } from "@effected/sbom";
|
|
149
|
+
import { Effect, Layer } from "effect";
|
|
150
|
+
|
|
151
|
+
const program = Effect.gen(function* () {
|
|
152
|
+
const provenance = yield* ActionsProvenance.capture();
|
|
153
|
+
return provenance.runDetails.builder.id;
|
|
154
|
+
});
|
|
155
|
+
// the builder id, derived from the workflow ref in the runner's own claims
|
|
156
|
+
|
|
157
|
+
const SigningLayer = SigstoreSigner.layer.pipe(
|
|
158
|
+
Layer.provide(ActionsIdentityToken.layer),
|
|
159
|
+
Layer.provide(OidcTokenIssuer.layer),
|
|
160
|
+
);
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
An `OidcTokenError` passes through `capture` untouched: whether an unattested publish is allowed is the action's policy, not this package's. `reason: "unavailable"` almost always means the workflow is missing `permissions: id-token: write`.
|
|
164
|
+
|
|
165
|
+
## Reporting on GitHub surfaces
|
|
166
|
+
|
|
167
|
+
The other half of an action's job is saying what happened — on a pull request comment, a check-run summary or the job summary. `GitHubMarkdown` writes those bodies. Every member takes pre-rendered markdown and returns a string, but the structure around it goes through `@effected/markdown`'s serializer rather than string joining, so a cell carrying `>=1 || <2` escapes its pipes instead of shifting every column after it. `tableFor` goes further and takes the table's whole shape from a row schema:
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
import { GitHubMarkdown } from "@effected/github-actions";
|
|
171
|
+
import { Schema } from "effect";
|
|
172
|
+
|
|
173
|
+
const Row = Schema.Struct({
|
|
174
|
+
name: Schema.String.annotate({ title: "Check" }),
|
|
175
|
+
detail: Schema.String,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
console.log(GitHubMarkdown.tableFor(Row).render([{ name: "build", detail: "clean" }]));
|
|
179
|
+
// | Check | detail |
|
|
180
|
+
// | --- | --- |
|
|
181
|
+
// | build | clean |
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Column order is field declaration order, each header is the field's `title` annotation, and each cell is the field's encoded form — so a typed field projects through its own codec, and a row cannot transpose columns because it is an object rather than a positional array.
|
|
185
|
+
|
|
186
|
+
`ManagedDocument` owns named regions inside text a human also edits: a sentinel HTML comment identifies the document, marker comments delimit each region, and everything outside them survives byte-for-byte. Regions are replaced from current state rather than appended, which is what makes a sticky comment safe to re-render. `CheckDocument` puts a debounced reconciler on top — report a check as its state changes, and a background fiber projects the registry onto the document and writes it through a narrow sink:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import { CheckDocument, CheckReport } from "@effected/github-actions";
|
|
190
|
+
import { Effect } from "effect";
|
|
191
|
+
|
|
192
|
+
const program = Effect.gen(function* () {
|
|
193
|
+
const doc = yield* CheckDocument;
|
|
194
|
+
yield* doc.report("build", CheckReport.make({ state: "running" }));
|
|
195
|
+
yield* doc.report("build", CheckReport.make({ state: "pass", outcome: "clean" }));
|
|
196
|
+
yield* doc.flush;
|
|
197
|
+
});
|
|
198
|
+
// one write carrying the final state; a render byte-identical to the last one writes nothing
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The debounce is trailing with a max-wait, so a burst of reports coalesces into one write carrying the *final* state while a steady stream still surfaces progress. `CheckState` is the vocabulary both of them speak, and it is deliberately wider than GitHub's conclusion set — `running` is a state rather than the absence of one, and `user_interaction_required` names a pipeline waiting on a human. `projectCheckState` maps each onto the wire form GitHub wants, as a status plus a conclusion exactly when the status is `completed`.
|
|
202
|
+
|
|
203
|
+
## Testing
|
|
204
|
+
|
|
205
|
+
Every service ships `makeTest(overrides?)` and `layerTest(overrides?)`, with unstubbed members dying loudly and naming themselves — three recorded exceptions state why they default instead: `ActionEnvironment.makeTest` seeds the twelve `GITHUB_*`/`RUNNER_*` variables, `ActionLogger.makeTest` defaults to silent, and `DryRun.makeTest` defaults to rehearsing, the safe direction:
|
|
206
|
+
|
|
207
|
+
```ts
|
|
208
|
+
import { ActionOutputs } from "@effected/github-actions";
|
|
209
|
+
import { Effect } from "effect";
|
|
210
|
+
|
|
211
|
+
const TestOutputs = ActionOutputs.layerTest({
|
|
212
|
+
set: () => Effect.void,
|
|
213
|
+
});
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`OidcTokenIssuer.layerFor(claims)` returns a real decodable unsigned JWT built from the same claims `claims()` reports, and `BlobStore.layerMemory` runs the real envelope framing — both exist because a synthetic double previously made the provenance path structurally untestable.
|
|
217
|
+
|
|
218
|
+
## Features
|
|
219
|
+
|
|
220
|
+
- `Action.run` / `ActionRuntime.layer` — the entry point and default runtime: environment, logger, outputs, state, the Node platform and an `HttpClient`.
|
|
221
|
+
- `ActionInput` — typed input accessors (`string`, `boolean`, `integer`, `redacted`, `lines`, `list`, `pairs`, `schema`) sharing one absence rule, plus the `INPUT_`-aware `ConfigProvider` `Action.run` installs by default.
|
|
222
|
+
- `ActionOutputs` — step outputs, JSON outputs, the job summary, exported variables, `PATH` additions, failure annotations and log masking.
|
|
223
|
+
- `ActionState` / `ActionEnvironment` — persisted `pre`/`main`/`post` state, and the fiber-local `GITHUB_*`/`RUNNER_*` context read once from `process.env`.
|
|
224
|
+
- `ActionLogger` — collapsible groups, buffered step transcripts (`withBuffer({ onSuccess: "discard" })` keeps a green log to one line per step, while a failure, defect or interruption still flushes), `::notice::` annotations, and the `Logger` that renders every `Effect.log*` as a workflow command.
|
|
225
|
+
- `DryRun` — the rehearsal guard every mutation goes through, driven by the `dry-run` input by default.
|
|
226
|
+
- `Secret` — the one declassification seam between a `Redacted` value and a plain string, masking before it returns plaintext.
|
|
227
|
+
- `GitHubToken` — the App-token lifecycle shaped like the `pre`/`main`/`post` workflow: mint, verify scopes, persist, read, revoke.
|
|
228
|
+
- `ActionCache` / `Artifact` — the runner's cache and artifact protocols over Twirp v2, excluded from the default runtime so a consumer that skips them never links Azure.
|
|
229
|
+
- `CacheKey` — cache keys and their restore-key ladder, plus `hashFiles`/`matchingFiles`/`hashMatching` byte-compatible with `@actions/glob`.
|
|
230
|
+
- `OidcTokenIssuer` — the runner's OIDC token service, with unverified claim decoding for provenance predicates.
|
|
231
|
+
- `ActionsIdentityToken` / `ActionsProvenance` — the `@effected/sbom` seam: `layer` serves that package's `IdentityToken` contract from the runner's issuer, and `capture` builds a `SlsaProvenance` predicate out of the runner's OIDC claims.
|
|
232
|
+
- `GitHubMarkdown` — the escaping-safe writer for GitHub surfaces: `table`, `tableFor(schema)`, `heading`, `link`, `code`, `codeBlock`, `list`, `details` and `raw`, and the package's only importer of `@effected/markdown`.
|
|
233
|
+
- `ManagedDocument` — marker-delimited named regions in text a human also edits, for a sticky comment or a managed pull request description; content outside the regions survives byte-for-byte.
|
|
234
|
+
- `CheckState` / `projectCheckState` — the kit's check vocabulary, wider than GitHub's conclusions, and its projection onto the check-run wire form.
|
|
235
|
+
- `CheckDocument` — the debounced check-to-document reconciler: report states as they change, and one write lands carrying the final one.
|
|
236
|
+
- `ToolInstaller` — download, extract and cache a toolchain in the runner's tool cache, with no `@actions/tool-cache` dependency.
|
|
237
|
+
- `BlobStore` — durable `get`/`put`/`has` over bytes plus a metadata channel, backed by `layerS3` (SigV4-signed, no `@aws-sdk/*` dependency) or `GitHubCacheBlobStore.layer` (the runner's own cache); `layerMemory` runs the real envelope framing for tests.
|
|
238
|
+
|
|
239
|
+
## License
|
|
240
|
+
|
|
241
|
+
[MIT](LICENSE)
|
package/Secret.js
CHANGED
|
@@ -49,6 +49,16 @@ var Secret = class Secret {
|
|
|
49
49
|
* @remarks
|
|
50
50
|
* Every value is masked before any plaintext is returned — including when
|
|
51
51
|
* one of them fails, because masking happens for the whole set first.
|
|
52
|
+
*
|
|
53
|
+
* **Detached workers invert the masking model.** Masking works because the
|
|
54
|
+
* runner parses this process's stdout; a *detached* child's stdout is a log
|
|
55
|
+
* file no runner parses, so a mask emitted **inside** the worker is inert
|
|
56
|
+
* and writes the plaintext verbatim into that log — a shipped incident, not
|
|
57
|
+
* a hypothetical. The rule: **the parent masks, before the spawn** — call
|
|
58
|
+
* this member in the parent, where the runner is listening, and hand the
|
|
59
|
+
* already-masked plaintext to the worker's environment. Inside the worker,
|
|
60
|
+
* compose `ActionOutputs.layerDetached`, whose `setSecret` is a documented
|
|
61
|
+
* no-op, so the same code cannot re-leak what the parent already masked.
|
|
52
62
|
*/
|
|
53
63
|
static forChildEnv = (entries) => Effect.gen(function* () {
|
|
54
64
|
const outputs = yield* ActionOutputs;
|
|
@@ -81,6 +91,19 @@ var Secret = class Secret {
|
|
|
81
91
|
* over a `Redacted`, so a signing key has to become a string somewhere, and
|
|
82
92
|
* the point of this module is that "somewhere" is here and nowhere else.
|
|
83
93
|
*
|
|
94
|
+
* Signing named the member; it does not gate it. **Any in-process use that
|
|
95
|
+
* needs the raw value without writing it to a runner file is this
|
|
96
|
+
* operation** — handing a key to a library that takes a plain string. The
|
|
97
|
+
* dividing line is where the value goes, not what it is for: a value
|
|
98
|
+
* crossing into a **child's** environment is {@link Secret.forChildEnv},
|
|
99
|
+
* one written to `GITHUB_STATE` or `GITHUB_OUTPUT` is
|
|
100
|
+
* {@link Secret.forRunnerFile}, and one that stays in this process is this
|
|
101
|
+
* member. A third-party SDK that reads only the ambient environment is the
|
|
102
|
+
* hard case: this package never mutates `process.env` (reads are seeded
|
|
103
|
+
* once through `ActionEnvironment`), so if a consumer chooses that bridge,
|
|
104
|
+
* the mutation — and its restore discipline — lives in consumer code as
|
|
105
|
+
* the consumer's own tradeoff, not as a pattern this member recommends.
|
|
106
|
+
*
|
|
84
107
|
* It masks even though the value is never *written* anywhere, and that is
|
|
85
108
|
* deliberate rather than superstitious: a signing key that leaks does so
|
|
86
109
|
* through something nobody audited — a debug log of outgoing headers, a
|
|
@@ -89,6 +112,17 @@ var Secret = class Secret {
|
|
|
89
112
|
*
|
|
90
113
|
* Call it **once, at layer construction**, not per operation: masking is
|
|
91
114
|
* idempotent but a workflow command per request is noise in the log.
|
|
115
|
+
*
|
|
116
|
+
* **In a detached worker, the mask this member emits is a leak.** The
|
|
117
|
+
* `::add-mask::` command only masks when the runner parses this process's
|
|
118
|
+
* stdout; a detached worker's stdout is a log file no runner parses, so
|
|
119
|
+
* there the command masks nothing *and* spells the plaintext into the log
|
|
120
|
+
* — a signing key shipped exactly that way for one round. A worker that
|
|
121
|
+
* signs (an S3-style `BlobStore` is the canonical case) must compose
|
|
122
|
+
* `ActionOutputs.layerDetached`, under which this member still returns the
|
|
123
|
+
* raw key for the HMAC but the mask is a documented no-op; the masking
|
|
124
|
+
* itself is the **parent's** job, done before the spawn via
|
|
125
|
+
* {@link Secret.forChildEnv} under the real layer.
|
|
92
126
|
*/
|
|
93
127
|
static forSigning = (secret) => Secret.forRunnerFile(secret);
|
|
94
128
|
/**
|
package/ToolInstaller.js
CHANGED
|
@@ -53,6 +53,8 @@ var ToolInstallerError = class extends Schema.TaggedErrorClass()("ToolInstallerE
|
|
|
53
53
|
};
|
|
54
54
|
/** How many times a retryable download is retried before giving up. */
|
|
55
55
|
const DOWNLOAD_RETRIES = 2;
|
|
56
|
+
/** The default per-attempt download budget. See {@link ToolDownloadOptions.timeout}. */
|
|
57
|
+
const DOWNLOAD_TIMEOUT = "5 minutes";
|
|
56
58
|
const make = Effect.gen(function* () {
|
|
57
59
|
const env = yield* ActionEnvironment;
|
|
58
60
|
const fs = yield* FileSystem.FileSystem;
|
|
@@ -83,29 +85,37 @@ const make = Effect.gen(function* () {
|
|
|
83
85
|
})));
|
|
84
86
|
};
|
|
85
87
|
/**
|
|
86
|
-
* Run an extraction command, keeping its stderr.
|
|
88
|
+
* Run an extraction command ONCE, keeping its stderr.
|
|
87
89
|
*
|
|
88
90
|
* @remarks
|
|
89
91
|
* `tar`'s exit code says only that it failed; its stderr says why, and it is
|
|
90
92
|
* the difference between "this is not a gzip archive" and "tar is not
|
|
91
93
|
* installed". Discarding it is what makes an extraction failure unactionable.
|
|
94
|
+
*
|
|
95
|
+
* **One spawn, not two — load-bearing.** The spawner's convenience members
|
|
96
|
+
* each spawn independently: `string` collects output without inspecting the
|
|
97
|
+
* exit code, and `exitCode` runs the command AGAIN (core's
|
|
98
|
+
* `ChildProcessSpawner.make` derives both from `spawn`). Calling them
|
|
99
|
+
* back-to-back double-executed every extraction — harmless for idempotent
|
|
100
|
+
* `tar`/`unzip -o`, but .NET's `ZipFile.ExtractToDirectory` refuses to
|
|
101
|
+
* overwrite, so the second run failed 5/5 on real Windows runners while the
|
|
102
|
+
* captured "complaint" was the FIRST run's silent success. Output and exit
|
|
103
|
+
* code must come from the same `spawn` handle.
|
|
92
104
|
*/
|
|
93
|
-
const extractWith = (command, archive) => Effect.gen(function* () {
|
|
94
|
-
const
|
|
95
|
-
reason: "extractFailed",
|
|
96
|
-
subject: archive,
|
|
97
|
-
cause
|
|
98
|
-
})));
|
|
99
|
-
if ((yield* spawner.exitCode(command).pipe(Effect.mapError((cause) => new ToolInstallerError({
|
|
105
|
+
const extractWith = (command, archive) => Effect.scoped(Effect.gen(function* () {
|
|
106
|
+
const extractError = (cause) => new ToolInstallerError({
|
|
100
107
|
reason: "extractFailed",
|
|
101
108
|
subject: archive,
|
|
102
109
|
cause
|
|
103
|
-
})
|
|
110
|
+
});
|
|
111
|
+
const handle = yield* spawner.spawn(command).pipe(Effect.mapError(extractError));
|
|
112
|
+
const output = yield* Stream.mkString(Stream.decodeText(handle.all)).pipe(Effect.mapError(extractError));
|
|
113
|
+
if ((yield* handle.exitCode.pipe(Effect.mapError(extractError))) !== 0) return yield* Effect.fail(new ToolInstallerError({
|
|
104
114
|
reason: "extractFailed",
|
|
105
115
|
subject: archive,
|
|
106
116
|
stderr: output.trim()
|
|
107
117
|
}));
|
|
108
|
-
});
|
|
118
|
+
}));
|
|
109
119
|
/**
|
|
110
120
|
* Install a staged directory into the tool cache by renaming it into place.
|
|
111
121
|
*
|
|
@@ -146,38 +156,55 @@ const make = Effect.gen(function* () {
|
|
|
146
156
|
recursive: true,
|
|
147
157
|
force: true
|
|
148
158
|
}))))));
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
subject: url,
|
|
161
|
-
status: response.status
|
|
162
|
-
}));
|
|
163
|
-
const directory = yield* fs.makeTempDirectory({ prefix: "effected-download-" }).pipe(Effect.mapError((cause) => new ToolInstallerError({
|
|
164
|
-
reason: "downloadFailed",
|
|
165
|
-
subject: url,
|
|
166
|
-
cause
|
|
167
|
-
})));
|
|
168
|
-
const file = path.join(directory, "download");
|
|
169
|
-
yield* Stream.run(response.stream, fs.sink(file)).pipe(Effect.mapError((cause) => new ToolInstallerError({
|
|
170
|
-
reason: "downloadFailed",
|
|
171
|
-
subject: url,
|
|
172
|
-
cause
|
|
173
|
-
})));
|
|
174
|
-
return file;
|
|
175
|
-
}).pipe(Effect.retry({
|
|
176
|
-
schedule: Schedule.exponential("1 second"),
|
|
177
|
-
times: DOWNLOAD_RETRIES,
|
|
178
|
-
while: (error) => error.retryable
|
|
159
|
+
const download = Effect.fn("ToolInstaller.download")(function* (url, options) {
|
|
160
|
+
return yield* Effect.gen(function* () {
|
|
161
|
+
const response = yield* http.get(url).pipe(Effect.mapError((cause) => new ToolInstallerError({
|
|
162
|
+
reason: "downloadFailed",
|
|
163
|
+
subject: url,
|
|
164
|
+
cause
|
|
165
|
+
})));
|
|
166
|
+
if (response.status < 200 || response.status >= 300) return yield* Effect.fail(new ToolInstallerError({
|
|
167
|
+
reason: "downloadFailed",
|
|
168
|
+
subject: url,
|
|
169
|
+
status: response.status
|
|
179
170
|
}));
|
|
180
|
-
|
|
171
|
+
const directory = yield* fs.makeTempDirectory({ prefix: "effected-download-" }).pipe(Effect.mapError((cause) => new ToolInstallerError({
|
|
172
|
+
reason: "downloadFailed",
|
|
173
|
+
subject: url,
|
|
174
|
+
cause
|
|
175
|
+
})));
|
|
176
|
+
const file = path.join(directory, "download");
|
|
177
|
+
yield* Stream.run(response.stream, fs.sink(file)).pipe(Effect.mapError((cause) => new ToolInstallerError({
|
|
178
|
+
reason: "downloadFailed",
|
|
179
|
+
subject: url,
|
|
180
|
+
cause
|
|
181
|
+
})));
|
|
182
|
+
return file;
|
|
183
|
+
}).pipe(Effect.timeout(options?.timeout ?? DOWNLOAD_TIMEOUT), Effect.catchTag("TimeoutError", (cause) => Effect.fail(new ToolInstallerError({
|
|
184
|
+
reason: "downloadFailed",
|
|
185
|
+
subject: url,
|
|
186
|
+
cause
|
|
187
|
+
}))), Effect.retry({
|
|
188
|
+
schedule: Schedule.exponential("1 second"),
|
|
189
|
+
times: DOWNLOAD_RETRIES,
|
|
190
|
+
while: (error) => error.retryable
|
|
191
|
+
}));
|
|
192
|
+
});
|
|
193
|
+
const find = (tool, version) => Effect.map(Effect.result(fs.stat(cachePath(tool, version))), (result) => result._tag === "Success" && result.success.type === "Directory" ? Option.some(cachePath(tool, version)) : Option.none());
|
|
194
|
+
const cacheFile = Effect.fn("ToolInstaller.cacheFile")(function* (source, name, tool, version) {
|
|
195
|
+
yield* Effect.annotateCurrentSpan({
|
|
196
|
+
tool,
|
|
197
|
+
version
|
|
198
|
+
});
|
|
199
|
+
return yield* staged(tool, (staging) => fs.copyFile(source, path.join(staging, name)).pipe(Effect.mapError((cause) => new ToolInstallerError({
|
|
200
|
+
reason: "cacheFailed",
|
|
201
|
+
subject: tool,
|
|
202
|
+
cause
|
|
203
|
+
})), Effect.flatMap(() => swapIntoCache(staging, tool, version))));
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
find,
|
|
207
|
+
download,
|
|
181
208
|
extractTar: Effect.fn("ToolInstaller.extractTar")(function* (archive, options) {
|
|
182
209
|
const destination = yield* destinationFor(options);
|
|
183
210
|
const flags = options?.flags === void 0 || options.flags.length === 0 ? ["xzf"] : [...options.flags];
|
|
@@ -195,7 +222,7 @@ const make = Effect.gen(function* () {
|
|
|
195
222
|
"-NoProfile",
|
|
196
223
|
"-NonInteractive",
|
|
197
224
|
"-Command",
|
|
198
|
-
|
|
225
|
+
`$ErrorActionPreference = 'Stop'; try { Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory('${archive.replaceAll("'", "''")}', '${destination.replaceAll("'", "''")}', $true) } catch { [Console]::Error.WriteLine($_.Exception.ToString()); exit 1 }`
|
|
199
226
|
]) : ChildProcess.make("unzip", [
|
|
200
227
|
"-oq",
|
|
201
228
|
archive,
|
|
@@ -216,16 +243,31 @@ const make = Effect.gen(function* () {
|
|
|
216
243
|
cause
|
|
217
244
|
})), Effect.flatMap(() => swapIntoCache(staging, tool, version))));
|
|
218
245
|
}),
|
|
219
|
-
cacheFile
|
|
246
|
+
cacheFile,
|
|
247
|
+
provisionFile: Effect.fn("ToolInstaller.provisionFile")(function* (options) {
|
|
220
248
|
yield* Effect.annotateCurrentSpan({
|
|
221
|
-
tool,
|
|
222
|
-
version
|
|
249
|
+
tool: options.tool,
|
|
250
|
+
version: options.version
|
|
223
251
|
});
|
|
224
|
-
|
|
252
|
+
const cached = yield* find(options.tool, options.version);
|
|
253
|
+
if (Option.isSome(cached)) {
|
|
254
|
+
const present = yield* Effect.result(fs.stat(path.join(cached.value, options.binary)));
|
|
255
|
+
if (present._tag === "Success" && present.success.type === "File") return {
|
|
256
|
+
directory: cached.value,
|
|
257
|
+
binDir: cached.value
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
const file = yield* download(options.url);
|
|
261
|
+
if (!windows) yield* fs.chmod(file, 493).pipe(Effect.mapError((cause) => new ToolInstallerError({
|
|
225
262
|
reason: "cacheFailed",
|
|
226
|
-
subject:
|
|
263
|
+
subject: file,
|
|
227
264
|
cause
|
|
228
|
-
}))
|
|
265
|
+
})));
|
|
266
|
+
const directory = yield* cacheFile(file, options.binary, options.tool, options.version);
|
|
267
|
+
return {
|
|
268
|
+
directory,
|
|
269
|
+
binDir: directory
|
|
270
|
+
};
|
|
229
271
|
})
|
|
230
272
|
};
|
|
231
273
|
});
|
|
@@ -236,10 +278,13 @@ const unimplemented = (member) => {
|
|
|
236
278
|
* Download, extract and cache a toolchain in the runner's tool cache.
|
|
237
279
|
*
|
|
238
280
|
* @remarks
|
|
239
|
-
* The primitives,
|
|
240
|
-
* `extractTar` → `cacheDir` in whatever order its tool
|
|
241
|
-
* orders genuinely differ (a
|
|
242
|
-
*
|
|
281
|
+
* The primitives, plus exactly one packaged workflow: a consumer composes
|
|
282
|
+
* `find` → `download` → `extractTar` → `cacheDir` in whatever order its tool
|
|
283
|
+
* needs, because the orders genuinely differ (a tarball with a nested root
|
|
284
|
+
* directory needs a path fixup between extract and cache). The one shape with
|
|
285
|
+
* no per-tool variation — a single bare binary, which skips extraction and
|
|
286
|
+
* needs only the executable bit — ships whole as
|
|
287
|
+
* {@link ToolInstallerShape.provisionFile}.
|
|
243
288
|
*
|
|
244
289
|
* No `@actions/tool-cache` dependency — the cache is a directory layout, and
|
|
245
290
|
* this reproduces the layout rather than importing a package to compute it.
|
|
@@ -283,6 +328,7 @@ var ToolInstaller = class ToolInstaller extends Context.Service()("@effected/git
|
|
|
283
328
|
extractZip: () => Effect.sync(() => unimplemented("extractZip")),
|
|
284
329
|
cacheDir: () => Effect.sync(() => unimplemented("cacheDir")),
|
|
285
330
|
cacheFile: () => Effect.sync(() => unimplemented("cacheFile")),
|
|
331
|
+
provisionFile: () => Effect.sync(() => unimplemented("provisionFile")),
|
|
286
332
|
...overrides
|
|
287
333
|
});
|
|
288
334
|
/** {@link ToolInstaller.makeTest} behind `Layer.succeed`. */
|