Repository navigation
Expand file tree
/
Copy pathvalidate.mjs
More file actions
2231 lines (2166 loc) · 257 KB
/
Copy pathvalidate.mjs
File metadata and controls
2231 lines (2166 loc) · 257 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env node
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
/**
* fleet-write/validate — the closed-schema gate every relay payload passes,
* on BOTH sides of the boundary.
*
* node scripts/pm/fleet-write/validate.mjs --file payload.json # judge a payload on disk
* node scripts/pm/fleet-write/validate.mjs --from-event # judge client_payload in $GITHUB_EVENT_PATH (the runner)
* node scripts/pm/fleet-write/validate.mjs --from-event --github-output # …and write repositories / request_id / action_count to $GITHUB_OUTPUT
* node scripts/pm/fleet-write/validate.mjs --self-test # offline, no network
*
* ## One validator, imported by both ends
*
* The seat-side packer (`dispatch.mjs`) refuses a payload here BEFORE the
* dispatch leaves, and the relay workflow's first step runs this same file
* against `github.event.client_payload` BEFORE any token is minted, reading it
* from the event file (`$GITHUB_EVENT_PATH`) — never from a step's `env:`,
* which GitHub prints into the public run log (`ops.mjs`, the advisory rows'
* section: a report's prose rides the payload and is printed nowhere). Two copies
* of a schema drift; one file cannot. The op table it reads is `ops.mjs`, so
* "is this op allowed, with these keys, at these caps" has one spelling.
*
* ## What "closed" means here, key by key
*
* - the envelope is exactly `request_id`, `repo`, `session`, `actions` —
* an unknown top-level key is a refusal, not an ignored extra;
* - `repo` is `objectstack-ai/<name>` — the App is installed on that one
* organization, and a target outside it is refused before any read;
* - `session` is `session_…` — the attribution the protocol carries INSIDE
* the text is also carried on the envelope, so the run's summary names it;
* - each action is exactly `op` + that op's required keys + a subset of its
* optional keys; an unknown per-action key is a refusal; a number is an
* integer (`"17"` is refused); a string is capped in BYTES; a list is
* capped in length and per item; an enum is one of its values; a sha is
* exactly 40 lowercase hex characters — a short one is refused HERE,
* before the dispatch, never left to the platform's 422;
* - the whole payload respects the platform's own `client_payload` limits
* (ten top-level properties, under 64KB — the header of `ops.mjs` quotes
* the source), so what this file accepts the platform accepts;
* - a stroke carrying a `transfer` carries transfers alone, to ONE target
* from `TRANSFER_TARGETS` that is not `repo`, each card once — so the
* token list below is `repo` alone for every other stroke and exactly
* `repo` + that target for a transfer stroke;
* - a stroke carrying a `workflow_dispatch` carries exactly that one action
* (the row's `alone`): its token carries `actions: write`, and that grant
* serves one request and nothing beside it. `workflow` is judged against
* the closed allowlist and `ref` against the one ref, as enums — an
* off-list file, a path, a glob or another ref is refused by name — and
* `inputs` against the file's closed entry (the row's `closedInputs`,
* `WORKFLOW_DISPATCH_INPUTS` in `ops.mjs`): exactly that object, every key
* and value, or no `inputs` key where the entry is `null`. A missing
* input, another value (`refresh_version_pr: false` on `release.yml`, the
* publish-repair lane) or any other input (`force`) is refused by name;
* - a stroke carrying a `run_rerun_failed` carries exactly that one action
* too (the row's `alone`), for the same reason: its token carries
* `actions: write`. Its one key is `run_id`, a positive integer; a
* workflow, an attempt, a job, an event or the platform's debug-logging
* flag beside it is refused by name. The five fences on the RUN and its
* pull — the workflow, the event, once, failed, the head
* (`RERUN_FAILED_FENCES` in `ops.mjs`) — are facts this file cannot see:
* the executor judges them on the run and the pull's own answer, read
* immediately before its POST;
* - a `sub_issue_add` or `sub_issue_remove` names two issue NUMBERS,
* `issue` the parent and `sub_issue` the child, each a positive integer,
* and never the same one twice (the row's `distinct`); a `sub_issue_id`,
* a `replace_parent` or any other key beside them is refused by name. The
* fences on the two CARDS — each an issue on the repository, the child
* free of any other parent (add) or listed by this one (remove) — are
* facts this file cannot see: the executor judges them on its own reads
* before the write (`SUB_ISSUE_FENCES` in `ops.mjs`). Both spend
* `issues: write`, so they ride any stroke with the other issue ops;
* - a stroke carrying `advisory_report`, `advisory_create`,
* `advisory_collaborators_add`, `advisory_versions` or `advisory_state`
* carries exactly that one action (each row's `alone`) — so a publish
* carries one advisory alone: its token carries
* `repository-advisories: write`. A report is `summary` and
* `description`, byte-capped, with an optional `severity` from GitHub's
* enum and `cwe_ids`, a capped list of `CWE-` ids; a draft is the same
* keys plus `packages`, a capped list of npm package names
* (`NPM_PACKAGE_SHAPE`), each one `vulnerabilities` entry; the other three
* name `ghsa_id` — one advisory's id, `GHSA_ID_SHAPE` exactly — and either
* `collaborators`, a capped list of logins; `versions`, a capped list of
* closed entries, each exactly an npm `package`, a
* `vulnerable_version_range` and its `patched_versions` in the one
* spelling `advisoryVersionEntryFailure` admits; or `advisory_state`,
* exactly `draft`, `closed` or `published` — `published` a value since the
* maintainer's ruling that the relay publishes, its move the executor's to
* fence. ⛔ A refusal names the key and the byte count and never echoes the
* value: a payload's text reaches a public run log, and a value in the
* wrong key may be an advisory's prose (`FIELDS`' `quiet`). Any other key —
* a `vulnerabilities`, a `state`, a `credits`, a `collaborating_users`, a
* `summary` beside the versions — is refused by name. The fences on the
* ADVISORY — readable, the one named, a state the action may act in, a
* list the versions may replace, versions set before a publish — are
* facts this file cannot see: the executor judges them on its own read
* before the PATCH (`ADVISORY_FENCES` in `ops.mjs`);
* - a stroke carrying `advisory_read` carries exactly that one action too,
* and its token carries `repository-advisories: read` — the row's `grant`
* — never write. It names `ghsa_id` and `public_key`, the seat's one-time
* X25519 key, its own kind: base64 SPKI DER of exactly 60 characters in
* its one spelling, a key a secret can be derived with
* (`advisoryReadKeyFailure` in `ops.mjs`), refused here before any
* request; a refusal names the key, the byte count and what the value
* failed to be, ⛔ never the value. Any other key — a `summary`, a
* `private_key`, a `state` — is refused by name. The fences on the READ —
* readable, the one named, a sender the gate admits — are the executor's,
* on its own GET (`ADVISORY_READ_FENCES` in `ops.mjs`);
* - a stroke carrying `advisory_list` carries exactly that one action too,
* and its token carries `repository-advisories: read`, its `grant`. It
* names `public_key` ALONE, judged as `advisory_read`'s: the caller names
* no state, no page, no cursor and no other query, so any other key — a
* `state`, a `per_page`, an `after`, a `ghsa_id` — is refused by name. Its
* walk, the cursor's fences, the projection and the pad are the
* executor's (`ops.mjs`, "The one row that LISTS advisories");
* - a `pr_create` may carry `assignees` and `labels`, the typed lists
* `assign` and `labels_add` take — judged here as every list is — and the
* op table turns each into a later request of the SAME action, sent by
* the executor at the number the create answered (`ops.mjs`, its header),
* so no separate assign or label write exists for anything to refuse.
*
* ## The token list and the token's grants — where a stroke widens either
*
* `--github-output` writes `repositories=<name>[,<name>]`, the list the
* workflow's mint step narrows the App token to (`tokenRepositoriesOf`): the
* payload's repository, plus the `secondRepo` a row declares — `transfer`'s
* target, the only such row. It also writes one `permission_<name>=` line per
* grant in `STROKE_SCOPED_PERMISSIONS` (`tokenPermissionsOf`): the level when
* an action in the stroke spends it, empty otherwise — `permission_actions=write`
* for a `workflow_dispatch` or `run_rerun_failed` stroke, `permission_actions=`
* for every other. So the second row rides the workflow's existing input and
* the workflow names neither op. The advisory rows' grant is the second such
* line, `permission_repository_advisories=write` for a stroke carrying one of
* them and empty for every other — and `permission_repository_advisories=read`
* for an `advisory_read` or `advisory_list` stroke, the two rows whose `grant`
* is lower than `PERMISSIONS` holds. The
* workflow reads those outputs and never the payload, and `--self-test` pins
* the list to ONE name for every other op, two for a transfer, the grant to
* those two rows alone, and the workflow lines that read both.
*
* A refusal is the WHOLE list of reasons, not the first one: a seat fixing a
* payload reads every problem in one pass, and the runner's step summary
* carries them all.
*
* ## Exit codes — capture them BEFORE any pipe
*
* 0 valid.
* 2 refused (the reasons are printed), or usage.
* 3 PREREQUISITE NOT MET — no payload to read (`--file` unreadable, the
* event file absent, unreadable, not JSON or carrying no
* `client_payload`). Nothing was judged.
*/
import { appendFileSync, existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { isEntrypoint } from '../../invoked-as.mjs';
import { GOVERNED_REPOS } from '../check-governed-merges.mjs';
import {
ALLOWED_MUTATIONS,
CLIENT_PAYLOAD_MAX_BYTES,
CLIENT_PAYLOAD_MAX_TOP_LEVEL,
EVENT_TYPE_MAX_CHARS,
FIELDS,
MAILBOX_BRANCH_SHAPE,
MAILBOX_LABEL,
MAX_ACTIONS,
MAX_REQUEST_ID_CHARS,
OPS,
OP_NAMES,
PAYLOAD_KEYS,
PERMISSIONS,
REFUSED_PATH_FAMILIES,
RELAY_EVENT_TYPE,
RELAY_FILES,
REQUEST_ID_SHAPE,
RERUN_FAILED_ATTEMPT,
RERUN_FAILED_CONCLUSION,
RERUN_FAILED_EVENT,
RERUN_FAILED_FENCES,
RERUN_FAILED_PATH_SHAPE,
RERUN_FAILED_RERUN_STATES,
RERUN_FAILED_STATUS,
RERUN_FAILED_WORKFLOWS,
SESSION_SHAPE,
SHA_SHAPE,
STROKE_SCOPED_PERMISSIONS,
SUB_ISSUE_FENCES,
SUB_ISSUE_MODES,
TARGET_OWNER,
ADVISORY_CREATE_ECOSYSTEM,
ADVISORY_CREATE_PATH_SHAPE,
ADVISORY_CREATED_STATE,
ADVISORY_FENCES,
ADVISORY_OPEN_STATES,
ADVISORY_PATH_SHAPE,
ADVISORY_PLATFORM_MESSAGES,
ADVISORY_REFUSED_STATES,
ADVISORY_REPORTS_PATH_SHAPE,
ADVISORY_SETTABLE_STATES,
ADVISORY_SEVERITIES,
ADVISORY_TRANSITIONS,
ADVISORY_PATCHED_VERSIONS_SHAPE,
ADVISORY_PUBLISHED_STATE,
ADVISORY_VERSION_KEYS,
ADVISORY_VERSION_RANGE_SHAPE,
ADVISORY_VERSIONS_STATES,
MAX_ADVISORY_PATCHED_VERSIONS,
MAX_ADVISORY_VERSION_ENTRIES,
advisoryVersionEntryFailure,
advisoryVersionsDiff,
advisoryVersionsLandedText,
advisoryVersionsOf,
advisoryVersionsOnTheWire,
advisoryVersionsReadBack,
compareReleaseVersions,
inAdvisoryRange,
advisoryRangeComparators,
CWE_ID_SHAPE,
GHSA_ID_SHAPE,
LOGIN_SHAPE,
MAX_ADVISORY_DESCRIPTION_BYTES,
MAX_ADVISORY_PACKAGES,
MAX_ADVISORY_SUMMARY_BYTES,
MAX_NPM_PACKAGE_CHARS,
NPM_PACKAGE_SHAPE,
ADVISORY_READ_FENCES,
ADVISORY_READ_FIELDS,
ADVISORY_READ_KEY_TYPE,
ADVISORY_READ_MAX_PARTS,
ADVISORY_READ_MAX_SEALED_CHARS,
ADVISORY_READ_OPEN_FAILED,
ADVISORY_READ_PART_CHARS,
ADVISORY_READ_PUBLIC_KEY_CHARS,
ADVISORY_READ_PUBLIC_KEY_SHAPE,
ADVISORY_READ_SEAL_OVERHEAD,
ADVISORY_READ_SEAL_VERSION,
ADVISORY_READ_SENDER_GATES,
ADVISORY_LIST_CAP_HIT,
ADVISORY_LIST_CURSOR_MAX_CHARS,
ADVISORY_LIST_CURSOR_REFUSED,
ADVISORY_LIST_CURSOR_SHAPE,
ADVISORY_LIST_FIELDS,
ADVISORY_LIST_LANDED,
ADVISORY_LIST_OVER_CAP,
ADVISORY_LIST_PADDED_BYTES,
ADVISORY_LIST_PAGE_CAP,
ADVISORY_LIST_PAGE_PATH_SHAPE,
ADVISORY_LIST_PAGE_SIZE,
ADVISORY_LIST_REPEATED,
ADVISORY_LIST_SEVERITIES,
ADVISORY_LIST_SHORT_PAGE_NEXT,
ADVISORY_LIST_TIMESTAMP_SHAPE,
ADVISORY_LIST_SENDER_REFUSED,
ADVISORY_LIST_STATES,
ADVISORY_LIST_UNREACHED,
advisoryListCall,
advisoryListDisplay,
advisoryListNextCursor,
advisoryListPadded,
advisoryListPageFailure,
advisoryListPagePath,
advisoryListPlaintext,
advisoryListProjection,
advisorySealAad,
advisoryReadAad,
advisoryReadCall,
advisoryReadFenceFailures,
advisoryReadFenceText,
advisoryReadKeyFailure,
advisoryReadLandedText,
advisoryReadOverCapText,
advisoryReadParts,
advisoryReadPlaintext,
openAdvisoryRead,
sealAdvisoryRead,
advisoryCollaboratorPlan,
advisoryCreateLandedText,
advisoryCreateReadBack,
advisoryFenceFailures,
advisoryFenceText,
advisoryLandedText,
advisoryPlatformText,
advisoryPrintable,
advisoryReadBack,
advisoryReportLandedText,
advisoryReportReadBack,
advisoryStateLandedText,
advisoryStateReadBack,
TARGET_REPO_SHAPE,
TRANSFER_TARGETS,
WORKFLOW_DISPATCH_ALLOWLIST,
WORKFLOW_DISPATCH_INPUTS,
WORKFLOW_DISPATCH_PATH_SHAPE,
WORKFLOW_DISPATCH_REF,
GOVERNED_REVIEW_OPS,
answerPlaceholder,
dispatchInputsOnTheWire,
failedJobNames,
governedReviewRequests,
rerunFailedFenceFailures,
rerunFailedFenceText,
rerunFailedLandedText,
rerunFailedPullNumber,
rerunFailedReadBack,
subIssueAlreadyHere,
subIssueCardFailure,
subIssueFenceFailures,
subIssueFenceText,
subIssueLandedText,
subIssueReadBack,
} from './ops.mjs';
const SELF_PATH = fileURLToPath(import.meta.url);
export const EXIT_OK = 0;
export const EXIT_REFUSED = 2;
export const EXIT_PREREQUISITE = 3;
/**
* The runner's path to the event that started the run — the payload is its `client_payload`. ⛔ Never a step's
* `env:`: GitHub prints a step's environment into the run log, and the repository is public (header).
*/
export const EVENT_PATH_ENV = 'GITHUB_EVENT_PATH';
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
const bytes = (s) => Buffer.byteLength(String(s), 'utf8');
const quote = (v) => JSON.stringify(v);
/** A value a `quiet` field refused, described by its type and size alone — ⛔ never echoed (`FIELDS` in `ops.mjs`). */
export const describeQuietly = (v) =>
typeof v === 'string' ? `a string of ${bytes(v)} byte(s)` : Array.isArray(v) ? `an array of ${v.length} item(s)` : v === null ? 'null' : isPlainObject(v) ? 'an object' : typeof v;
/**
* The payload in the event file the runner wrote — its `client_payload`, parsed. Throws a prerequisite sentence
* for a path that is absent, a file that cannot be read or parsed, or an event carrying no object there; the
* sentence never quotes the file's text. Shared with `execute.mjs`, so both read one way.
*/
export function readEventPayload(env = process.env, read = (p) => readFileSync(p, 'utf8')) {
const path = env[EVENT_PATH_ENV];
if (typeof path !== 'string' || path.trim() === '') throw new Error(`${EVENT_PATH_ENV} is absent or empty — the runner names the event file through it`);
let text;
try {
text = read(path);
} catch (e) {
throw new Error(`the event file ${EVENT_PATH_ENV} names cannot be read (${e?.code ?? 'unreadable'})`);
}
let event;
try {
event = JSON.parse(text);
} catch {
throw new Error(`the event file ${EVENT_PATH_ENV} names is not JSON`);
}
if (!isPlainObject(event) || !isPlainObject(event.client_payload)) throw new Error(`the event file ${EVENT_PATH_ENV} names carries no client_payload object — not a repository_dispatch?`);
return event.client_payload;
}
/** One field against its declared kind. Returns the error sentence, or null. */
export function validateField(name, value, spec, where) {
const at = `${where}.${name}`;
if (spec.kind === 'int') {
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) return `${at} must be a positive integer (a JSON number), got ${quote(value)}`;
return null;
}
if (spec.kind === 'str') {
if (typeof value !== 'string') return `${at} must be a string, got ${typeof value}`;
if (value.length === 0) return `${at} must not be empty`;
if (bytes(value) > spec.max) return `${at} is ${bytes(value)} bytes, over its cap of ${spec.max}`;
if (spec.shape && !spec.shape.test(value)) return `${at} must be ${spec.what}, got a string of ${value.length} character(s) that is not one — not echoed`;
return null;
}
if (spec.kind === 'list') {
if (!Array.isArray(value)) return `${at} must be an array of strings, got ${isPlainObject(value) ? 'an object' : typeof value}`;
if (value.length === 0) return `${at} must name at least one item`;
if (value.length > spec.max) return `${at} names ${value.length} items, over its cap of ${spec.max}`;
for (let i = 0; i < value.length; i++) {
const item = value[i];
// ⛔ A quiet list never echoes an item (`FIELDS`): it is described by its type and size.
if (typeof item !== 'string' || item.length === 0) return `${at}[${i}] must be a non-empty string, got ${spec.quiet ? describeQuietly(item) : quote(item)}`;
if (bytes(item) > spec.itemMax) return `${at}[${i}] is ${bytes(item)} bytes, over the per-item cap of ${spec.itemMax}`;
if (spec.shape && !spec.shape.test(item)) return `${at}[${i}] must be ${spec.itemWhat ?? `in the shape ${spec.shape}`}, got ${spec.quiet ? `${describeQuietly(item)} that is not one — not echoed` : quote(item)}`;
}
if (new Set(value).size !== value.length) return `${at} names an item twice`;
return null;
}
if (spec.kind === 'enum') {
if (spec.named && typeof value === 'string' && Object.hasOwn(spec.named, value)) return `${at} is ${quote(value)}, refused by name: ${spec.named[value]}`;
// ⛔ A quiet enum never echoes the value (`FIELDS`): it is described by its type and size.
if (!spec.values.includes(value)) return `${at} must be one of ${spec.values.map(quote).join(', ')}, got ${spec.quiet ? `${describeQuietly(value)} that is not one of them — not echoed` : quote(value)}`;
return null;
}
if (spec.kind === 'sha') {
if (typeof value !== 'string' || !SHA_SHAPE.test(value)) return `${at} must be the full 40-hex commit sha (lowercase), got ${quote(value)} — a short or hand-typed sha is refused here, before the dispatch, not by the platform's 422`;
return null;
}
if (spec.kind === 'ghsa') {
// ⛔ The value is never echoed: a payload's text reaches a public run log, and a refused id may be prose put in the wrong key.
if (typeof value !== 'string' || !GHSA_ID_SHAPE.test(value)) return `${at} must be one repository advisory's id — GHSA and three groups of four lowercase letters or digits — got ${typeof value === 'string' ? `a string of ${value.length} character(s)` : Array.isArray(value) ? 'an array' : value === null ? 'null' : typeof value} that is not one; it is spelled into a path, so it is refused here, before the dispatch, and never echoed`;
return null;
}
if (spec.kind === 'x25519') {
// ⛔ Never echoed: a refusal reaches a public run log, and a value in this key may be prose put in the wrong one.
const why = advisoryReadKeyFailure(value);
if (why) return `${at} must be one X25519 public key, base64 SPKI DER of exactly ${ADVISORY_READ_PUBLIC_KEY_CHARS} characters — got ${describeQuietly(value)} that ${why}; it is refused here, before any request, and never echoed`;
return null;
}
if (spec.kind === 'versions') {
// ⛔ Never echoed (`FIELDS`' `quiet`): before the publish a package and its versions are the unpublished advisory's.
if (!Array.isArray(value)) return `${at} must be an array of version entries, got ${describeQuietly(value)}`;
if (value.length === 0) return `${at} must name at least one entry`;
if (value.length > spec.max) return `${at} names ${value.length} entries, over its cap of ${spec.max}`;
for (let i = 0; i < value.length; i++) {
const why = advisoryVersionEntryFailure(value[i], `${at}[${i}]`);
if (why) return why;
}
const keys = value.map((v) => JSON.stringify(ADVISORY_VERSION_KEYS.map((k) => v[k])));
if (new Set(keys).size !== keys.length) return `${at} names an entry twice`;
return null;
}
if (spec.kind === 'inputs') {
if (!isPlainObject(value)) return `${at} must be a JSON object of input name → boolean, got ${Array.isArray(value) ? 'an array' : quote(value)}`;
const names = Object.keys(value);
if (names.length === 0) return `${at} must name at least one input — a workflow started with none carries no inputs key`;
for (const name of names) {
if (typeof value[name] !== 'boolean') return `${at}.${name} must be a JSON boolean, got ${quote(value[name])}`;
}
return null;
}
return `${at}: unknown field kind ${quote(spec.kind)} — a bug in ops.mjs, not in the payload`;
}
/** One action against the op table. Returns the errors for it (possibly none). */
export function validateAction(action, index) {
const where = `actions[${index}]`;
const errors = [];
if (!isPlainObject(action)) return [`${where} must be an object, got ${Array.isArray(action) ? 'an array' : typeof action}`];
const op = action.op;
if (typeof op !== 'string' || !(op in OPS)) {
return [`${where}.op ${quote(op ?? null)} is not an op the relay executes — the closed list is ${OP_NAMES.join(', ')}`];
}
const spec = OPS[op];
const allowed = new Set(['op', ...spec.required, ...spec.optional]);
for (const key of Object.keys(action)) {
if (!allowed.has(key)) errors.push(`${where}.${key} is not a key \`${op}\` takes (it takes ${[...spec.required, ...spec.optional].join(', ')})`);
}
for (const key of spec.required) {
if (!(key in action)) errors.push(`${where}.${key} is required by \`${op}\``);
}
if (spec.atLeastOne && !spec.atLeastOne.some((k) => k in action)) {
errors.push(`${where}: \`${op}\` needs at least one of ${spec.atLeastOne.join(', ')}`);
}
const refusedKeys = new Set();
for (const key of Object.keys(action)) {
if (key === 'op' || !allowed.has(key)) continue;
const e = validateField(key, action[key], FIELDS[key], where);
if (e) {
errors.push(e);
refusedKeys.add(key);
}
}
if (spec.closedInputs && !refusedKeys.has('inputs') && typeof action.workflow === 'string' && Object.hasOwn(spec.closedInputs, action.workflow)) {
errors.push(...closedInputsErrors(action, spec.closedInputs[action.workflow], where));
}
// Two keys the row needs DIFFERENT (`distinct`, ops.mjs): judged only on values the field kinds admitted.
if (spec.distinct) {
const [a, b] = spec.distinct;
if (a in action && b in action && !refusedKeys.has(a) && !refusedKeys.has(b) && action[a] === action[b]) {
errors.push(`${where}.${b} is ${quote(action[b])}, the same as ${a} — \`${op}\` links two different issues, and an issue is never its own sub-issue`);
}
}
return errors;
}
/**
* The action's `inputs` against its workflow's closed entry (`closedInputs`,
* `WORKFLOW_DISPATCH_INPUTS` in `ops.mjs`): `null` admits no `inputs` key; an
* object admits exactly itself — each missing input, each other value and
* each other input is a reason of its own. `validateAction` calls it only on
* an `inputs` the field kind admitted (or none), so a shape refused there is
* not judged twice. Pure.
*/
export function closedInputsErrors(action, declared, where) {
const workflow = action.workflow;
const has = 'inputs' in action;
if (declared === null) {
return has ? [`${where}.inputs: ${workflow} declares no inputs and the relay sends none — an action naming it carries no inputs key`] : [];
}
const exactly = `exactly ${quote(declared)}`;
if (!has) return [`${where}.inputs is required when workflow is ${quote(workflow)}: ${exactly} — the one lane the relay may start there`];
const sent = action.inputs;
const errors = [];
for (const name of Object.keys(sent)) {
if (!Object.hasOwn(declared, name)) errors.push(`${where}.inputs.${name} is not an input the relay sends to ${workflow} — it sends ${exactly}`);
}
for (const [name, value] of Object.entries(declared)) {
if (!Object.hasOwn(sent, name)) errors.push(`${where}.inputs.${name} is required when workflow is ${quote(workflow)}: ${exactly}`);
else if (sent[name] !== value) errors.push(`${where}.inputs.${name} must be ${quote(value)} when workflow is ${quote(workflow)}, got ${quote(sent[name])} — the relay sends ${exactly}`);
}
return errors;
}
/**
* The rules that span actions — the two rows that widen the token. The one
* whose token reaches a second repository (`secondRepo`, `transfer`'s alone):
* such a stroke carries that op alone, names ONE second repository, never
* `repo` itself, and each card once. The ones whose token carries a grant no
* other row spends (`alone`, `workflow_dispatch`'s and `run_rerun_failed`'s,
* the two that spend `actions: write`, and the seven advisory rows', which
* spend `repository-advisories`): such a stroke carries exactly that one
* action. Runs only over actions that passed
* `validateAction`. Pure.
*/
const levelOf = (op) => OPS[op].grant ?? PERMISSIONS[OPS[op].permission];
const grantOf = (op) => `${OPS[op].permission} ${levelOf(op)}`;
export function strokeErrors(repo, actions) {
const errors = [];
const solo = actions.map((a, i) => ({ a, i })).filter(({ a }) => OPS[a.op].alone);
if (solo.length && actions.length > 1) {
const op = solo[0].a.op;
for (const [i, a] of actions.entries()) {
errors.push(
a.op === op
? `actions[${i}]: a stroke carries exactly ONE \`${op}\` and nothing else (this stroke carries ${actions.length} actions) — its token carries ${grantOf(op)} for that one request; send each in its own dispatch`
: `actions[${i}] is \`${a.op}\` in a stroke that carries a \`${op}\` — its token carries ${grantOf(op)}, so that stroke carries the \`${op}\` alone; send \`${a.op}\` in its own dispatch`,
);
}
}
const widening = actions.map((a, i) => ({ a, i })).filter(({ a }) => OPS[a.op].secondRepo);
if (widening.length === 0) return errors;
const op = widening[0].a.op;
const key = OPS[op].secondRepo;
for (const [i, a] of actions.entries()) {
if (a.op !== op) errors.push(`actions[${i}] is \`${a.op}\` in a stroke that carries a \`${op}\` — its token reaches a second repository, so that stroke carries \`${op}\` alone; send \`${a.op}\` in its own dispatch`);
}
const seconds = [...new Set(widening.map(({ a }) => a[key]))];
if (seconds.length > 1) errors.push(`the \`${op}\` actions name ${seconds.length} ${key} values (${seconds.join(', ')}) — one stroke reaches ONE second repository; split them into one dispatch per ${key}`);
for (const { a, i } of widening) {
if (a[key] === repo) errors.push(`actions[${i}].${key} is ${repo}, the stroke's own repository — a \`${op}\` moves a card to ANOTHER repository`);
}
const seen = new Set();
for (const { a, i } of widening) {
if (seen.has(a.issue)) errors.push(`actions[${i}].issue #${a.issue} is named twice in one \`${op}\` stroke`);
seen.add(a.issue);
}
return errors;
}
/**
* Every action, then the rules that span them — the stroke without its
* envelope, for a door that plans one before it knows its transport.
*/
export function validateStroke(repo, actions) {
const errors = actions.flatMap((a, i) => validateAction(a, i));
return errors.length ? errors : strokeErrors(repo, actions);
}
/**
* The repositories the relay's token must reach for a JUDGED payload: its
* `repo`, then the one second repository a `secondRepo` row names. One entry
* for every stroke but a transfer; two for a transfer stroke.
*/
export function tokenRepositoriesOf(payload) {
const out = [payload.repo];
for (const a of payload.actions) {
const key = OPS[a.op].secondRepo;
if (key && !out.includes(a[key])) out.push(a[key]);
}
return out;
}
/**
* The grants the relay's token must carry for a JUDGED payload: every entry of
* `PERMISSIONS` but the stroke-scoped ones, plus each stroke-scoped grant some
* action's op spends, at the level its row spends it — the row's `grant`
* when it names one, else `PERMISSIONS`' level; the higher of two. `{ …five
* grants }` for every stroke but a `workflow_dispatch` or `run_rerun_failed`
* one, which adds `actions: write`; an advisory row that writes
* (`advisory_report`, `advisory_create`, `advisory_collaborators_add`,
* `advisory_versions`, `advisory_state`), which adds
* `repository-advisories: write`; and
* `advisory_read`, which adds `repository-advisories: read`. Pure.
*/
export function tokenPermissionsOf(payload) {
const spent = new Map();
for (const a of payload.actions) {
const { permission } = OPS[a.op];
spent.set(permission, spent.get(permission) === 'write' ? 'write' : levelOf(a.op));
}
const out = {};
for (const [name, level] of Object.entries(PERMISSIONS)) {
if (!STROKE_SCOPED_PERMISSIONS.includes(name)) out[name] = level;
else if (spent.has(name)) out[name] = spent.get(name);
}
return out;
}
/**
* The whole payload. Pure. Returns `{ ok, errors, payload }` where `payload`
* is a normalised copy carrying ONLY the keys this file admitted (so a caller
* that sends `payload` sends nothing it did not judge); `payload` is `null`
* on a refusal.
*/
export function validatePayload(input) {
const errors = [];
if (!isPlainObject(input)) {
return { ok: false, errors: [`the payload must be a JSON object, got ${input === null ? 'null' : Array.isArray(input) ? 'an array' : typeof input}`], payload: null };
}
const keys = Object.keys(input);
for (const key of keys) {
if (!PAYLOAD_KEYS.includes(key)) errors.push(`top-level key ${quote(key)} is not part of the envelope (${PAYLOAD_KEYS.join(', ')})`);
}
for (const key of PAYLOAD_KEYS) {
if (!(key in input)) errors.push(`top-level key ${quote(key)} is required`);
}
if (keys.length > CLIENT_PAYLOAD_MAX_TOP_LEVEL) {
errors.push(`${keys.length} top-level properties; the platform caps client_payload at ${CLIENT_PAYLOAD_MAX_TOP_LEVEL}`);
}
const { request_id: requestId, repo, session, actions } = input;
if (typeof requestId !== 'string' || requestId.length === 0) errors.push('request_id must be a non-empty string');
else if (requestId.length > MAX_REQUEST_ID_CHARS) errors.push(`request_id is ${requestId.length} characters, over its cap of ${MAX_REQUEST_ID_CHARS}`);
else if (!REQUEST_ID_SHAPE.test(requestId)) errors.push(`request_id ${quote(requestId)} must match ${REQUEST_ID_SHAPE} — letters, digits, dot, underscore, hyphen`);
if (typeof repo !== 'string' || !TARGET_REPO_SHAPE.test(repo) || repo.includes('..')) {
errors.push(`repo ${quote(repo ?? null)} must be \`${TARGET_OWNER}/<name>\` — the App is installed on that organization and nowhere else`);
}
if (typeof session !== 'string' || !SESSION_SHAPE.test(session)) {
errors.push(`session ${quote(session ?? null)} must be the dispatching seat's \`session_…\` id`);
}
if (!Array.isArray(actions)) errors.push(`actions must be an array, got ${actions === undefined ? 'nothing' : isPlainObject(actions) ? 'an object' : typeof actions}`);
else if (actions.length === 0) errors.push('actions must carry at least one action');
else if (actions.length > MAX_ACTIONS) errors.push(`actions carries ${actions.length} actions, over the cap of ${MAX_ACTIONS}`);
else errors.push(...validateStroke(typeof repo === 'string' ? repo : '', actions));
if (errors.length) return { ok: false, errors, payload: null };
const payload = {
request_id: requestId,
repo,
session,
actions: actions.map((a) => {
const spec = OPS[a.op];
const out = { op: a.op };
for (const key of [...spec.required, ...spec.optional]) if (key in a) out[key] = Array.isArray(a[key]) ? a[key].map((x) => (isPlainObject(x) ? { ...x } : x)) : isPlainObject(a[key]) ? { ...a[key] } : a[key];
return out;
}),
};
const size = bytes(JSON.stringify(payload));
if (size >= CLIENT_PAYLOAD_MAX_BYTES) {
return { ok: false, errors: [`the payload serialises to ${size} bytes; the platform requires client_payload under ${CLIENT_PAYLOAD_MAX_BYTES} — split the actions across more than one dispatch`], payload: null };
}
return { ok: true, errors: [], payload };
}
/** The refusal, as the lines a seat or a step summary prints. */
export function refusalText(errors) {
return [`fleet-write: payload REFUSED — ${errors.length} problem(s), ⛔ zero writes:`, ...errors.map((e) => ` - ${e}`)].join('\n');
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
export const KNOWN_FLAGS = Object.freeze(['--file', '--from-event', '--github-output', '--self-test', '--help', '-h', '--json']);
export function parseArgs(argv) {
const opts = { file: null, fromEvent: false, githubOutput: false, selfTest: false, help: false, json: false, errors: [] };
const args = [...argv];
while (args.length) {
const a = args.shift();
if (a === '--help' || a === '-h') opts.help = true;
else if (a === '--self-test') opts.selfTest = true;
else if (a === '--from-event') opts.fromEvent = true;
else if (a === '--github-output') opts.githubOutput = true;
else if (a === '--json') opts.json = true;
else if (a === '--file') {
const v = args.shift();
if (v === undefined || v.startsWith('--')) opts.errors.push('--file needs a path');
else opts.file = v;
} else if (a.startsWith('--file=')) opts.file = a.slice('--file='.length);
else opts.errors.push(`unrecognised argument ${quote(a)}`);
}
if (!opts.selfTest && !opts.help && opts.file === null && !opts.fromEvent) opts.errors.push('name the payload: --file <path> or --from-event');
if (opts.file !== null && opts.fromEvent) opts.errors.push('--file and --from-event name two sources; pass one');
return opts;
}
/** Read the payload text from the source the options name. Throws with a prerequisite message. */
export function readPayloadSource(opts, { env = process.env, read = (p) => readFileSync(p, 'utf8') } = {}) {
if (opts.fromEvent) return readEventPayload(env, read);
let text;
try {
text = read(opts.file);
} catch (e) {
throw new Error(`--file ${opts.file} cannot be read (${e?.message ?? 'unreadable'})`);
}
try {
return JSON.parse(text);
} catch (e) {
throw new Error(`the payload is not JSON (${e?.message ?? 'parse failed'})`);
}
}
/**
* The `$GITHUB_OUTPUT` lines the workflow's later steps read. Values are
* single-line by construction of the validator. One `permission_<name>=` line
* per stroke-scoped grant: its level when this stroke spends it, EMPTY
* otherwise — the mint action skips an empty `permission-*` input.
*/
export function githubOutputLines(payload) {
const repositories = tokenRepositoriesOf(payload).map((r) => r.split('/')[1]).join(',');
const grants = tokenPermissionsOf(payload);
const scoped = STROKE_SCOPED_PERMISSIONS.map((name) => `permission_${name.replace(/-/g, '_')}=${grants[name] ?? ''}`);
return [`repositories=${repositories}`, `request_id=${payload.request_id}`, `action_count=${payload.actions.length}`, `session=${payload.session}`, ...scoped].join('\n') + '\n';
}
const USAGE = [
'usage:',
' node scripts/pm/fleet-write/validate.mjs --file <payload.json> [--json]',
` node scripts/pm/fleet-write/validate.mjs --from-event [--github-output] # reads client_payload from $${EVENT_PATH_ENV}`,
' node scripts/pm/fleet-write/validate.mjs --self-test',
` Exits: ${EXIT_OK} valid · ${EXIT_REFUSED} refused or usage · ${EXIT_PREREQUISITE} no payload to judge`,
].join('\n');
export async function main(argv, deps = {}) {
const env = deps.env ?? process.env;
const out = deps.stdout ?? ((l) => console.log(l));
const err = deps.stderr ?? ((l) => console.error(l));
const opts = parseArgs(argv);
if (opts.help) {
out(USAGE);
return EXIT_OK;
}
if (opts.selfTest) return selfTest();
if (opts.errors.length) {
for (const e of opts.errors) err(`fleet-write/validate: ${e}`);
err(USAGE);
return EXIT_REFUSED;
}
let input;
try {
input = readPayloadSource(opts, { env, read: deps.read });
} catch (e) {
err(`fleet-write/validate: PREREQUISITE NOT MET — ${e.message}. ⛔ NOT MEASURED: nothing was judged.`);
return EXIT_PREREQUISITE;
}
const verdict = validatePayload(input);
const summary = env.GITHUB_STEP_SUMMARY;
const append = deps.append ?? ((file, text) => appendFileSync(file, text, 'utf8'));
if (!verdict.ok) {
err(refusalText(verdict.errors));
if (summary) append(summary, `### fleet-write: payload refused, zero writes\n\n${verdict.errors.map((e) => `- ${e}`).join('\n')}\n`);
return EXIT_REFUSED;
}
const p = verdict.payload;
out(`fleet-write/validate: OK — request ${p.request_id} · target ${p.repo} · session ${p.session} · ${p.actions.length} action(s): ${p.actions.map((a) => a.op).join(', ')}`);
if (opts.json) out(JSON.stringify(p));
if (opts.githubOutput) {
const file = env.GITHUB_OUTPUT;
if (!file) {
err('fleet-write/validate: --github-output was asked for but GITHUB_OUTPUT is not set — not on a runner?');
return EXIT_PREREQUISITE;
}
append(file, githubOutputLines(p));
}
return EXIT_OK;
}
// ---------------------------------------------------------------------------
// A dispatched workflow, read from disk — what the self-test holds an allowlisted entry to.
//
// An entry's inputs pick a lane of another file, so the claim "the relay starts that lane alone" is a claim
// about THAT file. Two readers keep it measured, not recalled: `declaredDispatchInputs` reads the inputs the
// file declares under its `workflow_dispatch` trigger, and `jobsStartedBy` evaluates every job's `if:` for a
// given event and inputs — and, for a `schedule` run, the cron that fired it (`github.event.schedule`), when
// the caller names one — job by job in `needs` order, with three values (true, false, UNKNOWN), so a guard
// this reading cannot decide (a running job's result or outputs, a function it does not model, a scheduled
// run whose cron the caller did not name) reads as a job that MAY run, never as one that skips. Both are
// text readings of the workflow's own shape (two-space YAML, a job's `if:` at four spaces), not a YAML
// library — a file they cannot read yields no inputs or no jobs, and every case that uses them first pins
// that they read something.
// ---------------------------------------------------------------------------
/**
* The inputs a workflow declares under its `workflow_dispatch` trigger, as
* `{ name: { type, default } }` — `{}` for a trigger declaring none, `null`
* for a file with no such trigger. Pure.
*/
export function declaredDispatchInputs(text) {
const lines = String(text).split('\n');
const at = lines.findIndex((l) => /^ {2}workflow_dispatch:\s*(\{\s*\})?\s*(#.*)?$/.test(l));
if (at < 0) return null;
const out = {};
let inInputs = false;
let current = null;
for (const line of lines.slice(at + 1)) {
if (/^\s*(#.*)?$/.test(line)) continue;
if (/^ {0,2}\S/.test(line)) break;
if (!inInputs) {
inInputs = /^ {4}inputs:\s*$/.test(line);
continue;
}
if (/^ {0,4}\S/.test(line)) break;
const name = /^ {6}([A-Za-z_][A-Za-z0-9_-]*):\s*$/.exec(line);
if (name) {
current = name[1];
out[current] = { type: null, default: null };
continue;
}
const field = /^ {8}(type|default):\s*'?([^'#\s]+)'?\s*(#.*)?$/.exec(line);
if (field && current) out[current][field[1]] = field[2];
}
return out;
}
/** Every job of a workflow: `{ name: { guard, needs } }`, the guard's text unwrapped or `null`. Pure. */
export function workflowJobs(text) {
const lines = String(text).split('\n');
const at = lines.findIndex((l) => /^jobs:\s*$/.test(l));
const jobs = {};
if (at < 0) return jobs;
let job = null;
for (let i = at + 1; i < lines.length; i++) {
const line = lines[i];
if (/^[^\s#]/.test(line)) break;
const head = /^ {2}([A-Za-z0-9_-]+):\s*$/.exec(line);
if (head) {
job = jobs[head[1]] = { guard: null, needs: [] };
continue;
}
if (!job) continue;
const guard = /^ {4}if:\s*(.*)$/.exec(line);
if (guard) {
const parts = [guard[1]];
for (let j = i + 1; j < lines.length && /^ {6,}[^\s#]/.test(lines[j]); j++) parts.push(lines[j].trim());
job.guard = parts.join(' ').replace(/^[>|]-?\s*/, '').replace(/\s+/g, ' ').trim();
continue;
}
const needs = /^ {4}needs:\s*\[([^\]]*)\]\s*$/.exec(line) ?? /^ {4}needs:\s*([A-Za-z0-9_-]+)\s*$/.exec(line);
if (needs) job.needs = needs[1].split(',').map((s) => s.trim()).filter(Boolean);
}
return jobs;
}
const UNKNOWN = Symbol('unknown');
const truth = (v) => (v === UNKNOWN ? UNKNOWN : !(v === false || v === null || v === '' || v === 0 || Number.isNaN(v)));
const all = (vs) => (vs.some((v) => v === false) ? false : vs.some((v) => v === UNKNOWN) ? UNKNOWN : true);
const any = (vs) => (vs.some((v) => v === true) ? true : vs.some((v) => v === UNKNOWN) ? UNKNOWN : false);
const loosely = (a, b) => {
if (a === UNKNOWN || b === UNKNOWN) return UNKNOWN;
if (typeof a === 'string' && typeof b === 'string') return a.toLowerCase() === b.toLowerCase();
if (typeof a === typeof b) return a === b;
return Number(a ?? 0) === Number(b ?? 0);
};
/**
* One guard, evaluated: `true`, `false` or UNKNOWN. `lookup(path)` answers a
* context path, `status(fn)` a status function. Throws on a token it cannot read.
*/
function evaluateGuard(expr, { lookup, status }) {
const tokens = [];
const re = /\s*(?:(\$\{\{|\}\})|'((?:[^']|'')*)'|(&&|\|\||==|!=|!|\(|\)|,)|(\d+(?:\.\d+)?)|([A-Za-z_][A-Za-z0-9_.*-]*))/y;
const src = String(expr);
let m;
while (re.lastIndex < src.length && (m = re.exec(src))) {
if (m[1]) continue;
if (m[2] !== undefined) tokens.push({ kind: 'value', value: m[2].replace(/''/g, "'") });
else if (m[3]) tokens.push({ kind: m[3] });
else if (m[4]) tokens.push({ kind: 'value', value: Number(m[4]) });
else if (m[5]) tokens.push({ kind: 'name', value: m[5] });
}
if (src.slice(re.lastIndex).trim() !== '') throw new Error(`cannot read the guard at ${JSON.stringify(src.slice(re.lastIndex, re.lastIndex + 20))}`);
let i = 0;
const peek = (k) => tokens[i]?.kind === k;
const take = (k) => {
if (!peek(k)) throw new Error(`expected ${k} at token ${i} of ${JSON.stringify(src)}`);
return tokens[i++];
};
const primary = () => {
if (peek('(')) {
take('(');
const v = or();
take(')');
return v;
}
if (peek('value')) return take('value').value;
const name = take('name').value;
if (peek('(')) {
take('(');
const args = [];
while (!peek(')')) {
args.push(or());
if (peek(',')) take(',');
}
take(')');
return status(name, args);
}
if (name === 'true') return true;
if (name === 'false') return false;
if (name === 'null') return null;
return lookup(name);
};
const unary = () => {
if (peek('!')) {
take('!');
const v = truth(unary());
return v === UNKNOWN ? UNKNOWN : !v;
}
return primary();
};
const eq = () => {
const left = unary();
if (peek('==') || peek('!=')) {
const op = tokens[i++].kind;
const r = loosely(left, unary());
return op === '==' || r === UNKNOWN ? r : !r;
}
return left;
};
const and = () => {
let v = truth(eq());
while (peek('&&')) {
take('&&');
v = all([v, truth(eq())]);
}
return v;
};
const or = () => {
let v = and();
while (peek('||')) {
take('||');
v = any([v, and()]);
}
return v;
};
const v = or();
if (i !== tokens.length) throw new Error(`unread tokens after ${i} in ${JSON.stringify(src)}`);
return v;
}
/**
* Which jobs of a workflow a run with `event` and `inputs` starts: `{ runs,
* mayRun, skipped, unread }`, each a list of job names. A job's guard without a
* status function is read as GitHub reads it, `success() && (guard)`; a job
* with no guard is `success()`. A skipped job's outputs are empty; a job that
* runs answers UNKNOWN for its result and its outputs, so a job downstream of
* one is decided only when its guard does not depend on them. On a `schedule`
* run `github.event.schedule` is the cron that fired it: `schedule` names it,
* and a guard that reads it on a run whose cron was not named is UNKNOWN (the
* job MAY run) — the reader never guesses which clock fired. On every other
* event the context is empty, as GitHub leaves it. Pure.
*/
export function jobsStartedBy(text, { event, inputs = {}, schedule = null }) {
const jobs = workflowJobs(text);
const state = {};
const out = { runs: [], mayRun: [], skipped: [], unread: [] };
const pending = new Set(Object.keys(jobs));
while (pending.size) {
const ready = [...pending].filter((name) => jobs[name].needs.every((n) => n in state || !(n in jobs)));
if (!ready.length) {
for (const name of pending) out.unread.push(`${name}: its needs never resolve`);
break;
}
for (const name of ready) {
pending.delete(name);
const needs = jobs[name].needs.map((n) => state[n] ?? 'skipped');
const lookup = (path) => {
if (path === 'github.event_name') return event;
if (path === 'github.ref') return `refs/heads/${WORKFLOW_DISPATCH_REF}`;
if (path === 'github.event.schedule') return event === 'schedule' ? (schedule ?? UNKNOWN) : null;
if (path.startsWith('inputs.')) return event === 'workflow_dispatch' ? (inputs[path.slice('inputs.'.length)] ?? null) : null;
const need = /^needs\.([A-Za-z0-9_-]+)\.(outputs\.|result$)/.exec(path);
if (need) {
const s = state[need[1]] ?? 'skipped';
if (s === 'skipped') return need[2] === 'result' ? 'skipped' : '';
return UNKNOWN;
}
return UNKNOWN;
};
const status = (fn) => {
if (fn === 'always') return true;
if (fn === 'cancelled') return false;
if (fn === 'success') return needs.some((s) => s === 'skipped') ? false : needs.length === 0 ? true : UNKNOWN;
if (fn === 'failure') return needs.every((s) => s === 'skipped') ? false : UNKNOWN;
return UNKNOWN;
};
const guard = jobs[name].guard;
const explicit = guard !== null && /\b(success|failure|always|cancelled)\s*\(/.test(guard);
let verdict;
try {
verdict = guard === null ? status('success') : explicit ? truth(evaluateGuard(guard, { lookup, status })) : all([status('success'), truth(evaluateGuard(guard, { lookup, status }))]);
} catch (e) {
out.unread.push(`${name}: ${e.message}`);
verdict = UNKNOWN;
}
state[name] = verdict === true ? 'runs' : verdict === false ? 'skipped' : 'may-run';