@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/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
- * Each rung drops the last segment and keeps the trailing separator, so a
84
- * three-segment key falls back to two prefixes and a one-segment key falls
85
- * back to nothing — there is no rung that would match every cache in the
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(`${this.segments.slice(0, length).join(SEPARATOR)}${SEPARATOR}`);
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 };