willfire 0.1.27 → 0.1.28

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/dist/execute.js CHANGED
@@ -33,7 +33,7 @@ export function renderTemplate(text, scope) {
33
33
  }
34
34
  /** An `env:` block rendered to concrete strings, every key or nothing. */
35
35
  function renderEnvLayer(layer, scope) {
36
- if (layer == null) {
36
+ if (layer === null || layer === undefined) {
37
37
  return { ok: true, v: {} };
38
38
  }
39
39
  if (typeof layer !== "object" || Array.isArray(layer)) {
@@ -42,7 +42,7 @@ function renderEnvLayer(layer, scope) {
42
42
  const out = {};
43
43
  for (const [k, raw] of Object.entries(layer)) {
44
44
  const rendered = renderTemplate(String(raw ?? ""), scope);
45
- if (rendered == null) {
45
+ if (rendered === null) {
46
46
  return err(`cannot resolve env '${k}'`);
47
47
  }
48
48
  out[k] = rendered;
@@ -60,32 +60,25 @@ export function parseGithubOutput(text) {
60
60
  while (i < lines.length) {
61
61
  const line = lines[i];
62
62
  i++;
63
- if (line === "") {
64
- continue;
65
- }
66
- const heredoc = /^([^=<]+)<<(.+)$/.exec(line);
67
- if (heredoc != null) {
68
- const [, name, delim] = heredoc;
69
- const buf = [];
70
- for (;;) {
71
- if (i >= lines.length) {
63
+ if (line !== "") {
64
+ const heredoc = /^([^=<]+)<<(.+)$/.exec(line);
65
+ if (heredoc !== null) {
66
+ const [, name, delim] = heredoc;
67
+ const end = lines.indexOf(delim, i);
68
+ if (end === -1) {
72
69
  return null; // unterminated heredoc
73
70
  }
74
- if (lines[i] === delim) {
75
- i++;
76
- break;
71
+ out[name] = lines.slice(i, end).join("\n");
72
+ i = end + 1;
73
+ }
74
+ else {
75
+ const eq = line.indexOf("=");
76
+ if (eq <= 0) {
77
+ return null;
77
78
  }
78
- buf.push(lines[i]);
79
- i++;
79
+ out[line.slice(0, eq)] = line.slice(eq + 1);
80
80
  }
81
- out[name] = buf.join("\n");
82
- continue;
83
- }
84
- const eq = line.indexOf("=");
85
- if (eq <= 0) {
86
- return null;
87
81
  }
88
- out[line.slice(0, eq)] = line.slice(eq + 1);
89
82
  }
90
83
  return out;
91
84
  }
@@ -130,23 +123,22 @@ async function readActionManifest(dir) {
130
123
  */
131
124
  function bindActionInputs(action, withBlock, scope) {
132
125
  const bind = (raw) => {
133
- if (raw == null) {
126
+ if (raw === null || raw === undefined) {
134
127
  return { kind: "value", v: "" };
135
128
  }
136
129
  if (typeof raw === "boolean" || typeof raw === "number") {
137
130
  return { kind: "value", v: String(raw) };
138
131
  }
139
132
  const rendered = renderTemplate(String(raw), scope);
140
- return rendered == null ? UNKNOWN : { kind: "value", v: rendered };
133
+ return rendered === null ? UNKNOWN : { kind: "value", v: rendered };
141
134
  };
142
135
  const out = {};
143
136
  for (const [name, decl] of Object.entries(action?.inputs ?? {})) {
144
- out[name] =
145
- decl != null && typeof decl === "object" && "default" in decl
146
- ? bind(decl["default"])
147
- : { kind: "value", v: "" };
137
+ // An input declared without a `default:` binds the same empty string a
138
+ // missing declaration does, so the two cases share one path.
139
+ out[name] = bind(decl?.["default"]);
148
140
  }
149
- if (withBlock != null && typeof withBlock === "object") {
141
+ if (withBlock !== null && typeof withBlock === "object") {
150
142
  for (const [name, raw] of Object.entries(withBlock)) {
151
143
  out[name] = bind(raw);
152
144
  }
@@ -164,34 +156,37 @@ async function runSteps(steps, scope, ctx) {
164
156
  const step = steps[i] ?? {};
165
157
  const label = `step '${step.id ?? step.name ?? `#${i + 1}`}'`;
166
158
  const stepScope = { ...scope, steps: stepsCtx };
167
- if (step.if != null) {
159
+ let skipped = false;
160
+ if (step.if !== undefined && step.if !== null) {
168
161
  const verdict = evaluate(String(step.if), stepScope);
169
- if (verdict == null) {
162
+ if (verdict === null) {
170
163
  return err(`cannot decide if: for ${label}`);
171
164
  }
172
- if (!verdict) {
173
- // A skipped step still occupies its id, with no outputs.
174
- if (typeof step.id === "string") {
175
- stepsCtx[step.id] = { outputs: {} };
176
- }
177
- continue;
178
- }
165
+ skipped = !verdict;
179
166
  }
180
- let res;
181
- if (typeof step.uses === "string") {
182
- res = await runUses(step, label, stepScope, ctx);
183
- }
184
- else if (step.run != null) {
185
- res = await runRun(step, label, stepScope, ctx);
167
+ if (skipped) {
168
+ // A skipped step still occupies its id, with no outputs.
169
+ if (typeof step.id === "string") {
170
+ stepsCtx[step.id] = { outputs: {} };
171
+ }
186
172
  }
187
173
  else {
188
- return err(`${label} has neither uses nor run`);
189
- }
190
- if (!res.ok) {
191
- return res;
192
- }
193
- if (typeof step.id === "string") {
194
- stepsCtx[step.id] = { outputs: res.v };
174
+ let res;
175
+ if (typeof step.uses === "string") {
176
+ res = await runUses(step, label, stepScope, ctx);
177
+ }
178
+ else if (step.run !== undefined && step.run !== null) {
179
+ res = await runRun(step, label, stepScope, ctx);
180
+ }
181
+ else {
182
+ return err(`${label} has neither uses nor run`);
183
+ }
184
+ if (!res.ok) {
185
+ return res;
186
+ }
187
+ if (typeof step.id === "string") {
188
+ stepsCtx[step.id] = { outputs: res.v };
189
+ }
195
190
  }
196
191
  }
197
192
  return { ok: true, v: stepsCtx };
@@ -247,24 +242,24 @@ async function runUses(step, label, scope, ctx) {
247
242
  }
248
243
  else {
249
244
  const target = parseActionUses(uses);
250
- if (target == null) {
245
+ if (target === null) {
251
246
  return err(`${label}: unresolvable uses: ${uses}`);
252
247
  }
253
248
  const { ref } = target.source;
254
249
  const sha = SHA_RE.test(ref) ? ref : await ctx.deps.resolveRef(target.source);
255
- if (sha == null) {
250
+ if (sha === null) {
256
251
  return err(`${label}: cannot resolve ref for ${uses}`);
257
252
  }
258
253
  const source = { ...target.source, sha };
259
254
  const root = await ctx.deps.provideTree(source);
260
- if (root == null) {
255
+ if (root === null) {
261
256
  return err(`${label}: cannot materialize ${source.owner}/${source.repo}@${sha}`);
262
257
  }
263
- actionDir = target.path === "" ? root : join(root, target.path);
258
+ actionDir = join(root, target.path);
264
259
  actionRoot = root;
265
260
  }
266
261
  const manifest = await readActionManifest(actionDir);
267
- if (manifest == null) {
262
+ if (manifest === null) {
268
263
  return err(`${label}: no action.yml under ${uses}`);
269
264
  }
270
265
  let action;
@@ -300,11 +295,11 @@ async function runUses(step, label, scope, ctx) {
300
295
  const outputs = {};
301
296
  for (const [name, decl] of Object.entries(action.outputs ?? {})) {
302
297
  const raw = decl?.["value"];
303
- if (raw == null) {
298
+ if (raw === null || raw === undefined) {
304
299
  return err(`${label}: output '${name}' of ${uses} has no value`);
305
300
  }
306
301
  const rendered = renderTemplate(String(raw), outScope);
307
- if (rendered == null) {
302
+ if (rendered === null) {
308
303
  return err(`${label}: cannot resolve output '${name}' of ${uses}`);
309
304
  }
310
305
  outputs[name] = rendered;
@@ -384,12 +379,12 @@ async function runNodeAction(step, label, uses, action, actionDir, actionRoot, u
384
379
  }
385
380
  /** A `run:` step, executed under its declared shell with its declared env. */
386
381
  async function runRun(step, label, scope, ctx) {
387
- const shell = step.shell == null ? "bash" : String(step.shell);
382
+ const shell = step.shell === null || step.shell === undefined ? "bash" : String(step.shell);
388
383
  if (shell !== "bash" && shell !== "sh") {
389
384
  return err(`${label}: shell '${shell}' is not modelled`);
390
385
  }
391
386
  const script = renderTemplate(String(step.run), scope);
392
- if (script == null) {
387
+ if (script === null) {
393
388
  return err(`${label}: cannot resolve \${{ }} in run`);
394
389
  }
395
390
  const env = {
@@ -416,9 +411,9 @@ async function runRun(step, label, scope, ctx) {
416
411
  Object.assign(env, rendered.v);
417
412
  }
418
413
  let cwd = ctx.tree;
419
- if (step["working-directory"] != null) {
414
+ if (step["working-directory"] !== undefined && step["working-directory"] !== null) {
420
415
  const wd = renderTemplate(String(step["working-directory"]), scope);
421
- if (wd == null) {
416
+ if (wd === null) {
422
417
  return err(`${label}: cannot resolve working-directory`);
423
418
  }
424
419
  cwd = resolve(ctx.tree, wd);
@@ -445,7 +440,7 @@ async function runRun(step, label, scope, ctx) {
445
440
  return err(`${label}: exited ${r.code}${tail === "" ? "" : ` (${tail})`}`);
446
441
  }
447
442
  const outputs = parseGithubOutput(await readFile(outFile, "utf8"));
448
- if (outputs == null) {
443
+ if (outputs === null) {
449
444
  return err(`${label}: malformed GITHUB_OUTPUT`);
450
445
  }
451
446
  return { ok: true, v: outputs };
@@ -463,7 +458,10 @@ export function makeExecutor(opts) {
463
458
  if (job.strategy !== undefined && job.strategy !== null) {
464
459
  return fail(`job '${jobId}' has a strategy; not modelled`);
465
460
  }
466
- if (job.container != null || job.services != null) {
461
+ if (job.container !== null && job.container !== undefined) {
462
+ return fail(`job '${jobId}' uses a container or services; not modelled`);
463
+ }
464
+ if (job.services !== null && job.services !== undefined) {
467
465
  return fail(`job '${jobId}' uses a container or services; not modelled`);
468
466
  }
469
467
  if (!Array.isArray(job.steps)) {
@@ -477,7 +475,7 @@ export function makeExecutor(opts) {
477
475
  CHECKOUT_RE.test(s.uses) &&
478
476
  Object.keys(s.with ?? {}).length > 0);
479
477
  const tree = await deps.provideTree(workspace, { history: needsHistory });
480
- if (tree == null) {
478
+ if (tree === null) {
481
479
  return fail(`cannot materialize workspace ${workspace.owner}/${workspace.repo}@${workspace.sha}`);
482
480
  }
483
481
  const jobScope = { ...scope, github: { ...github, ...scope.github } };
@@ -496,7 +494,7 @@ export function makeExecutor(opts) {
496
494
  const outputs = {};
497
495
  for (const [name, raw] of Object.entries(job.outputs ?? {})) {
498
496
  const rendered = renderTemplate(String(raw), outScope);
499
- if (rendered == null) {
497
+ if (rendered === null) {
500
498
  return fail(`cannot resolve job output '${name}'`);
501
499
  }
502
500
  outputs[name] = rendered;
@@ -609,7 +607,7 @@ async function cloneAt(source, remote, token, runCommand) {
609
607
  }
610
608
  async function materialize(source, download, runCommand) {
611
609
  const bytes = await download(source);
612
- if (bytes == null) {
610
+ if (bytes === null) {
613
611
  return null;
614
612
  }
615
613
  const dir = await mkdtemp(join(tmpdir(), "willfire-tree-"));
@@ -13,7 +13,7 @@ export class Cursor {
13
13
  }
14
14
  eatOp(v) {
15
15
  const t = this.peek();
16
- if (t != null && t.t === "op" && t.v === v) {
16
+ if (t !== undefined && t.t === "op" && t.v === v) {
17
17
  this.pos++;
18
18
  return true;
19
19
  }
@@ -23,7 +23,7 @@ export function evaluateValue(expr, scope = {}) {
23
23
  return UNKNOWN;
24
24
  }
25
25
  const toks = tokenize(stripped);
26
- if (toks == null || toks.length === 0) {
26
+ if (toks === null) {
27
27
  return UNKNOWN;
28
28
  }
29
29
  const cur = new Cursor(toks);
@@ -24,7 +24,7 @@ export function indexVal(base, idx) {
24
24
  }
25
25
  el = base.v[idx.v];
26
26
  }
27
- if (el == null) {
27
+ if (el === null || el === undefined) {
28
28
  return { kind: "value", v: "" };
29
29
  }
30
30
  if (typeof el === "object") {
@@ -22,7 +22,7 @@ export function lookup(scope, path) {
22
22
  return UNKNOWN;
23
23
  }
24
24
  const job = scope.needs?.[parts[0]];
25
- if (job == null) {
25
+ if (job === undefined) {
26
26
  return UNKNOWN;
27
27
  }
28
28
  // A known job's missing output is the empty string, not a hole: the
@@ -40,7 +40,7 @@ export function lookup(scope, path) {
40
40
  return UNKNOWN;
41
41
  }
42
42
  const step = scope.steps?.[parts[0]];
43
- if (step == null) {
43
+ if (step === undefined) {
44
44
  return UNKNOWN;
45
45
  }
46
46
  return { kind: "value", v: step.outputs[parts[2]] ?? "" };
@@ -4,7 +4,7 @@ import { parseOr } from "./parseOr.js";
4
4
  import { UNKNOWN } from "./val.js";
5
5
  export function parseAtom(cur, scope) {
6
6
  const t = cur.peek();
7
- if (t == null) {
7
+ if (t === undefined) {
8
8
  return UNKNOWN;
9
9
  }
10
10
  if (t.t === "op" && t.v === "(") {
@@ -12,13 +12,12 @@ export function parseCall(cur, scope, name) {
12
12
  if (!cur.eatOp(")")) {
13
13
  for (;;) {
14
14
  args.push(parseOr(cur, scope));
15
- if (cur.eatOp(",")) {
16
- continue;
15
+ if (!cur.eatOp(",")) {
16
+ if (cur.eatOp(")")) {
17
+ break;
18
+ }
19
+ return UNKNOWN; // malformed argument list
17
20
  }
18
- if (cur.eatOp(")")) {
19
- break;
20
- }
21
- return UNKNOWN; // malformed argument list
22
21
  }
23
22
  }
24
23
  return applyFunction(name.toLowerCase(), args);
@@ -11,13 +11,13 @@ export function tokenize(src) {
11
11
  const c = src[i];
12
12
  if (c === " " || c === "\t" || c === "\n" || c === "\r") {
13
13
  i++;
14
- continue;
15
14
  }
16
- // Single-quoted string. GitHub escapes an inner quote by doubling it.
17
- if (c === "'") {
15
+ else if (c === "'") {
16
+ // Single-quoted string. GitHub escapes an inner quote by doubling it.
18
17
  let j = i + 1;
19
18
  let s = "";
20
- for (;;) {
19
+ let closed = false;
20
+ while (!closed) {
21
21
  if (j >= src.length) {
22
22
  return null; // unterminated
23
23
  }
@@ -25,50 +25,57 @@ export function tokenize(src) {
25
25
  if (src[j + 1] === "'") {
26
26
  s += "'";
27
27
  j += 2;
28
- continue;
29
28
  }
29
+ else {
30
+ j++;
31
+ closed = true;
32
+ }
33
+ }
34
+ else {
35
+ s += src[j];
30
36
  j++;
31
- break;
32
37
  }
33
- s += src[j];
34
- j++;
35
38
  }
36
39
  out.push({ t: "str", v: s });
37
40
  i = j;
38
- continue;
39
41
  }
40
- const op = OPS.find((o) => src.startsWith(o, i));
41
- if (op != null) {
42
- out.push({ t: "op", v: op });
43
- i += op.length;
44
- continue;
45
- }
46
- const word = /^[A-Za-z_][A-Za-z0-9_.\-]*/.exec(src.slice(i));
47
- if (word != null) {
48
- const w = word[0];
49
- i += w.length;
50
- const lower = w.toLowerCase();
51
- if (lower === "true") {
52
- out.push({ t: "bool", v: true });
53
- }
54
- else if (lower === "false") {
55
- out.push({ t: "bool", v: false });
56
- }
57
- else if (lower === "null") {
58
- out.push({ t: "null" });
42
+ else {
43
+ const op = OPS.find((o) => src.startsWith(o, i));
44
+ if (op !== undefined) {
45
+ out.push({ t: "op", v: op });
46
+ i += op.length;
59
47
  }
60
48
  else {
61
- out.push({ t: "path", v: w });
49
+ const word = /^[A-Za-z_][A-Za-z0-9_.\-]*/.exec(src.slice(i));
50
+ if (word !== null) {
51
+ const w = word[0];
52
+ i += w.length;
53
+ const lower = w.toLowerCase();
54
+ if (lower === "true") {
55
+ out.push({ t: "bool", v: true });
56
+ }
57
+ else if (lower === "false") {
58
+ out.push({ t: "bool", v: false });
59
+ }
60
+ else if (lower === "null") {
61
+ out.push({ t: "null" });
62
+ }
63
+ else {
64
+ out.push({ t: "path", v: w });
65
+ }
66
+ }
67
+ else {
68
+ const num = /^-?\d+(\.\d+)?/.exec(src.slice(i));
69
+ if (num !== null) {
70
+ out.push({ t: "num", v: Number(num[0]) });
71
+ i += num[0].length;
72
+ }
73
+ else {
74
+ return null; // a character we have no token for
75
+ }
76
+ }
62
77
  }
63
- continue;
64
- }
65
- const num = /^-?\d+(\.\d+)?/.exec(src.slice(i));
66
- if (num != null) {
67
- out.push({ t: "num", v: Number(num[0]) });
68
- i += num[0].length;
69
- continue;
70
78
  }
71
- return null; // a character we have no token for
72
79
  }
73
80
  return out;
74
81
  }
@@ -9,7 +9,7 @@ import { prScope } from "./prScope.js";
9
9
  * `needs.*`.
10
10
  */
11
11
  export function evalIf(cond, scope = {}) {
12
- if (cond == null) {
12
+ if (cond === null || cond === undefined) {
13
13
  return "run";
14
14
  }
15
15
  const verdict = evaluate(String(cond), prScope(scope));
@@ -37,15 +37,15 @@ function inputValue(raw, scope) {
37
37
  /** The `on.workflow_call.inputs` block, tolerating the YAML 1.1 `on` -> true key. */
38
38
  function workflowCallInputs(wf) {
39
39
  const on = wf?.["on"] ?? wf?.["true"];
40
- if (on == null || typeof on !== "object") {
40
+ if (on === null || typeof on !== "object") {
41
41
  return {};
42
42
  }
43
43
  const call = on["workflow_call"];
44
- if (call == null || typeof call !== "object") {
44
+ if (call === null || call === undefined) {
45
45
  return {};
46
46
  }
47
47
  const inputs = call["inputs"];
48
- return inputs != null && typeof inputs === "object" ? inputs : {};
48
+ return inputs !== null && typeof inputs === "object" ? inputs : {};
49
49
  }
50
50
  /**
51
51
  * What `inputs.*` resolves to inside a called workflow: what the caller passed,
@@ -60,12 +60,12 @@ function calleeInputs(withBlock, subWf, scope) {
60
60
  const out = {};
61
61
  for (const [name, decl] of Object.entries(workflowCallInputs(subWf))) {
62
62
  out[name] =
63
- decl != null && typeof decl === "object" && "default" in decl
63
+ decl !== null && typeof decl === "object" && "default" in decl
64
64
  ? // Defaults live in the callee, out of the caller's context's reach.
65
65
  inputValue(decl["default"], {})
66
66
  : UNKNOWN;
67
67
  }
68
- if (withBlock != null && typeof withBlock === "object") {
68
+ if (withBlock !== null && typeof withBlock === "object") {
69
69
  for (const [name, raw] of Object.entries(withBlock)) {
70
70
  out[name] = inputValue(raw, scope);
71
71
  }
@@ -108,7 +108,7 @@ export async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = ""
108
108
  // verdict the main loop applies.
109
109
  let scoped = scope;
110
110
  const execFailures = {};
111
- if (executor != null) {
111
+ if (executor !== undefined) {
112
112
  const needed = neededJobIds(jobs);
113
113
  for (const [jobId, jobRaw] of Object.entries(jobs)) {
114
114
  const job = jobRaw ?? {};
@@ -127,12 +127,12 @@ export async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = ""
127
127
  }
128
128
  const execNote = (needs) => {
129
129
  const failed = needs.find((n) => n in execFailures);
130
- return failed == null ? "" : `; executing '${failed}' failed: ${execFailures[failed]}`;
130
+ return failed === undefined ? "" : `; executing '${failed}' failed: ${execFailures[failed]}`;
131
131
  };
132
132
  for (const [jobId, jobRaw] of Object.entries(jobs)) {
133
133
  const job = jobRaw ?? {};
134
134
  let status = evalIf(job.if, scoped);
135
- let reason = job.if != null ? `if: ${JSON.stringify(job.if)}` : "";
135
+ let reason = job.if !== undefined && job.if !== null ? `if: ${JSON.stringify(job.if)}` : "";
136
136
  let needs = job.needs ?? [];
137
137
  if (typeof needs === "string") {
138
138
  needs = [needs];
@@ -162,113 +162,122 @@ export async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = ""
162
162
  status,
163
163
  reason,
164
164
  });
165
- continue;
166
165
  }
167
- const combos = expandMatrixDetailed(job.strategy, prScope(scoped));
168
- if ("uses" in job) {
166
+ else if ("uses" in job) {
169
167
  // Reusable workflow call. The calling job produces no check of its own;
170
168
  // each called job becomes `<calling job name> / <called job name>`, and
171
169
  // a matrix on the *caller* multiplies the whole callee set. A cross-repo
172
170
  // call names its checks exactly the same way a local one does — probe
173
171
  // PR #9, `call-remote-tag / r-inner` alongside `call-plain / inner`.
172
+ const combos = expandMatrixDetailed(job.strategy, prScope(scoped));
174
173
  const uses = job.uses;
175
- if (combos == null) {
174
+ if (combos === null) {
176
175
  entries.push({
177
176
  job: prefix + jobId,
178
177
  checkName: null,
179
178
  status: "unknown",
180
179
  reason: "dynamic matrix on reusable workflow call" + execNote(needs),
181
180
  });
182
- continue;
183
- }
184
- // Resolve the called workflow once, not once per matrix combination.
185
- let subWf = null;
186
- let failure = null;
187
- // Where the callee's own `./` calls will resolve. A remote `uses:` moves
188
- // this to the callee's repo and pinned ref; a local one leaves it alone.
189
- let subSource = source;
190
- // What `inputs.*` means on the other side of the call.
191
- let subScope = {};
192
- const target = parseUses(uses);
193
- if (depth + 1 > MAX_REUSABLE_DEPTH) {
194
- failure = `reusable workflow nested deeper than ${MAX_REUSABLE_DEPTH} levels`;
195
- }
196
- else if (target == null) {
197
- failure = `unresolvable reusable reference: ${uses}`;
198
181
  }
199
182
  else {
200
- // A local `./` call stays on the caller's source, which is already
201
- // pinned to a commit. A cross-repo one arrives as whatever the `uses:`
202
- // string spelled — `@v0` — and has to be resolved before anything is
203
- // read from it, so the file that gets read and the commit the
204
- // prediction names are the same one.
205
- let resolved = source;
206
- if (target.source != null) {
207
- const { ref } = target.source;
208
- const sha = isSha(ref) ? ref : await reader.resolveRef(target.source);
209
- resolved = sha == null ? null : { ...target.source, sha };
183
+ // Resolve the called workflow once, not once per matrix combination.
184
+ let subWf = null;
185
+ let failure = null;
186
+ // Where the callee's own `./` calls will resolve. A remote `uses:`
187
+ // moves this to the callee's repo and pinned ref; a local one leaves
188
+ // it alone.
189
+ let subSource = source;
190
+ // What `inputs.*` means on the other side of the call.
191
+ let subScope = {};
192
+ const target = parseUses(uses);
193
+ if (depth + 1 > MAX_REUSABLE_DEPTH) {
194
+ failure = `reusable workflow nested deeper than ${MAX_REUSABLE_DEPTH} levels`;
210
195
  }
211
- if (resolved == null) {
212
- failure = `cannot resolve ref for ${uses}`;
196
+ else if (target === null) {
197
+ failure = `unresolvable reusable reference: ${uses}`;
213
198
  }
214
199
  else {
215
- subSource = resolved;
216
- const content = await reader.fetchWorkflow(target.path, subSource);
217
- if (content == null) {
218
- failure = `cannot fetch ${uses}`;
200
+ // A local `./` call stays on the caller's source, which is already
201
+ // pinned to a commit. A cross-repo one arrives as whatever the
202
+ // `uses:` string spelled — `@v0` — and has to be resolved before
203
+ // anything is read from it, so the file that gets read and the
204
+ // commit the prediction names are the same one.
205
+ let resolved = source;
206
+ if (target.source !== null) {
207
+ const { ref } = target.source;
208
+ const sha = isSha(ref) ? ref : await reader.resolveRef(target.source);
209
+ resolved = sha === null ? null : { ...target.source, sha };
210
+ }
211
+ if (resolved === null) {
212
+ failure = `cannot resolve ref for ${uses}`;
219
213
  }
220
214
  else {
221
- try {
222
- subWf = parseYaml(content);
223
- // `inputs.*` changes at the call boundary; `github.*` does not.
224
- // A callee's jobs run in the caller's repo, so the facts seeded
225
- // at the top of the prediction stay true all the way down.
226
- subScope = {
227
- inputs: calleeInputs(job.with, subWf ?? {}, scoped),
228
- github: scoped.github,
229
- };
215
+ subSource = resolved;
216
+ const content = await reader.fetchWorkflow(target.path, subSource);
217
+ if (content === null) {
218
+ failure = `cannot fetch ${uses}`;
230
219
  }
231
- catch (e) {
232
- failure = `YAML parse error in ${uses}: ${e}`;
220
+ else {
221
+ try {
222
+ const parsed = parseYaml(content);
223
+ // `inputs.*` changes at the call boundary; `github.*` does
224
+ // not. A callee's jobs run in the caller's repo, so the facts
225
+ // seeded at the top of the prediction stay true all the way
226
+ // down.
227
+ subScope = {
228
+ inputs: calleeInputs(job.with, parsed ?? {}, scoped),
229
+ github: scoped.github,
230
+ };
231
+ // Assigned last, so a throw above leaves it null and the one
232
+ // check below covers every way the call failed to resolve.
233
+ subWf = parsed;
234
+ }
235
+ catch (e) {
236
+ failure = `YAML parse error in ${uses}: ${e}`;
237
+ }
233
238
  }
234
239
  }
235
240
  }
241
+ for (const combo of combos) {
242
+ const disp = jobDisplayName(jobId, job, combo);
243
+ const baseName = prefix + disp.name;
244
+ const nameResolved = prefixResolved && disp.resolved;
245
+ if (subWf === null) {
246
+ entries.push({
247
+ job: baseName,
248
+ checkName: null,
249
+ status: "unknown",
250
+ reason: failure ?? `cannot resolve ${uses}`,
251
+ });
252
+ }
253
+ else {
254
+ entries.push(...(await expandJobs(subWf, ctx, reader, subSource, depth + 1, `${baseName} / `, nameResolved, subScope, executor)));
255
+ }
256
+ }
257
+ }
258
+ }
259
+ else {
260
+ const combos = expandMatrixDetailed(job.strategy, prScope(scoped));
261
+ if (combos === null) {
262
+ entries.push({
263
+ job: prefix + jobId,
264
+ checkName: null,
265
+ status: "unknown",
266
+ reason: "dynamic matrix" + execNote(needs),
267
+ });
236
268
  }
237
- for (const combo of combos) {
238
- const disp = jobDisplayName(jobId, job, combo);
239
- const baseName = prefix + disp.name;
240
- const nameResolved = prefixResolved && disp.resolved;
241
- if (failure != null || subWf == null) {
269
+ else {
270
+ for (const combo of combos) {
271
+ const disp = jobDisplayName(jobId, job, combo);
272
+ const name = prefix + disp.name;
242
273
  entries.push({
243
- job: baseName,
244
- checkName: null,
245
- status: "unknown",
246
- reason: failure ?? `cannot resolve ${uses}`,
274
+ job: name,
275
+ checkName: prefixResolved && disp.resolved ? name : null,
276
+ status,
277
+ reason,
247
278
  });
248
- continue;
249
279
  }
250
- entries.push(...(await expandJobs(subWf, ctx, reader, subSource, depth + 1, `${baseName} / `, nameResolved, subScope, executor)));
251
280
  }
252
- continue;
253
- }
254
- if (combos == null) {
255
- entries.push({
256
- job: prefix + jobId,
257
- checkName: null,
258
- status: "unknown",
259
- reason: "dynamic matrix" + execNote(needs),
260
- });
261
- continue;
262
- }
263
- for (const combo of combos) {
264
- const disp = jobDisplayName(jobId, job, combo);
265
- const name = prefix + disp.name;
266
- entries.push({
267
- job: name,
268
- checkName: prefixResolved && disp.resolved ? name : null,
269
- status,
270
- reason,
271
- });
272
281
  }
273
282
  }
274
283
  return entries;
@@ -2,5 +2,5 @@ import { expandMatrixDetailed } from "./expandMatrixDetailed.js";
2
2
  /** Return list of matrix combination dicts, or null if dynamic. */
3
3
  export function expandMatrix(strategy, scope = {}) {
4
4
  const detailed = expandMatrixDetailed(strategy, scope);
5
- return detailed == null ? null : detailed.map((c) => (c == null ? null : c.values));
5
+ return detailed === null ? null : detailed.map((c) => (c === null ? null : c.values));
6
6
  }
@@ -43,7 +43,7 @@ function comboList(v, scope) {
43
43
  }
44
44
  export function expandMatrixDetailed(strategy, scope = {}) {
45
45
  const matrix = strategy?.matrix;
46
- if (matrix == null) {
46
+ if (matrix === null || matrix === undefined) {
47
47
  return [null];
48
48
  }
49
49
  // `matrix: ${{ ... }}` — the whole matrix as one expression, rather than the
@@ -59,14 +59,13 @@ export function expandMatrixDetailed(strategy, scope = {}) {
59
59
  }
60
60
  const axes = {};
61
61
  for (const [k, v] of Object.entries(matrix)) {
62
- if (k === "include" || k === "exclude") {
63
- continue;
64
- }
65
- const vals = axisValues(v, scope);
66
- if (vals == null) {
67
- return null;
62
+ if (k !== "include" && k !== "exclude") {
63
+ const vals = axisValues(v, scope);
64
+ if (vals === null) {
65
+ return null;
66
+ }
67
+ axes[k] = vals;
68
68
  }
69
- axes[k] = vals;
70
69
  }
71
70
  const axisKeys = Object.keys(axes);
72
71
  let combos = [{ values: {}, displayKeys: axisKeys }];
@@ -6,7 +6,7 @@
6
6
  * `m-object (linux, x64)`.
7
7
  */
8
8
  export function formatMatrixValue(v) {
9
- if (v == null) {
9
+ if (v === null || v === undefined) {
10
10
  return "";
11
11
  }
12
12
  if (Array.isArray(v)) {
@@ -16,7 +16,7 @@ import { renderName } from "./renderName.js";
16
16
  export const EXPRESSION_RE = /\$\{\{/;
17
17
  /** The check name for one job/combination. */
18
18
  export function jobDisplayName(jobId, job, combo) {
19
- const raw = job != null && job.name != null ? String(job.name) : null;
19
+ const raw = job.name !== undefined && job.name !== null ? String(job.name) : null;
20
20
  if (raw === null) {
21
21
  return { name: capDisplayName(jobId + (combo ? matrixSuffix(combo) : "")), resolved: true };
22
22
  }
@@ -12,6 +12,6 @@ import { capDisplayName } from "./capDisplayName.js";
12
12
  * caller, with no `/ <callee job>` entries.
13
13
  */
14
14
  export function skippedDisplayName(jobId, job) {
15
- const raw = job != null && job.name != null ? String(job.name) : null;
15
+ const raw = job.name !== undefined && job.name !== null ? String(job.name) : null;
16
16
  return { name: capDisplayName(raw ?? jobId), resolved: true };
17
17
  }
@@ -22,13 +22,13 @@ export function workflowDispatches(wf, ctx) {
22
22
  }
23
23
  const branchRef = ctx.stackTarget ?? ctx.baseRef;
24
24
  if ("branches" in trig && !matchFilters(branchRef, trig["branches"])) {
25
- const label = ctx.stackTarget == null ? "base branch" : "stack target";
25
+ const label = ctx.stackTarget === undefined ? "base branch" : "stack target";
26
26
  return [false, `${label} '${branchRef}' not in branches`];
27
27
  }
28
28
  if ("branches-ignore" in trig && matchFilters(branchRef, trig["branches-ignore"])) {
29
29
  return [
30
30
  false,
31
- ctx.stackTarget == null
31
+ ctx.stackTarget === undefined
32
32
  ? "base branch in branches-ignore"
33
33
  : `stack target '${branchRef}' in branches-ignore`,
34
34
  ];
package/dist/verify.js CHANGED
@@ -50,9 +50,9 @@ const { entries: predictedRaw } = await predict(octokit, repo, pr);
50
50
  // no key to compare and are reported separately below.
51
51
  const predicted = new Map(predictedRaw
52
52
  .filter(isJobEntry)
53
- .filter((r) => r.checkName != null)
53
+ .filter((r) => r.checkName !== null)
54
54
  .map((r) => [`${r.workflow} :: ${r.checkName}`, r.status]));
55
- const unresolved = predictedRaw.filter(isJobEntry).filter((r) => r.checkName == null);
55
+ const unresolved = predictedRaw.filter(isJobEntry).filter((r) => r.checkName === null);
56
56
  const unknownWfs = new Set(predictedRaw
57
57
  .filter((r) => r.status === "unknown")
58
58
  .map((r) => r.workflow)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "willfire",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "description": "Predict the set of CI check entries GitHub Actions will create for a pull request",
5
5
  "license": "MIT",
6
6
  "packageManager": "pnpm@10.33.0",