willfire 0.1.9 → 0.1.11

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/predict.js CHANGED
@@ -12,7 +12,8 @@
12
12
  // src/names.test.ts.
13
13
  import { Octokit } from "@octokit/rest";
14
14
  import { parse as parseYaml } from "yaml";
15
- import { evaluate, UNKNOWN } from "./expr.js";
15
+ import { makeExecutor, makeTreeProvider, parseGrant, runShell, } from "./execute.js";
16
+ import { evaluate, evaluateValue, UNKNOWN } from "./expr.js";
16
17
  /** Tag a job display name. Rejects the workflow-level sentinel. */
17
18
  export const jobName = (name) => name;
18
19
  /** Narrow to the workflow-level variant without inspecting the sentinel. */
@@ -126,12 +127,34 @@ function workflowDispatches(wf, ctx) {
126
127
  }
127
128
  return [true, "trigger matched"];
128
129
  }
129
- function expandMatrixDetailed(strategy) {
130
+ /**
131
+ * The values of one matrix axis, or null when they cannot be known.
132
+ *
133
+ * A plain list is itself. An axis written as an expression —
134
+ * `language: ${{ fromJSON(needs.detect.outputs.coverage_languages) }}` — is
135
+ * the values another job computed, and is knowable exactly when the scope
136
+ * carries that job's outputs. Anything else stays null, which is what makes
137
+ * the whole job `unknown` rather than a guess at how many checks it creates.
138
+ */
139
+ function axisValues(v, scope) {
140
+ if (Array.isArray(v))
141
+ return v;
142
+ if (typeof v !== "string")
143
+ return null;
144
+ const val = evaluateValue(v, scope);
145
+ if (val.kind !== "json" || !Array.isArray(val.v))
146
+ return null;
147
+ return val.v;
148
+ }
149
+ function expandMatrixDetailed(strategy, scope = {}) {
130
150
  const matrix = strategy?.matrix;
131
151
  if (matrix == null)
132
152
  return [null];
153
+ // `matrix: ${{ ... }}` — the whole matrix as one expression, rather than the
154
+ // per-axis form below. It yields include-style entries, not axes, so it is a
155
+ // separate expansion and is not modelled.
133
156
  if (typeof matrix === "string")
134
- return null; // ${{ fromJSON(...) }}
157
+ return null;
135
158
  const include = matrix.include ?? [];
136
159
  const exclude = matrix.exclude ?? [];
137
160
  if (typeof include === "string" || typeof exclude === "string")
@@ -140,9 +163,10 @@ function expandMatrixDetailed(strategy) {
140
163
  for (const [k, v] of Object.entries(matrix)) {
141
164
  if (k === "include" || k === "exclude")
142
165
  continue;
143
- if (!Array.isArray(v))
166
+ const vals = axisValues(v, scope);
167
+ if (vals == null)
144
168
  return null;
145
- axes[k] = v;
169
+ axes[k] = vals;
146
170
  }
147
171
  const axisKeys = Object.keys(axes);
148
172
  let combos = [{ values: {}, displayKeys: axisKeys }];
@@ -177,8 +201,8 @@ function expandMatrixDetailed(strategy) {
177
201
  return combos;
178
202
  }
179
203
  /** Return list of matrix combination dicts, or null if dynamic. */
180
- export function expandMatrix(strategy) {
181
- const detailed = expandMatrixDetailed(strategy);
204
+ export function expandMatrix(strategy, scope = {}) {
205
+ const detailed = expandMatrixDetailed(strategy, scope);
182
206
  return detailed == null ? null : detailed.map((c) => (c == null ? null : c.values));
183
207
  }
184
208
  /**
@@ -283,20 +307,30 @@ function skippedDisplayName(jobId, job) {
283
307
  * resolve instead of hanging the job on an unknown.
284
308
  */
285
309
  const PR_GITHUB_CONTEXT = { event_name: "pull_request" };
310
+ /**
311
+ * A scope with the fixed pull-request facts filled in.
312
+ *
313
+ * Every `${{ }}` this module evaluates — a job `if:`, a matrix axis — is
314
+ * evaluated for the same event, so they all get the same `github.*`. Anything
315
+ * the caller states wins; there is nothing here worth overriding, but a scope
316
+ * that silently ignored what it was handed would be the wrong shape.
317
+ */
318
+ const prScope = (scope) => ({
319
+ ...scope,
320
+ github: { ...PR_GITHUB_CONTEXT, ...scope.github },
321
+ });
286
322
  /**
287
323
  * Return run|skipped|unknown for a job-level `if:`.
288
324
  *
289
- * `scope` carries the inputs the calling workflow passed down. Without it a
290
- * reusable workflow's guards are all unknown, because every one of them is
291
- * written against `inputs.*`.
325
+ * `scope` carries the inputs the calling workflow passed down, and any job
326
+ * outputs the caller knows. Without it a reusable workflow's guards are all
327
+ * unknown, because every one of them is written against `inputs.*` or
328
+ * `needs.*`.
292
329
  */
293
330
  export function evalIf(cond, scope = {}) {
294
331
  if (cond == null)
295
332
  return "run";
296
- const verdict = evaluate(String(cond), {
297
- inputs: scope.inputs,
298
- github: { ...PR_GITHUB_CONTEXT, ...scope.github },
299
- });
333
+ const verdict = evaluate(String(cond), prScope(scope));
300
334
  if (verdict === null)
301
335
  return "unknown";
302
336
  return verdict ? "run" : "skipped";
@@ -363,6 +397,11 @@ function calleeInputs(withBlock, subWf) {
363
397
  * mixes the two is counted the same way.
364
398
  */
365
399
  const MAX_REUSABLE_DEPTH = 4;
400
+ /** `ref` is already a commit id, so resolving it is a no-op. */
401
+ const SHA_RE = /^[0-9a-f]{40}$/i;
402
+ const isSha = (ref) => SHA_RE.test(ref);
403
+ /** Identity of a source as written, before resolution. */
404
+ const sourceKey = (s) => `${s.owner}/${s.repo}@${s.ref}`;
366
405
  /**
367
406
  * Split a job-level `uses:` into the file it names and the repo it lives in.
368
407
  *
@@ -395,13 +434,43 @@ export function parseUses(uses) {
395
434
  return null;
396
435
  return { path, source: { owner, repo, ref } };
397
436
  }
398
- async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = "", prefixResolved = true, scope = {}) {
437
+ async function expandJobs(wf, ctx, reader, source, depth = 0, prefix = "", prefixResolved = true, scope = {}, executor) {
399
438
  const entries = [];
400
439
  const jobs = wf.jobs ?? {};
401
440
  const statuses = {};
441
+ // Execute what the caller granted, before anything reads `needs`. The
442
+ // grant names the repo the workflow *file* lives in, so a granted callee
443
+ // job fires here in the recursion, where `source` is that repo. The guard
444
+ // is the same `evalIf` the main loop applies — same scope, same verdict —
445
+ // so a job is executed exactly when it is predicted to run. A job that
446
+ // would not run is not executed; a job that fails to execute contributes
447
+ // nothing but its reason, which `execNote` threads into the entries that
448
+ // needed it.
449
+ let scoped = scope;
450
+ const execFailures = {};
451
+ if (executor != null) {
452
+ for (const [jobId, jobRaw] of Object.entries(jobs)) {
453
+ if (!executor.granted(source, jobId))
454
+ continue;
455
+ const job = jobRaw ?? {};
456
+ if (evalIf(job.if, scoped) !== "run")
457
+ continue;
458
+ const res = await executor.executeJob(jobId, job, wf, scoped);
459
+ if (res.ok) {
460
+ scoped = { ...scoped, needs: { ...scoped.needs, [jobId]: { outputs: res.outputs } } };
461
+ }
462
+ else {
463
+ execFailures[jobId] = res.reason;
464
+ }
465
+ }
466
+ }
467
+ const execNote = (needs) => {
468
+ const failed = needs.find((n) => n in execFailures);
469
+ return failed == null ? "" : `; executing '${failed}' failed: ${execFailures[failed]}`;
470
+ };
402
471
  for (const [jobId, jobRaw] of Object.entries(jobs)) {
403
472
  const job = jobRaw ?? {};
404
- let status = evalIf(job.if, scope);
473
+ let status = evalIf(job.if, scoped);
405
474
  let reason = job.if != null ? `if: ${JSON.stringify(job.if)}` : "";
406
475
  let needs = job.needs ?? [];
407
476
  if (typeof needs === "string")
@@ -433,7 +502,7 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
433
502
  });
434
503
  continue;
435
504
  }
436
- const combos = expandMatrixDetailed(job.strategy);
505
+ const combos = expandMatrixDetailed(job.strategy, prScope(scoped));
437
506
  if ("uses" in job) {
438
507
  // Reusable workflow call. The calling job produces no check of its own;
439
508
  // each called job becomes `<calling job name> / <called job name>`, and
@@ -446,7 +515,7 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
446
515
  job: prefix + jobId,
447
516
  checkName: null,
448
517
  status: "unknown",
449
- reason: "dynamic matrix on reusable workflow call",
518
+ reason: "dynamic matrix on reusable workflow call" + execNote(needs),
450
519
  });
451
520
  continue;
452
521
  }
@@ -466,18 +535,37 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
466
535
  failure = `unresolvable reusable reference: ${uses}`;
467
536
  }
468
537
  else {
469
- subSource = target.source ?? source;
470
- const content = await fetchWorkflow(target.path, subSource);
471
- if (content == null) {
472
- failure = `cannot fetch ${uses}`;
538
+ // A local `./` call stays on the caller's source, which is already
539
+ // pinned to a commit. A cross-repo one arrives as whatever the `uses:`
540
+ // string spelled `@v0` — and has to be resolved before anything is
541
+ // read from it, so the file that gets read and the commit the
542
+ // prediction names are the same one.
543
+ let resolved = source;
544
+ if (target.source != null) {
545
+ const { ref } = target.source;
546
+ const sha = isSha(ref) ? ref : await reader.resolveRef(target.source);
547
+ resolved = sha == null ? null : { ...target.source, sha };
548
+ }
549
+ if (resolved == null) {
550
+ failure = `cannot resolve ref for ${uses}`;
473
551
  }
474
552
  else {
475
- try {
476
- subWf = parseYaml(content);
477
- subScope = { inputs: calleeInputs(job.with, subWf ?? {}) };
553
+ subSource = resolved;
554
+ const content = await reader.fetchWorkflow(target.path, subSource);
555
+ if (content == null) {
556
+ failure = `cannot fetch ${uses}`;
478
557
  }
479
- catch (e) {
480
- failure = `YAML parse error in ${uses}: ${e}`;
558
+ else {
559
+ try {
560
+ subWf = parseYaml(content);
561
+ // `inputs.*` changes at the call boundary; `github.*` does not.
562
+ // A callee's jobs run in the caller's repo, so the facts seeded
563
+ // at the top of the prediction stay true all the way down.
564
+ subScope = { inputs: calleeInputs(job.with, subWf ?? {}), github: scoped.github };
565
+ }
566
+ catch (e) {
567
+ failure = `YAML parse error in ${uses}: ${e}`;
568
+ }
481
569
  }
482
570
  }
483
571
  }
@@ -494,7 +582,7 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
494
582
  });
495
583
  continue;
496
584
  }
497
- entries.push(...(await expandJobs(subWf, ctx, fetchWorkflow, subSource, depth + 1, `${baseName} / `, nameResolved, subScope)));
585
+ entries.push(...(await expandJobs(subWf, ctx, reader, subSource, depth + 1, `${baseName} / `, nameResolved, subScope, executor)));
498
586
  }
499
587
  continue;
500
588
  }
@@ -503,7 +591,7 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
503
591
  job: prefix + jobId,
504
592
  checkName: null,
505
593
  status: "unknown",
506
- reason: "dynamic matrix",
594
+ reason: "dynamic matrix" + execNote(needs),
507
595
  });
508
596
  continue;
509
597
  }
@@ -524,9 +612,14 @@ async function expandJobs(wf, ctx, fetchWorkflow, source, depth = 0, prefix = ""
524
612
  * Expand one already-parsed workflow into its job entries. Exported so check
525
613
  * names can be tested against recorded GitHub behaviour without a network
526
614
  * round-trip; `predict` is the API you want.
615
+ *
616
+ * `scope` seeds what this workflow's own `${{ }}` resolve against — notably
617
+ * `needs`, the outputs of jobs that have not run. Nothing here works out what
618
+ * those are; a caller that knows hands them in, and a caller that does not
619
+ * leaves them out and gets `unknown` where they would have been used.
527
620
  */
528
- export function expandWorkflowJobs(wf, ctx, fetchWorkflow, source) {
529
- return expandJobs(wf, ctx, fetchWorkflow, source);
621
+ export function expandWorkflowJobs(wf, ctx, reader, source, scope = {}, executor) {
622
+ return expandJobs(wf, ctx, reader, source, 0, "", true, scope, executor);
530
623
  }
531
624
  // ------------------------------------------------------------------- pipeline
532
625
  export function makeOctokit() {
@@ -552,22 +645,60 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
552
645
  files: files.map((f) => f.filename),
553
646
  };
554
647
  const headSha = pr.head.sha;
648
+ /**
649
+ * The PR's own repo at the head commit — where expansion starts, and already
650
+ * a commit id, so its `ref` and `sha` are the same string.
651
+ */
652
+ const headSource = { owner, repo: name, ref: headSha, sha: headSha };
653
+ // Provenance for the answer, filled as expansion reaches each source. The head
654
+ // is in from the start: it is read even on the skip path, where the commit
655
+ // message is what decides the verdict.
656
+ const sources = new Map([[sourceKey(headSource), headSource]]);
555
657
  const { data: headCommit } = await octokit.rest.repos.getCommit({
556
658
  ...base,
557
659
  ref: headSha,
558
660
  });
559
661
  const headMsg = headCommit.commit.message;
560
662
  if (SKIP_RE.test(headMsg) || SKIP_TRAILER_RE.test(headMsg)) {
561
- return finalizePrediction([], "head commit message contains a skip instruction");
663
+ return finalizePrediction([], "head commit message contains a skip instruction", sources);
562
664
  }
563
- /** The PR's own repo at the head commit where expansion starts. */
564
- const headSource = { owner, repo: name, ref: headSha };
665
+ // A `uses:` naming a tag is the same lookup from every caller that writes it,
666
+ // so resolve each `owner/repo@ref` once. Misses are cached too: a ref that
667
+ // cannot be resolved will not start resolving on the second ask.
668
+ const refCache = new Map();
669
+ const resolveRef = async (src) => {
670
+ const key = sourceKey(src);
671
+ const hit = refCache.get(key);
672
+ if (hit !== undefined)
673
+ return hit;
674
+ let sha;
675
+ try {
676
+ const { data } = await octokit.rest.repos.getCommit({
677
+ owner: src.owner,
678
+ repo: src.repo,
679
+ ref: src.ref,
680
+ });
681
+ sha = data.sha;
682
+ }
683
+ catch {
684
+ // Deleted tag, private repo, rate limit, network: all one answer here.
685
+ // The caller turns it into an `unknown` entry rather than throwing.
686
+ sha = null;
687
+ }
688
+ refCache.set(key, sha);
689
+ if (sha != null)
690
+ sources.set(key, { ...src, sha });
691
+ return sha;
692
+ };
565
693
  // One callee is commonly reached from several callers — a fleet repo calls
566
694
  // the same `testing-conventions@v0` from eight workflows — so remember what
567
- // each `owner/repo/path@ref` resolved to, misses included.
695
+ // each `owner/repo/path@sha` resolved to, misses included.
568
696
  const cache = new Map();
569
697
  const fetchWorkflow = async (path, src) => {
570
- const key = `${src.owner}/${src.repo}/${path}@${src.ref}`;
698
+ // Keyed and fetched on the commit, never the ref that named it. Two callers
699
+ // writing `@v0` and `@abc123` for the same commit are one read, and a tag
700
+ // that moves mid-prediction cannot hand back two different files.
701
+ const key = `${src.owner}/${src.repo}/${path}@${src.sha}`;
571
702
  const hit = cache.get(key);
572
703
  if (hit !== undefined)
573
704
  return hit;
@@ -577,7 +708,7 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
577
708
  owner: src.owner,
578
709
  repo: src.repo,
579
710
  path,
580
- ref: src.ref,
711
+ ref: src.sha,
581
712
  mediaType: { format: "raw" },
582
713
  });
583
714
  content = data;
@@ -590,10 +721,48 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
590
721
  cache.set(key, content);
591
722
  return content;
592
723
  };
724
+ const reader = { fetchWorkflow, resolveRef };
725
+ // The executor exists only when the caller granted something. Trees come
726
+ // from the tarball endpoint at the resolved commit, and every subprocess —
727
+ // `tar` included — goes through the one `runShell` seam.
728
+ let executor;
729
+ if (opts.execute != null && opts.execute.length > 0) {
730
+ const download = async (src) => {
731
+ try {
732
+ const { data } = await octokit.rest.repos.downloadTarballArchive({
733
+ owner: src.owner,
734
+ repo: src.repo,
735
+ ref: src.sha,
736
+ });
737
+ return new Uint8Array(data);
738
+ }
739
+ catch {
740
+ // Private, deleted, rate limit, network: one answer, and the entries
741
+ // behind it stay unresolved with the failure named.
742
+ return null;
743
+ }
744
+ };
745
+ executor = makeExecutor({
746
+ grants: opts.execute,
747
+ workspace: headSource,
748
+ deps: {
749
+ provideTree: makeTreeProvider(download, runShell),
750
+ runCommand: runShell,
751
+ resolveRef,
752
+ },
753
+ });
754
+ }
593
755
  const workflows = await octokit.paginate(octokit.rest.actions.listRepoWorkflows, {
594
756
  ...base,
595
757
  per_page: 100,
596
758
  });
759
+ // `github.repository` is fixed for everything predicted here: reusable
760
+ // workflows and composite actions all run in the repo the PR is against.
761
+ // Seeding it once makes guards like the fleet's hermetic-vs-published
762
+ // `github.repository ==` checks decidable everywhere, granted or not.
763
+ const prFacts = {
764
+ github: { repository: `${headSource.owner}/${headSource.repo}` },
765
+ };
597
766
  const entries = [];
598
767
  for (const w of workflows) {
599
768
  const path = w.path;
@@ -642,7 +811,7 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
642
811
  entries.push({ workflow: path, job: "*", status: "no-dispatch", reason });
643
812
  continue;
644
813
  }
645
- for (const j of await expandJobs(wf, ctx, fetchWorkflow, headSource)) {
814
+ for (const j of await expandJobs(wf, ctx, reader, headSource, 0, "", true, prFacts, executor)) {
646
815
  entries.push({
647
816
  workflow: path,
648
817
  job: jobName(j.job),
@@ -652,19 +821,25 @@ export async function predict(octokit, repo, prNumber, opts = {}) {
652
821
  });
653
822
  }
654
823
  }
655
- return finalizePrediction(entries, null);
824
+ return finalizePrediction(entries, null, sources);
656
825
  }
657
- function finalizePrediction(entries, skip) {
826
+ function finalizePrediction(entries, skip, sources) {
658
827
  const final = entries.map(finalize);
659
828
  const names = new Set();
660
829
  for (const e of final) {
661
830
  if (e.status === "run" && e.checkName != null)
662
831
  names.add(e.checkName);
663
832
  }
664
- return { entries: final, checkNames: [...names].sort(), skip };
833
+ return {
834
+ entries: final,
835
+ checkNames: [...names].sort(),
836
+ skip,
837
+ sources: [...sources.values()].sort((a, b) => sourceKey(a).localeCompare(sourceKey(b))),
838
+ };
665
839
  }
666
840
  // ------------------------------------------------------------------------ CLI
667
- const USAGE = "usage: predict --repo owner/name --pr N [--action opened|synchronize|reopened] [--json]";
841
+ const USAGE = "usage: predict --repo owner/name --pr N [--action opened|synchronize|reopened]" +
842
+ " [--execute owner/repo:job1,job2]... [--json]";
668
843
  const isPrEventAction = (v) => v === "opened" || v === "synchronize" || v === "reopened";
669
844
  function parseArgs(argv) {
670
845
  const get = (flag) => {
@@ -686,28 +861,52 @@ function parseArgs(argv) {
686
861
  console.error(USAGE);
687
862
  process.exit(2);
688
863
  }
689
- return { repo, pr: Number(pr), json: argv.includes("--json"), action };
864
+ // Repeatable, one grant per flag. A malformed grant is refused for the same
865
+ // reason a bad --action is: silently dropping it would predict without the
866
+ // execution the caller thought they asked for.
867
+ const execute = [];
868
+ for (let i = 0; i < argv.length; i++) {
869
+ if (argv[i] !== "--execute")
870
+ continue;
871
+ const spec = argv[i + 1];
872
+ const grant = spec == null ? null : parseGrant(spec);
873
+ if (grant == null) {
874
+ console.error(`bad --execute: ${spec}`);
875
+ console.error(USAGE);
876
+ process.exit(2);
877
+ }
878
+ execute.push(grant);
879
+ }
880
+ return { repo, pr: Number(pr), json: argv.includes("--json"), action, execute };
690
881
  }
691
882
  const isMain = /predict\.(ts|js)$|\/willfire$/.test(process.argv[1] ?? "");
692
883
  if (isMain) {
693
884
  const args = parseArgs(process.argv.slice(2));
694
- const { entries, checkNames, skip } = await predict(makeOctokit(), args.repo, args.pr, {
885
+ const prediction = await predict(makeOctokit(), args.repo, args.pr, {
695
886
  action: args.action,
887
+ execute: args.execute,
696
888
  });
889
+ const { entries, skip, sources } = prediction;
697
890
  if (args.json) {
698
- console.log(JSON.stringify({ entries, checkNames, skip }, null, 2));
699
- }
700
- else if (skip) {
701
- console.log(`# ${skip} -> nothing dispatches`);
891
+ console.log(JSON.stringify(prediction, null, 2));
702
892
  }
703
893
  else {
704
- for (const e of entries) {
705
- if (isWorkflowEntry(e))
706
- console.log(`# ${e.workflow} :: ${e.status} (${e.reason})`);
707
- else {
708
- const name = e.checkName ?? `${e.job} (name unresolved)`;
709
- console.log(`${e.workflow} :: ${name} :: ${e.status}`);
894
+ if (skip) {
895
+ console.log(`# ${skip} -> nothing dispatches`);
896
+ }
897
+ else {
898
+ for (const e of entries) {
899
+ if (isWorkflowEntry(e))
900
+ console.log(`# ${e.workflow} :: ${e.status} (${e.reason})`);
901
+ else {
902
+ const name = e.checkName ?? `${e.job} (name unresolved)`;
903
+ console.log(`${e.workflow} :: ${name} :: ${e.status}`);
904
+ }
710
905
  }
711
906
  }
907
+ // Last, and on the skip path too, so a red gate's first question — which
908
+ // commits was this read from? — is answered wherever the reader lands.
909
+ for (const s of sources)
910
+ console.log(`# read ${s.owner}/${s.repo}@${s.ref} -> ${s.sha}`);
712
911
  }
713
912
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "willfire",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
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",