@effected/github-actions 0.2.0 → 0.4.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/ActionEnvironment.js +34 -1
- package/ActionInput.js +61 -8
- package/ActionOutputs.js +71 -2
- package/ActionState.js +33 -6
- package/Artifact.js +2 -2
- package/BlobStore.githubCache.js +2 -1
- package/CacheKey.js +147 -8
- package/ChildEnv.js +124 -0
- package/PackageManagerInstaller.js +494 -0
- package/Secret.js +21 -0
- package/ToolInstaller.js +98 -52
- package/index.d.ts +878 -125
- package/index.js +3 -1
- package/package.json +5 -4
package/CacheKey.js
CHANGED
|
@@ -48,6 +48,20 @@ const MAX_KEY_LENGTH = 512;
|
|
|
48
48
|
const Segment = Schema.String.check(Schema.isPattern(/^[^,\n\r]+$/));
|
|
49
49
|
const Segments = Schema.NonEmptyArray(Segment).check(Schema.makeFilter((values) => values.join(SEPARATOR).length <= MAX_KEY_LENGTH, { title: `a cache key of at most ${MAX_KEY_LENGTH} characters` }));
|
|
50
50
|
/**
|
|
51
|
+
* One explicit rung: how many leading segments it keeps. The upper bound is
|
|
52
|
+
* cross-field (`segments.length - 1`) and lives on the class schema.
|
|
53
|
+
*/
|
|
54
|
+
const RestoreDepth = Schema.Number.check(Schema.isInt(), Schema.isBetween({
|
|
55
|
+
minimum: 1,
|
|
56
|
+
maximum: Number.MAX_SAFE_INTEGER
|
|
57
|
+
}));
|
|
58
|
+
/**
|
|
59
|
+
* The explicit ladder: possibly empty, because **zero rungs is a policy** —
|
|
60
|
+
* exact-match-only restores, spelled by {@link CacheKey.withoutRestoreKeys}.
|
|
61
|
+
* Only *absence* means the default every-prefix ladder.
|
|
62
|
+
*/
|
|
63
|
+
const RestoreDepths = Schema.Array(RestoreDepth);
|
|
64
|
+
/**
|
|
51
65
|
* A GitHub Actions cache key and the restore-key ladder that goes with it.
|
|
52
66
|
*
|
|
53
67
|
* @remarks
|
|
@@ -58,6 +72,12 @@ const Segments = Schema.NonEmptyArray(Segment).check(Schema.makeFilter((values)
|
|
|
58
72
|
* the primary key and its fallbacks impossible to drift apart, which is the
|
|
59
73
|
* failure that produces a cache that never hits and never says why.
|
|
60
74
|
*
|
|
75
|
+
* By default every prefix is a rung. When some segments must never be dropped
|
|
76
|
+
* — a rung without a version digest resurrects stale cross-version caches —
|
|
77
|
+
* give the key an explicit ladder policy with
|
|
78
|
+
* {@link CacheKey.withRestoreDepths}, or none at all with
|
|
79
|
+
* {@link CacheKey.withoutRestoreKeys}, instead of bypassing the typed key.
|
|
80
|
+
*
|
|
61
81
|
* @example
|
|
62
82
|
* ```ts
|
|
63
83
|
* import { CacheKey } from "@effected/github-actions";
|
|
@@ -69,9 +89,18 @@ const Segments = Schema.NonEmptyArray(Segment).check(Schema.makeFilter((values)
|
|
|
69
89
|
*
|
|
70
90
|
* @public
|
|
71
91
|
*/
|
|
72
|
-
var CacheKey = class CacheKey extends Schema.Class("CacheKey")({
|
|
73
|
-
/** The components, most general first. */
|
|
74
|
-
segments: Segments
|
|
92
|
+
var CacheKey = class CacheKey extends Schema.Class("CacheKey")(Schema.Struct({
|
|
93
|
+
/** The components, most general first. */
|
|
94
|
+
segments: Segments,
|
|
95
|
+
/**
|
|
96
|
+
* The explicit ladder policy, when one was given: how many leading
|
|
97
|
+
* segments each rung keeps, in the order the rungs are tried. Absent for
|
|
98
|
+
* the default every-prefix ladder; **empty for no ladder at all** —
|
|
99
|
+
* exact-match-only. See {@link CacheKey.withRestoreDepths} and
|
|
100
|
+
* {@link CacheKey.withoutRestoreKeys}.
|
|
101
|
+
*/
|
|
102
|
+
restoreDepths: Schema.optionalKey(RestoreDepths)
|
|
103
|
+
}).check(Schema.makeFilter((key) => key.restoreDepths === void 0 || key.restoreDepths.every((depth) => depth <= key.segments.length - 1) ? void 0 : "every restore depth must be between 1 and segments.length - 1 — a rung keeping every segment would just repeat the primary key"))) {
|
|
75
104
|
/** The primary key: every segment, joined. */
|
|
76
105
|
get key() {
|
|
77
106
|
return this.segments.join(SEPARATOR);
|
|
@@ -80,16 +109,85 @@ segments: Segments }) {
|
|
|
80
109
|
* The restore keys, most specific first.
|
|
81
110
|
*
|
|
82
111
|
* @remarks
|
|
83
|
-
*
|
|
84
|
-
* three-segment key falls back to two prefixes and a
|
|
85
|
-
* back to nothing — there is no rung that would match
|
|
86
|
-
* repository.
|
|
112
|
+
* By default each rung drops the last segment and keeps the trailing
|
|
113
|
+
* separator, so a three-segment key falls back to two prefixes and a
|
|
114
|
+
* one-segment key falls back to nothing — there is no rung that would match
|
|
115
|
+
* every cache in the repository.
|
|
116
|
+
*
|
|
117
|
+
* **The default ladder is every prefix**, which is wrong for a key whose
|
|
118
|
+
* later segments must never be dropped alone: a
|
|
119
|
+
* `platform-arch-versionHash-branchHash-lockfileHash` key derives rungs
|
|
120
|
+
* without the version digest, and those resurrect stale cross-version
|
|
121
|
+
* caches. A key built with {@link CacheKey.withRestoreDepths} carries its
|
|
122
|
+
* own ladder instead, and this getter answers exactly that ladder — so
|
|
123
|
+
* `ActionCache.restore` picks the policy up through the same typed-key
|
|
124
|
+
* path, no hand-built restore-key list required. An **empty** policy
|
|
125
|
+
* ({@link CacheKey.withoutRestoreKeys}) answers no rungs at all: only
|
|
126
|
+
* *absence* selects the default ladder, so "none" is never mistaken for
|
|
127
|
+
* "every prefix".
|
|
87
128
|
*/
|
|
88
129
|
get restoreKeys() {
|
|
130
|
+
const rung = (length) => `${this.segments.slice(0, length).join(SEPARATOR)}${SEPARATOR}`;
|
|
131
|
+
if (this.restoreDepths !== void 0) return this.restoreDepths.map(rung);
|
|
89
132
|
const rungs = [];
|
|
90
|
-
for (let length = this.segments.length - 1; length >= 1; length -= 1) rungs.push(
|
|
133
|
+
for (let length = this.segments.length - 1; length >= 1; length -= 1) rungs.push(rung(length));
|
|
91
134
|
return rungs;
|
|
92
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* A copy of this key carrying an explicit restore-key ladder policy.
|
|
138
|
+
*
|
|
139
|
+
* @remarks
|
|
140
|
+
* Each depth is the number of **leading segments** the rung keeps, and the
|
|
141
|
+
* rungs are emitted in exactly the order given — GitHub tries restore keys
|
|
142
|
+
* in order, so the order IS the policy. Descending depths (most specific
|
|
143
|
+
* first) are what a fallback ladder almost always wants; that convention is
|
|
144
|
+
* recommended rather than enforced, because rung ordering is the consumer's
|
|
145
|
+
* policy where commas, newlines and the length limit are the runner's
|
|
146
|
+
* protocol. A depth outside `1..segments.length - 1` is refused at
|
|
147
|
+
* construction: `0` would match every cache in the repository, and
|
|
148
|
+
* `segments.length` would just repeat the primary key.
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* ```ts
|
|
152
|
+
* import { CacheKey } from "@effected/github-actions";
|
|
153
|
+
*
|
|
154
|
+
* const key = CacheKey.of("Linux", "X64", "v1hash", "main", "lockhash").withRestoreDepths([4, 3]);
|
|
155
|
+
* key.restoreKeys; // ["Linux-X64-v1hash-main-", "Linux-X64-v1hash-"]
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
withRestoreDepths(depths) {
|
|
159
|
+
return CacheKey.make({
|
|
160
|
+
segments: this.segments,
|
|
161
|
+
restoreDepths: depths
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* A copy of this key that restores from its **exact key only** — no ladder.
|
|
166
|
+
*
|
|
167
|
+
* @remarks
|
|
168
|
+
* The third point in the policy space, and the one the typed key had no
|
|
169
|
+
* spelling for: absence of a policy means the default every-prefix ladder,
|
|
170
|
+
* so exact-match-only restores (a cache-bust mode, where a stale partial
|
|
171
|
+
* hit is worse than a cold start) previously forced the caller back to the
|
|
172
|
+
* string form. This is the same `restoreDepths` field carrying **zero
|
|
173
|
+
* rungs** — an honest value, not a sentinel: `ActionCache.restore` sends no
|
|
174
|
+
* `restore_keys` at all, so the backend answers this key or a miss.
|
|
175
|
+
*
|
|
176
|
+
* @example
|
|
177
|
+
* ```ts
|
|
178
|
+
* import { CacheKey } from "@effected/github-actions";
|
|
179
|
+
*
|
|
180
|
+
* const key = CacheKey.of("Linux", "pnpm-store", "abc123").withoutRestoreKeys();
|
|
181
|
+
* key.key; // "Linux-pnpm-store-abc123"
|
|
182
|
+
* key.restoreKeys; // []
|
|
183
|
+
* ```
|
|
184
|
+
*/
|
|
185
|
+
withoutRestoreKeys() {
|
|
186
|
+
return CacheKey.make({
|
|
187
|
+
segments: this.segments,
|
|
188
|
+
restoreDepths: []
|
|
189
|
+
});
|
|
190
|
+
}
|
|
93
191
|
/** Build a key from its segments. */
|
|
94
192
|
static of(...segments) {
|
|
95
193
|
return CacheKey.make({ segments });
|
|
@@ -117,6 +215,47 @@ segments: Segments }) {
|
|
|
117
215
|
return CacheKey.make({ segments });
|
|
118
216
|
}
|
|
119
217
|
/**
|
|
218
|
+
* A segment-safe short digest of a string.
|
|
219
|
+
*
|
|
220
|
+
* @remarks
|
|
221
|
+
* {@link CacheKey.hashFiles} covers file *content*; this covers the key
|
|
222
|
+
* segments built from **non-file** inputs — a sorted version list, a branch
|
|
223
|
+
* name — that every compound key hashes and truncates by hand, each caller
|
|
224
|
+
* re-deciding the length and the placement. One home for the derivation:
|
|
225
|
+
* SHA-256, lowercase hex, the first `length` characters.
|
|
226
|
+
*
|
|
227
|
+
* The answer is **guaranteed to satisfy the segment grammar**: lowercase hex
|
|
228
|
+
* is nonempty (for any permitted `length`) and can never contain the comma
|
|
229
|
+
* or newline the restore-key protocol reserves, so the result drops straight
|
|
230
|
+
* into {@link CacheKey.of} with nothing to check at the call site.
|
|
231
|
+
*
|
|
232
|
+
* Deterministic and pure — the same input always answers the same digest,
|
|
233
|
+
* which is the entire point of putting one in a cache key. Distinct inputs
|
|
234
|
+
* are only *probably* distinct, as with any truncated hash; eight hex
|
|
235
|
+
* characters (32 bits) is the conventional balance between key legibility
|
|
236
|
+
* and collision risk for cache segments.
|
|
237
|
+
*
|
|
238
|
+
* A `length` outside `1..64` (or a fractional one) is a wiring mistake, not
|
|
239
|
+
* data, and **throws a `RangeError`** rather than failing typed: 64 is all
|
|
240
|
+
* SHA-256 has, and asking for more would silently answer fewer characters
|
|
241
|
+
* than the caller believes it got.
|
|
242
|
+
*
|
|
243
|
+
* @example
|
|
244
|
+
* ```ts
|
|
245
|
+
* import { CacheKey } from "@effected/github-actions";
|
|
246
|
+
*
|
|
247
|
+
* const key = CacheKey.of(
|
|
248
|
+
* "Linux",
|
|
249
|
+
* CacheKey.digest("node:24.4.0,pnpm:10.13.1"),
|
|
250
|
+
* CacheKey.digest("feat/my-branch"),
|
|
251
|
+
* );
|
|
252
|
+
* ```
|
|
253
|
+
*/
|
|
254
|
+
static digest(input, length = 8) {
|
|
255
|
+
if (!Number.isInteger(length) || length < 1 || length > 64) throw new RangeError(`A digest length must be an integer between 1 and 64, got ${length}`);
|
|
256
|
+
return createHash("sha256").update(input).digest("hex").slice(0, length);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
120
259
|
* Hash a set of files into a single digest.
|
|
121
260
|
*
|
|
122
261
|
* @remarks
|
package/ChildEnv.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
//#region src/ChildEnv.ts
|
|
2
|
+
/**
|
|
3
|
+
* Child-process environment construction: prepending directories to `PATH`
|
|
4
|
+
* without falling into the three traps that each cost a real consumer a
|
|
5
|
+
* cross-OS matrix round.
|
|
6
|
+
*
|
|
7
|
+
* @remarks
|
|
8
|
+
* Spawning a child with directories prepended to `PATH` is one tiny concern
|
|
9
|
+
* with three sharp edges, and every one of them fails silently or only on
|
|
10
|
+
* Windows:
|
|
11
|
+
*
|
|
12
|
+
* 1. **`env` without `extendEnv: true` strips the child's environment.**
|
|
13
|
+
* Core's `ChildProcess.CommandOptions.env` *replaces* the block unless
|
|
14
|
+
* `extendEnv` says to merge, so a bare `{ env }` costs the child everything
|
|
15
|
+
* — including the very `PATH` being extended.
|
|
16
|
+
* 2. **Windows spells it `Path`, and casing decides who wins.** The
|
|
17
|
+
* environment block is case-insensitive on win32, and Node de-duplicates a
|
|
18
|
+
* merged block by keeping the **lexicographically first** spelling — an
|
|
19
|
+
* undocumented internal that happens to favour `PATH` over an inherited
|
|
20
|
+
* `Path`, and that nothing obliges Node to keep. Writing through the
|
|
21
|
+
* inherited key's own casing means never depending on it.
|
|
22
|
+
* 3. **`.cmd` shims need a shell since CVE-2024-27980.** See
|
|
23
|
+
* {@link ChildEnv.needsShell}.
|
|
24
|
+
*
|
|
25
|
+
* The first two live in {@link ChildEnv.prependPath}; the third is a separate
|
|
26
|
+
* predicate because it belongs on the spawn call, not in the environment.
|
|
27
|
+
*
|
|
28
|
+
* This class is total and pure — no service, no `R`, nothing read from the
|
|
29
|
+
* ambient process — the same species as `Secret`'s statics, one concern per
|
|
30
|
+
* member.
|
|
31
|
+
*
|
|
32
|
+
* @public
|
|
33
|
+
*/
|
|
34
|
+
var ChildEnv = class ChildEnv {
|
|
35
|
+
constructor() {}
|
|
36
|
+
/**
|
|
37
|
+
* The key `base` spells `PATH` with — `Path` on a typical Windows block,
|
|
38
|
+
* `PATH` elsewhere, and `PATH` when the base has no entry at all.
|
|
39
|
+
*
|
|
40
|
+
* @remarks
|
|
41
|
+
* The lookup is case-insensitive because the Windows environment block is;
|
|
42
|
+
* the answer preserves the base's own casing because that is the spelling a
|
|
43
|
+
* merged block must write through (trap 2 above).
|
|
44
|
+
*/
|
|
45
|
+
static pathKeyOf(base) {
|
|
46
|
+
return Object.keys(base).find((key) => key.toUpperCase() === "PATH") ?? "PATH";
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The environment additions that prepend `dirs` to the child's `PATH`,
|
|
50
|
+
* paired with the `extendEnv: true` that keeps them a *prepend*.
|
|
51
|
+
*
|
|
52
|
+
* @remarks
|
|
53
|
+
* `dirs` are joined ahead of the inherited value **in the order given** —
|
|
54
|
+
* order is the caller's policy, because the first directory wins any name
|
|
55
|
+
* collision (a package manager's shim directory leads so it beats a runtime
|
|
56
|
+
* of the same name). The entry is written under the base's own `PATH`
|
|
57
|
+
* spelling ({@link ChildEnv.pathKeyOf}), so a Windows child never receives
|
|
58
|
+
* `PATH` beside its inherited `Path`. An absent or empty inherited value
|
|
59
|
+
* appends nothing — deliberately not an empty trailing entry, which both
|
|
60
|
+
* platforms historically read as "the current directory".
|
|
61
|
+
*
|
|
62
|
+
* Two consumers, two compositions, and the difference is exactly trap 1:
|
|
63
|
+
*
|
|
64
|
+
* - A spawner call takes the **whole pair** — spread it into the command
|
|
65
|
+
* options, as in the example below, so the `extendEnv: true` that makes
|
|
66
|
+
* `env` a merge rather than a replacement cannot be forgotten.
|
|
67
|
+
* - An API that already merges additions over the parent itself —
|
|
68
|
+
* `DetachedProcess.spawn`, whose `env` is documented as merged — takes
|
|
69
|
+
* `.env` alone; `extendEnv` would be meaningless there.
|
|
70
|
+
*
|
|
71
|
+
* On a runner, pass `{ base: process.env, platform: process.platform }`.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```ts
|
|
75
|
+
* import { ChildEnv } from "@effected/github-actions";
|
|
76
|
+
* import { ChildProcess } from "effect/unstable/process";
|
|
77
|
+
*
|
|
78
|
+
* const command = ChildProcess.make("pnpm", ["install"], {
|
|
79
|
+
* ...ChildEnv.prependPath(["/opt/hostedtoolcache/pnpm/10.13.1/x64/bin"], {
|
|
80
|
+
* base: process.env,
|
|
81
|
+
* platform: process.platform,
|
|
82
|
+
* }),
|
|
83
|
+
* ...(ChildEnv.needsShell(process.platform) ? { shell: true } : {}),
|
|
84
|
+
* });
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
static prependPath(dirs, options) {
|
|
88
|
+
const delimiter = options.platform === "win32" ? ";" : ":";
|
|
89
|
+
const key = ChildEnv.pathKeyOf(options.base);
|
|
90
|
+
const inherited = options.base[key];
|
|
91
|
+
const entries = inherited === void 0 || inherited === "" ? dirs : [...dirs, inherited];
|
|
92
|
+
return {
|
|
93
|
+
env: { [key]: entries.join(delimiter) },
|
|
94
|
+
extendEnv: true
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Whether a bare-name command must be launched through a shell on this
|
|
99
|
+
* platform.
|
|
100
|
+
*
|
|
101
|
+
* @remarks
|
|
102
|
+
* On Windows every npm-installed tool on `PATH` is a `.cmd` batch shim, not
|
|
103
|
+
* an executable: `CreateProcess` cannot run one, and since CVE-2024-27980
|
|
104
|
+
* Node refuses to hand `.cmd`/`.bat` files to it at all unless a shell is
|
|
105
|
+
* asked for — so the spawn fails at launch, before the tool ever runs.
|
|
106
|
+
* Under `shell: true`, `cmd.exe` resolves the bare name off the **child's**
|
|
107
|
+
* `PATH` — the one {@link ChildEnv.prependPath} builds — through `PATHEXT`,
|
|
108
|
+
* where `.EXE` precedes `.CMD`, so a real binary of the same name is still
|
|
109
|
+
* found first and the prepends keep working exactly as they do without a
|
|
110
|
+
* shell.
|
|
111
|
+
*
|
|
112
|
+
* POSIX answers `false`: the direct spawn already works, and a shell would
|
|
113
|
+
* only add a quoting hazard between the caller and the exit code. Note the
|
|
114
|
+
* hazard `true` carries: under a shell Node concatenates the command line
|
|
115
|
+
* rather than passing an argv, so arguments must be static literals, never
|
|
116
|
+
* derived from input.
|
|
117
|
+
*/
|
|
118
|
+
static needsShell(platform) {
|
|
119
|
+
return platform === "win32";
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
//#endregion
|
|
124
|
+
export { ChildEnv };
|