Repository navigation
Expand file tree
/
Copy pathreference-publish.mjs
More file actions
1598 lines (1476 loc) · 67.9 KB
/
Copy pathreference-publish.mjs
File metadata and controls
1598 lines (1476 loc) · 67.9 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
// @ts-check
//
// THE REFERENCE A CALLER CAN NAME, AND THE REFUSALS THAT KEEP IT MEANING ONE COMMIT FOREVER.
//
// Thirteen repositories call the six reusable workflows in this repository at `@main`, so every push
// to this repository's default branch changes thirteen pipelines that nobody in those repositories
// reviewed. This script is this repository's half of the fix. On a push to `main` it publishes the
// current state of the reusable workflows at a named reference that never moves, with a note saying
// what a caller adopting it must act on. A caller then upgrades when it edits its own `uses:` line.
//
// It does not move any caller. That is thirteen other repositories' work and is deliberately not
// done from here.
//
// ---------------------------------------------------------------------------
// A MOVED REFERENCE IS WORSE THAN A BRANCH, SO NOTHING HERE CAN MOVE ONE
// ---------------------------------------------------------------------------
//
// `@main` is at least honest: a caller reading it knows it tracks a branch. A `v1` re-pointed on each
// release LOOKS immutable and is not, which is the failure this whole path exists to avoid. So:
//
// Every published reference names exactly one commit, forever. The name embeds the commit
// (`workflows-YYYY-MM-DD-<12 hex>`), so it is a pure function of what it points at and there is no
// such thing as "the same name at a different commit".
// A name that already exists is a HARD FAILURE. Not a delete, not a force-update, not a re-point.
// `publishOperations` below is the complete list of commands this script will ever run against the
// published state, and `operationsAreNonDestructive` asserts, in the test suite, that not one of
// them can delete or overwrite anything.
//
// The platform's half of that guarantee is the repository's `Enable release immutability` setting
// (Settings, "Releases"), which locks a published release's tag to its commit. That setting is a
// human's click and this script cannot make it, so this script does not depend on it: the refusal
// above holds whether or not it is on.
//
// ---------------------------------------------------------------------------
// A NARROW YAML READER, BECAUSE THE ALTERNATIVE IS A PERMISSIVE ONE
// ---------------------------------------------------------------------------
//
// This repository has no `package.json`, no lockfile and no install step, on purpose, and Node ships
// no YAML parser. So the caller-facing interface of each reusable is read by the reader below, which
// recognises only the shapes these six files actually use and REFUSES, naming the workflow and the
// line, on anything else. That asymmetry is the whole design: a reader that refuses on an unfamiliar
// shape cannot silently miss a caller-affecting change, and a permissive one can. A refusal costs a
// red run on this repository alone; a miss publishes a reference whose note says a caller has nothing
// to act on when it does.
//
// The interface it extracts is exactly what a caller can be broken by:
//
// the `workflow_call` inputs, with their types, defaults and required flags
// the `workflow_call` secrets a caller must pass
// the permissions the caller must grant, at the workflow level and per job
// the job ids, which are the check-run contexts a caller's ruleset may require
//
// Everything else in these files (steps, `run:` bodies, `uses:` pins, comments) is opaque to the
// reader. It still moves the reference, because it changes what a caller runs; it just does not
// appear in the note's "must act on" list.
//
// ---------------------------------------------------------------------------
// WHAT COUNTS AS A CHANGE, AND WHY THE TWO LISTS ARE NOT THE SAME LIST
// ---------------------------------------------------------------------------
//
// WHETHER TO PUBLISH is decided on the STATE a caller resolves: the six workflow files plus
// everything under `scripts/`. That second half is not decoration. `ci.yml` fetches
// `scripts/docs-content-check.mjs` and `scripts/prepublish-check.mjs` at `github.job_workflow_sha`,
// and `release.yml` runs five more, so a caller pinned to a reference runs the scripts AS OF THAT
// COMMIT. Publishing on workflow-file changes alone would leave a script fix with no reference to
// adopt it from, which is the same stranding this work exists to end, pointed the other way.
//
// WHAT THE NOTE SAYS A CALLER MUST ACT ON is decided on the INTERFACE, and only the four things
// listed above. A comment sweep, a pinned SHA bump or a new step changes the state, mints a
// reference, and truthfully reports that there is nothing a caller has to do about it.
//
// Run it by hand against this checkout, without publishing anything:
//
// node scripts/reference-publish.mjs --dry-run
import { execFile } from 'node:child_process';
import { createHash } from 'node:crypto';
import { appendFileSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import process from 'node:process';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/**
* The `workflow_call` reusables. A caller can only be broken by one of these.
*
* A NAME MISSING FROM THIS LIST IS A FILE NO REFERENCE EVER CARRIES. `isStateFile` below reads it to
* decide whether a push has moved state a caller resolves, so a reusable published without being
* added here would sit on `main` with no published reference a caller could name it at, which is the
* stranding this whole path exists to end.
*/
export const REUSABLE_WORKFLOWS = Object.freeze([
'ci.yml',
'codeql.yml',
'drift-check.yml',
// The installing form of each gate sits beside the form it copies. Both are `workflow_call` files
// a caller names in its own `uses:` line, so both are state a reference carries; a gate published
// without an entry here is a file a caller could name at no reference at all.
'gate-no-emdash-install.yml',
'gate-no-emdash.yml',
'gate-no-internal-refs-install.yml',
'gate-no-internal-refs.yml',
'nightly-fuzz.yml',
'release.yml',
'scorecard.yml',
]);
/** Every published reference starts with this, so `git tag --list` can find them all. */
export const REFERENCE_PREFIX = 'workflows-';
/** `workflows-YYYY-MM-DD-<12 hex>`. The commit is IN the name, so a name is never reusable. */
export const REFERENCE_PATTERN = /^workflows-\d{4}-\d{2}-\d{2}-[0-9a-f]{12}$/;
/** The reader accepts these keys inside an input's mapping and refuses any other. */
const INPUT_KEYS = Object.freeze(['description', 'type', 'default', 'required']);
/** The same, for a secret. */
const SECRET_KEYS = Object.freeze(['description', 'required']);
/** The only members of `on: workflow_call:` this reader knows how to read. */
const WORKFLOW_CALL_KEYS = Object.freeze(['inputs', 'secrets']);
export const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
/** How many changed paths the note lists before it summarises the rest. */
export const NOTE_FILE_LIST_CAP = 40;
/**
* Is this path part of the state a caller resolves when it names a reference?
*
* The six workflow files, plus `scripts/`, which those workflows fetch and run at
* `github.job_workflow_sha`. `test/`, `README.md` and the community-health files are NOT: nothing a
* caller runs reads them, and minting a reference for a README edit would make the reference stream
* noise rather than signal.
* @param {string} file
*/
export function isStateFile(file) {
if (file.startsWith('scripts/')) return true;
return REUSABLE_WORKFLOWS.some((name) => file === `.github/workflows/${name}`);
}
/** @param {string|Buffer} bytes */
export function digestOf(bytes) {
return createHash('sha256').update(bytes).digest('hex');
}
// ---------------------------------------------------------------------------
// The narrow YAML reader
// ---------------------------------------------------------------------------
/**
* A mapping key and, optionally, a value on the same line. Anything else is `other` and, inside a
* region this reader parses strictly, `other` is a refusal.
*/
const KEY_LINE = /^([A-Za-z_][A-Za-z0-9_.\-]*):( .*)?$/;
/** The escapes a double-quoted YAML scalar can carry in these files. */
const DOUBLE_ESCAPES = Object.freeze({ n: '\n', t: '\t', r: '\r', '"': '"', "'": "'", '\\': '\\', '0': '\0' });
/**
* The value written after `key:` on one line.
*
* `empty` means the value is a nested node on the following lines. `block` is a `|` or `>` scalar,
* whose body is those following lines. `flow` is a `[...]` or `{...}` collection, which this reader
* does not read. `bad` is an unterminated quote. Trailing comments are stripped by YAML's own rule:
* after a closing quote, or at the first ` #` of a plain scalar, which a plain scalar cannot contain.
* @param {string} after
*/
export function readValue(after) {
const text = after.trim();
if (text === '' || text.startsWith('#')) return { kind: 'empty' };
if (/^[|>][+-]?[0-9]*(\s.*)?$/.test(text)) return { kind: 'block' };
if (text.startsWith("'")) {
const value = readSingleQuoted(text);
return value === null ? { kind: 'bad' } : { kind: 'scalar', value };
}
if (text.startsWith('"')) {
const value = readDoubleQuoted(text);
return value === null ? { kind: 'bad' } : { kind: 'scalar', value };
}
if (/^[[{&*!]/.test(text)) return { kind: 'flow' };
const commentAt = text.search(/\s#/);
const plain = (commentAt === -1 ? text : text.slice(0, commentAt)).trim();
if (plain === '') return { kind: 'empty' };
if (plain === 'true') return { kind: 'scalar', value: true };
if (plain === 'false') return { kind: 'scalar', value: false };
if (plain === 'null' || plain === '~') return { kind: 'scalar', value: null };
return { kind: 'scalar', value: plain };
}
/** @param {string} text */
function readSingleQuoted(text) {
let out = '';
for (let i = 1; i < text.length; i += 1) {
if (text[i] !== "'") {
out += text[i];
continue;
}
if (text[i + 1] === "'") {
out += "'";
i += 1;
continue;
}
const rest = text.slice(i + 1).trim();
return rest === '' || rest.startsWith('#') ? out : null;
}
return null;
}
/** @param {string} text */
function readDoubleQuoted(text) {
let out = '';
for (let i = 1; i < text.length; i += 1) {
const char = text[i];
if (char === '\\') {
const next = text[i + 1];
if (next === undefined) return null;
out += DOUBLE_ESCAPES[next] ?? next;
i += 1;
continue;
}
if (char === '"') {
const rest = text.slice(i + 1).trim();
return rest === '' || rest.startsWith('#') ? out : null;
}
out += char;
}
return null;
}
/**
* One pass over the document, classifying every line. Nothing here decides meaning; the mapping
* reader below does, and only inside the regions it was asked about.
* @param {string} text
*/
export function lex(text) {
/** @type {any[]} */
const lines = [];
const raw = text.split('\n');
for (let i = 0; i < raw.length; i += 1) {
const line = raw[i].replace(/\r$/, '');
const number = i + 1;
if (line.trim() === '') {
lines.push({ number, kind: 'blank', indent: 0 });
continue;
}
const lead = /^[ \t]*/.exec(line)?.[0] ?? '';
if (lead.includes('\t')) {
return { ok: false, reason: `line ${number} indents with a tab, which YAML does not allow` };
}
const indent = lead.length;
const rest = line.slice(indent);
if (rest.startsWith('#')) {
lines.push({ number, kind: 'comment', indent });
continue;
}
const match = KEY_LINE.exec(rest);
if (!match) {
lines.push({ number, kind: 'other', indent, text: rest });
continue;
}
lines.push({ number, kind: 'key', indent, key: match[1], value: readValue(match[2] ?? '') });
}
return { ok: true, lines };
}
/**
* Is a key line's value complete on that line, so that nothing indented under it is part of it?
*
* `empty` means the value is the nested node below, and `block` means the value is the block scalar
* below. For every other kind the value was written after the colon and is finished there.
* @param {{kind: string}} value
*/
function valueEndsOnItsLine(value) {
return value.kind !== 'empty' && value.kind !== 'block';
}
/**
* Read the block mapping occupying `lines[from..to)`, at the indentation of its first key.
*
* Every entry carries the half-open range of the lines BELOW it, which is its value when the value
* is a nested node. That range is computed by indentation alone, which is why an opaque region (a
* step list, a `run:` body) can be skipped without being understood: YAML requires a nested node,
* block scalar included, to be indented deeper than the key that introduces it.
*
* Blank and comment lines are skipped wherever they appear, at any indentation, because YAML gives
* neither of them structural meaning.
*
* WHERE THE VALUE ENDS ON ITS OWN LINE, THE RANGE BELOW IT MUST BE EMPTY OF EVERYTHING ELSE, and
* this reader refuses when it is not. Those bytes are not part of any shape it recognises, so
* accepting the entry anyway means DROPPING them: a plain scalar wrapped onto a second line reads
* as its first line alone, and two interfaces that genuinely differ (a changed input `default:`, a
* changed job `name:`, both of them things Constraint 4 calls a change a caller must act on) read
* as one. The note then says a caller has nothing to act on while it does, which is the single
* failure this reader's narrowness exists to make impossible. Nobody has to SUPPORT a wrapped
* scalar; the rule is to refuse what is not recognised, and this is the shape the range-based skip
* used to swallow. Blanks and comments stay exempt: every one of these six files carries a comment
* block under its `name:` and between its inputs, and YAML gives neither structural meaning.
* @param {any[]} lines
* @param {number} from
* @param {number} to
*/
export function readMapping(lines, from, to) {
let i = from;
const skippable = (line) => line.kind === 'blank' || line.kind === 'comment';
while (i < to && skippable(lines[i])) i += 1;
if (i >= to) return { ok: true, indent: null, entries: [] };
if (lines[i].kind !== 'key') {
return { ok: false, reason: `line ${lines[i].number} is not a \`key:\` line where a mapping was expected` };
}
const indent = lines[i].indent;
/** @type {any[]} */
const entries = [];
while (i < to) {
const line = lines[i];
if (skippable(line)) {
i += 1;
continue;
}
if (line.indent !== indent) {
return { ok: false, reason: `line ${line.number} is indented by ${line.indent} where ${indent} was expected` };
}
if (line.kind !== 'key') {
return { ok: false, reason: `line ${line.number} is not a \`key:\` line` };
}
let j = i + 1;
while (j < to) {
const next = lines[j];
if (skippable(next) || next.indent > indent) {
j += 1;
continue;
}
break;
}
if (valueEndsOnItsLine(line.value)) {
for (let k = i + 1; k < j; k += 1) {
const below = lines[k];
if (skippable(below)) continue;
return {
ok: false,
reason:
`line ${below.number} is indented under \`${line.key}:\` (line ${line.number}), whose value ends on that line, ` +
'so this reader neither reads it nor recognises it',
};
}
}
entries.push({ key: line.key, value: line.value, line: line.number, from: i + 1, to: j });
i = j;
}
return { ok: true, indent, entries };
}
/** @param {any[]} entries @param {string} where */
function keyed(entries, where) {
const map = new Map();
for (const entry of entries) {
if (map.has(entry.key)) {
return { ok: false, reason: `${where} declares \`${entry.key}:\` twice (line ${entry.line})` };
}
map.set(entry.key, entry);
}
return { ok: true, map };
}
/** A `scope: level` block mapping, and nothing else counts as one. */
function readPermissions(lines, entry, where) {
if (entry.value.kind !== 'empty') {
return { ok: false, reason: `${where} is not a \`scope: level\` block (line ${entry.line})` };
}
const mapping = readMapping(lines, entry.from, entry.to);
if (!mapping.ok) return { ok: false, reason: `${where}: ${mapping.reason}` };
/** @type {Record<string, string>} */
const grants = {};
for (const grant of mapping.entries) {
if (grant.value.kind !== 'scalar' || typeof grant.value.value !== 'string') {
return { ok: false, reason: `${where} gives \`${grant.key}\` a value this reader does not read (line ${grant.line})` };
}
grants[grant.key] = grant.value.value;
}
return { ok: true, grants };
}
/**
* The caller-facing interface of one reusable workflow, or a refusal naming what stopped it.
*
* @param {string} text the file's bytes
* @param {string} workflow its name, so every refusal says which one
* @returns {{ok: true, interface: any} | {ok: false, reason: string}}
*/
export function readWorkflowInterface(text, workflow) {
const fail = (reason) => ({ ok: false, reason: `${workflow}: ${reason}` });
const lexed = lex(text);
if (!lexed.ok) return fail(lexed.reason);
const lines = lexed.lines;
const top = readMapping(lines, 0, lines.length);
if (!top.ok) return fail(top.reason);
if (top.indent !== null && top.indent !== 0) return fail('the document does not begin at column 0');
const topKeys = keyed(top.entries, 'the document');
if (!topKeys.ok) return fail(topKeys.reason);
// -- `on: workflow_call:` -----------------------------------------------------------------------
const on = topKeys.map.get('on');
if (!on) return fail('it declares no `on:` block, so it has no caller-facing interface to read');
/** @type {{from:number,to:number}|null} */
let workflowCall = null;
if (on.value.kind === 'scalar') {
if (on.value.value !== 'workflow_call') {
return fail(`\`on: ${String(on.value.value)}\` is not a \`workflow_call\` reusable`);
}
workflowCall = { from: 0, to: 0 };
} else if (on.value.kind === 'empty') {
const triggers = readMapping(lines, on.from, on.to);
if (!triggers.ok) return fail(`\`on:\`: ${triggers.reason}`);
const called = triggers.entries.find((entry) => entry.key === 'workflow_call');
if (!called) return fail('`on:` declares no `workflow_call:`, so it is not a reusable workflow');
if (called.value.kind !== 'empty') {
return fail(`\`on: workflow_call:\` has a shape this reader does not recognise (line ${called.line})`);
}
workflowCall = { from: called.from, to: called.to };
} else {
return fail('`on:` has a shape this reader does not recognise');
}
const call = readMapping(lines, workflowCall.from, workflowCall.to);
if (!call.ok) return fail(`\`on: workflow_call:\`: ${call.reason}`);
const callKeys = keyed(call.entries, '`on: workflow_call:`');
if (!callKeys.ok) return fail(callKeys.reason);
for (const entry of call.entries) {
if (!WORKFLOW_CALL_KEYS.includes(entry.key)) {
// The fail-closed heart of this reader. `outputs:` would land here, and so would anything else
// GitHub adds to `workflow_call`. Refusing names the key; guessing would publish a note that
// says a caller has nothing to act on while a new part of the contract moved underneath it.
return fail(`\`on: workflow_call:\` declares \`${entry.key}:\` (line ${entry.line}), which this reader does not know how to read`);
}
}
/** @type {Record<string, any>} */
const inputs = {};
const inputsEntry = callKeys.map.get('inputs');
if (inputsEntry) {
if (inputsEntry.value.kind !== 'empty') {
return fail(`\`inputs:\` is not a block mapping (line ${inputsEntry.line})`);
}
const mapping = readMapping(lines, inputsEntry.from, inputsEntry.to);
if (!mapping.ok) return fail(`\`inputs:\`: ${mapping.reason}`);
const names = keyed(mapping.entries, '`inputs:`');
if (!names.ok) return fail(names.reason);
for (const input of mapping.entries) {
const read = readInput(lines, input);
if (!read.ok) return fail(read.reason);
inputs[input.key] = read.input;
}
}
/** @type {Record<string, any>} */
const secrets = {};
const secretsEntry = callKeys.map.get('secrets');
if (secretsEntry) {
if (secretsEntry.value.kind !== 'empty') {
return fail(`\`secrets:\` is not a block mapping (line ${secretsEntry.line})`);
}
const mapping = readMapping(lines, secretsEntry.from, secretsEntry.to);
if (!mapping.ok) return fail(`\`secrets:\`: ${mapping.reason}`);
const names = keyed(mapping.entries, '`secrets:`');
if (!names.ok) return fail(names.reason);
for (const secret of mapping.entries) {
const read = readSecret(lines, secret);
if (!read.ok) return fail(read.reason);
secrets[secret.key] = read.secret;
}
}
// -- the permissions a caller must grant ---------------------------------------------------------
/** @type {Record<string, string>|null} */
let workflowPermissions = null;
const permissionsEntry = topKeys.map.get('permissions');
if (permissionsEntry) {
const read = readPermissions(lines, permissionsEntry, 'the workflow-level `permissions:`');
if (!read.ok) return fail(read.reason);
workflowPermissions = read.grants;
}
// -- the job ids, which are the contexts a caller's ruleset may require --------------------------
const jobsEntry = topKeys.map.get('jobs');
if (!jobsEntry) return fail('it declares no `jobs:` block');
if (jobsEntry.value.kind !== 'empty') return fail('`jobs:` is not a block mapping');
const jobsMapping = readMapping(lines, jobsEntry.from, jobsEntry.to);
if (!jobsMapping.ok) return fail(`\`jobs:\`: ${jobsMapping.reason}`);
const jobNames = keyed(jobsMapping.entries, '`jobs:`');
if (!jobNames.ok) return fail(jobNames.reason);
/** @type {Record<string, any>} */
const jobs = {};
/** @type {Record<string, Record<string, string>|null>} */
const jobPermissions = {};
for (const job of jobsMapping.entries) {
if (job.value.kind !== 'empty') {
return fail(`the job \`${job.key}\` is not a block mapping (line ${job.line})`);
}
const body = readMapping(lines, job.from, job.to);
if (!body.ok) return fail(`the job \`${job.key}\`: ${body.reason}`);
const bodyKeys = keyed(body.entries, `the job \`${job.key}\``);
if (!bodyKeys.ok) return fail(bodyKeys.reason);
let displayName = null;
const nameEntry = bodyKeys.map.get('name');
if (nameEntry) {
if (nameEntry.value.kind !== 'scalar') {
return fail(`the job \`${job.key}\` has a \`name:\` this reader does not read (line ${nameEntry.line})`);
}
displayName = String(nameEntry.value.value);
}
jobs[job.key] = { name: displayName };
const jobPermissionsEntry = bodyKeys.map.get('permissions');
if (jobPermissionsEntry) {
const read = readPermissions(lines, jobPermissionsEntry, `the job \`${job.key}\`'s \`permissions:\``);
if (!read.ok) return fail(read.reason);
jobPermissions[job.key] = read.grants;
} else {
jobPermissions[job.key] = null;
}
}
return {
ok: true,
interface: {
workflow,
inputs,
secrets,
permissions: { workflow: workflowPermissions, jobs: jobPermissions },
jobs,
},
};
}
/** One `workflow_call` input. `description:` is read past, never read: a caller cannot act on prose. */
function readInput(lines, entry) {
const where = `the input \`${entry.key}\``;
if (entry.value.kind !== 'empty') {
return { ok: false, reason: `${where} is not a block mapping (line ${entry.line})` };
}
const mapping = readMapping(lines, entry.from, entry.to);
if (!mapping.ok) return { ok: false, reason: `${where}: ${mapping.reason}` };
const fields = keyed(mapping.entries, where);
if (!fields.ok) return { ok: false, reason: fields.reason };
for (const field of mapping.entries) {
if (!INPUT_KEYS.includes(field.key)) {
return { ok: false, reason: `${where} declares \`${field.key}:\` (line ${field.line}), which this reader does not know how to read` };
}
if (field.key === 'description') continue;
if (field.value.kind !== 'scalar') {
return { ok: false, reason: `${where} gives \`${field.key}:\` a value this reader does not read (line ${field.line})` };
}
}
const typeField = fields.map.get('type');
const requiredField = fields.map.get('required');
const defaultField = fields.map.get('default');
if (requiredField && typeof requiredField.value.value !== 'boolean') {
return { ok: false, reason: `${where} sets \`required:\` to something that is not true or false (line ${requiredField.line})` };
}
return {
ok: true,
input: {
type: typeField ? String(typeField.value.value) : null,
required: requiredField ? Boolean(requiredField.value.value) : false,
default: defaultField ? { present: true, value: defaultField.value.value } : { present: false, value: null },
},
};
}
/** One `workflow_call` secret. Only `required:` changes what a caller has to hand over. */
function readSecret(lines, entry) {
const where = `the secret \`${entry.key}\``;
if (entry.value.kind === 'empty' && entry.from === entry.to) {
return { ok: true, secret: { required: false } };
}
if (entry.value.kind !== 'empty') {
return { ok: false, reason: `${where} is not a block mapping (line ${entry.line})` };
}
const mapping = readMapping(lines, entry.from, entry.to);
if (!mapping.ok) return { ok: false, reason: `${where}: ${mapping.reason}` };
const fields = keyed(mapping.entries, where);
if (!fields.ok) return { ok: false, reason: fields.reason };
for (const field of mapping.entries) {
if (!SECRET_KEYS.includes(field.key)) {
return { ok: false, reason: `${where} declares \`${field.key}:\` (line ${field.line}), which this reader does not know how to read` };
}
if (field.key === 'description') continue;
if (field.value.kind !== 'scalar' || typeof field.value.value !== 'boolean') {
return { ok: false, reason: `${where} sets \`required:\` to something that is not true or false (line ${field.line})` };
}
}
const requiredField = fields.map.get('required');
return { ok: true, secret: { required: requiredField ? Boolean(requiredField.value.value) : false } };
}
// ---------------------------------------------------------------------------
// The diff, and the line between "act on this" and "for your information"
// ---------------------------------------------------------------------------
/** Stable text for a default, so two of them can be compared and printed the same way. */
export function formatDefault(value) {
if (!value || value.present !== true) return 'no default';
return JSON.stringify(value.value);
}
const sameDefault = (a, b) => formatDefault(a) === formatDefault(b);
const samePermissions = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
/**
* What changed between two readings of one workflow's interface.
*
* `act: true` is this spec's definition of a change a caller must act on, and it is deliberately
* narrow: an input added WITH a default breaks nobody (it is how `check-docs-content` shipped to
* thirteen repositories without touching one of them), so it is reported without being demanded.
* @param {any} before
* @param {any} after
*/
export function diffInterfaces(before, after) {
const workflow = after.workflow;
/** @type {any[]} */
const changes = [];
const push = (kind, act, subject, extra = {}) => changes.push({ workflow, kind, act, subject, ...extra });
for (const name of Object.keys(after.inputs)) {
const now = after.inputs[name];
const then = before.inputs[name];
if (!then) {
if (now.required || !now.default.present) push('input-added-required', true, name);
else push('input-added', false, name, { to: formatDefault(now.default) });
continue;
}
if (!then.required && now.required) push('input-became-required', true, name);
if (then.required && !now.required) push('input-became-optional', false, name);
if (!sameDefault(then.default, now.default)) {
push('input-default-changed', true, name, { from: formatDefault(then.default), to: formatDefault(now.default) });
}
if (then.type !== now.type) push('input-type-changed', false, name, { from: then.type, to: now.type });
}
for (const name of Object.keys(before.inputs)) {
if (!after.inputs[name]) push('input-removed', true, name);
}
for (const name of Object.keys(after.secrets)) {
const now = after.secrets[name];
const then = before.secrets[name];
if (!then) {
push('secret-added', true, name, { to: now.required ? 'required' : 'optional' });
continue;
}
if (then.required !== now.required) {
push('secret-required-changed', true, name, {
from: then.required ? 'required' : 'optional',
to: now.required ? 'required' : 'optional',
});
}
}
for (const name of Object.keys(before.secrets)) {
if (!after.secrets[name]) push('secret-removed', true, name);
}
if (!samePermissions(before.permissions.workflow, after.permissions.workflow)) {
push('permissions-changed', true, 'the workflow', {
from: renderGrants(before.permissions.workflow),
to: renderGrants(after.permissions.workflow),
});
}
const jobIds = new Set([...Object.keys(before.permissions.jobs), ...Object.keys(after.permissions.jobs)]);
for (const id of [...jobIds].sort()) {
if (!(id in before.permissions.jobs) || !(id in after.permissions.jobs)) continue;
if (samePermissions(before.permissions.jobs[id], after.permissions.jobs[id])) continue;
push('permissions-changed', true, `the job \`${id}\``, {
from: renderGrants(before.permissions.jobs[id]),
to: renderGrants(after.permissions.jobs[id]),
});
}
for (const id of Object.keys(after.jobs)) {
if (!(id in before.jobs)) {
push('job-added', true, id);
continue;
}
if (before.jobs[id].name !== after.jobs[id].name) {
push('job-name-changed', true, id, { from: before.jobs[id].name ?? id, to: after.jobs[id].name ?? id });
}
}
for (const id of Object.keys(before.jobs)) {
if (!(id in after.jobs)) push('job-removed', true, id);
}
return changes;
}
/** @param {Record<string,string>|null} grants */
function renderGrants(grants) {
if (!grants) return 'none declared';
const keys = Object.keys(grants).sort();
if (keys.length === 0) return 'none declared';
return keys.map((scope) => `${scope}: ${grants[scope]}`).join(', ');
}
// ---------------------------------------------------------------------------
// The note
// ---------------------------------------------------------------------------
/**
* One sentence per change, or null for a kind nobody taught this function to render.
*
* A null is not cosmetic: `composeNote` refuses on it, and `decide` publishes nothing when the note
* refuses, so an unrenderable change can never end up as a reference whose note quietly omits it.
*/
export function renderChange(change) {
const { workflow, subject } = change;
switch (change.kind) {
case 'workflow-added':
return `\`${workflow}\` did not exist at the earlier reference. Everything it declares is new to you.`;
case 'input-added-required':
return `\`${workflow}\`: the input \`${subject}\` was added and has no default. A caller that does not pass it fails.`;
case 'input-added':
return `\`${workflow}\`: the input \`${subject}\` was added, defaulting to ${change.to}. A caller that passes nothing is unaffected.`;
case 'input-removed':
return `\`${workflow}\`: the input \`${subject}\` was removed. A caller still passing it fails.`;
case 'input-became-required':
return `\`${workflow}\`: the input \`${subject}\` is now required. A caller that does not pass it fails.`;
case 'input-became-optional':
return `\`${workflow}\`: the input \`${subject}\` is no longer required.`;
case 'input-default-changed':
return `\`${workflow}\`: the default of \`${subject}\` changed from ${change.from} to ${change.to}. A caller that does not pass it gets the new value.`;
case 'input-type-changed':
return `\`${workflow}\`: the type of \`${subject}\` changed from ${change.from} to ${change.to}.`;
case 'secret-added':
return `\`${workflow}\`: the secret \`${subject}\` was added and is ${change.to}.`;
case 'secret-removed':
return `\`${workflow}\`: the secret \`${subject}\` was removed. A caller passing it by name fails.`;
case 'secret-required-changed':
return `\`${workflow}\`: the secret \`${subject}\` went from ${change.from} to ${change.to}.`;
case 'permissions-changed':
return `\`${workflow}\`: the permissions ${subject} asks the caller to grant changed from (${change.from}) to (${change.to}). A called workflow can only downgrade its caller's token, so a caller that does not grant these fails at startup.`;
case 'job-added':
return `\`${workflow}\`: the job \`${subject}\` was added. It emits a new check-run context.`;
case 'job-removed':
return `\`${workflow}\`: the job \`${subject}\` was removed. A ruleset requiring that context strands every pull request.`;
case 'job-name-changed':
return `\`${workflow}\`: the job \`${subject}\` renamed its display name from ${change.from} to ${change.to}.`;
default:
return null;
}
}
const USES_EXAMPLE = 'uses: cosyte/.github/.github/workflows/ci.yml@';
/**
* The note that travels with the reference, and the only thing a caller reads before adopting it.
*
* It refuses rather than degrading. AC8 of the spec behind this file says a note that cannot be
* produced publishes NOTHING, because a reference with no note is a reference nobody can safely
* adopt, and one silently missing a change is worse than that.
*
* @param {object} facts
* @returns {{ok: true, note: string} | {ok: false, reason: string}}
*/
export function composeNote(facts) {
const { reference, commit, previous, changes = [], files = {} } = facts;
if (typeof reference !== 'string' || !REFERENCE_PATTERN.test(reference)) {
return { ok: false, reason: `the reference name ${JSON.stringify(reference)} is not of the form ${REFERENCE_PREFIX}YYYY-MM-DD-<12 hex>` };
}
if (typeof commit !== 'string' || !/^[0-9a-f]{40}$/.test(commit)) {
return { ok: false, reason: `the commit ${JSON.stringify(commit)} is not a full 40 character SHA` };
}
const rendered = [];
for (const change of changes) {
const sentence = renderChange(change);
if (sentence === null) {
return { ok: false, reason: `there is no way to describe a change of kind ${JSON.stringify(change.kind)} in \`${change.workflow}\`` };
}
rendered.push({ change, sentence });
}
const mustAct = rendered.filter((entry) => entry.change.act === true);
const forInformation = rendered.filter((entry) => entry.change.act !== true);
const body = [];
body.push(`The reusable workflows of this repository, as they stand at commit \`${commit}\`.`);
body.push('');
body.push('This reference names that one commit and will never name another. Call it by name:');
body.push('');
body.push('```yaml');
body.push('jobs:');
body.push(' ci:');
body.push(` ${USES_EXAMPLE}${reference}`);
body.push('```');
body.push('');
if (!previous) {
body.push(
'No reference has been published from this repository before, so there is no earlier reference to compare this one against. This is not a report that nothing changed: every input, every secret, every permission and every job name in these workflows is new to a caller adopting a reference for the first time. Read the workflow headers at this commit before you pin it.',
);
} else {
body.push(`Compared against \`${previous.reference}\` (commit \`${previous.commit}\`).`);
}
body.push('');
body.push('## What a caller must act on');
body.push('');
if (mustAct.length === 0) {
body.push(
previous
? `Nothing. No \`workflow_call\` input, no secret, no permission and no job name changed between \`${previous.reference}\` and this reference. Moving to it is safe without editing anything else in your repository.`
: // Not "everything below": nothing below this heading is a must-act list, and pointing a
// caller at one that is not there is how a first adopter skips the reading it actually
// owes. There is no earlier reference, so there is no change to list; what a caller must
// act on is the whole interface, which lives in the workflow itself.
'This is the first reference, so there is no list here: with no earlier reference to have adopted, nothing can be reported as having changed. What a caller must act on is the whole interface, which is every `workflow_call` input, every secret it must pass, every permission it must grant and every job name it may have made required. Read it from the workflow headers at this commit rather than from this note.',
);
} else {
for (const entry of mustAct) body.push(`- ${entry.sentence}`);
}
body.push('');
if (forInformation.length > 0) {
body.push('## Changes a caller does not have to act on');
body.push('');
for (const entry of forInformation) body.push(`- ${entry.sentence}`);
body.push('');
}
const changed = Array.isArray(files.changed) ? files.changed : [];
const added = Array.isArray(files.added) ? files.added : [];
const removed = Array.isArray(files.removed) ? files.removed : [];
const touched = [
...added.map((file) => ({ file, suffix: ' (added)' })),
...removed.map((file) => ({ file, suffix: ' (removed)' })),
...changed.map((file) => ({ file, suffix: '' })),
].sort((a, b) => (a.file < b.file ? -1 : 1));
if (touched.length > 0) {
body.push('## What moved in the state a caller resolves');
body.push('');
body.push(
'That state is the six reusable workflows plus everything under `scripts/`, which those workflows fetch and run at the commit a caller resolved.',
);
body.push('');
if (!previous) {
// On a first publish every file is "added", which is a list nobody reads and a sentence
// everybody does.
body.push(`All ${touched.length} of those files, because this is the first reference.`);
} else {
for (const entry of touched.slice(0, NOTE_FILE_LIST_CAP)) body.push(`- \`${entry.file}\`${entry.suffix}`);
if (touched.length > NOTE_FILE_LIST_CAP) {
body.push(`- and ${touched.length - NOTE_FILE_LIST_CAP} more`);
}
}
body.push('');
}
body.push('## Why this exists');
body.push('');
body.push(
'A reference at `@main` delivers every later change to your pipeline on this repository\'s clock rather than yours, reviewed by nobody in your repository. A reference like this one delivers nothing until you edit your own `uses:` line.',
);
body.push('');
const note = body.join('\n');
// A cheap self-check, because the expensive failure is a note that reads fine and omits the one
// sentence a caller needed. Every element the criteria demand is asserted on the bytes about to be
// published rather than on the intent that produced them.
const missing = [];
if (!note.includes(reference)) missing.push('the reference name');
if (!note.includes(commit)) missing.push('the commit');
if (!note.includes(USES_EXAMPLE)) missing.push('a `uses:` example');
if (!previous && !note.includes('no earlier reference')) missing.push('the first-publish statement');
for (const entry of mustAct) {
if (!note.includes(entry.sentence)) missing.push(`the change to ${entry.change.workflow}`);
}
if (missing.length > 0) {
return { ok: false, reason: `the note was composed without ${missing.join(', ')}` };
}
return { ok: true, note };
}
// ---------------------------------------------------------------------------
// The decision
// ---------------------------------------------------------------------------
/** `workflows-YYYY-MM-DD-<12 hex>`, or null when either half is not what it claims to be. */
export function referenceName({ date, commit }) {
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) return null;
if (typeof commit !== 'string' || !/^[0-9a-f]{40}$/.test(commit)) return null;
return `${REFERENCE_PREFIX}${date}-${commit.slice(0, 12)}`;
}
/** @param {{path:string,digest:string}[]} files */
function digestMap(files) {
const map = new Map();
for (const file of files) map.set(file.path, file.digest);
return map;
}
/**
* Pure, so every arm below is graded without a network, a token or a real release.
*
* The order of the checks is part of the contract:
*
* 1. Every current interface must be readable, or nothing is published (AC7).
* 2. An earlier reference whose interface cannot be read is the same refusal: the comparison the
* note owes a caller cannot be made. An ABSENT file there is different, and means the workflow
* is new since that reference.
* 3. Nothing changed publishes nothing (AC6). This runs BEFORE the name collision check on
* purpose: a re-run on an already published commit is owed "no new reference was needed", not a
* failure, because no reference is about to be published.
* 4. A name that already exists fails the run and is never deleted, moved or overwritten (AC4).
* 5. A note that cannot be produced publishes neither note nor reference (AC8).
*
* @param {object} facts
* @param {object} [options]
*/
export function decide(facts, options = {}) {
const { compose = composeNote } = options;
const { current, previous = null, existingReferences = [] } = facts;
for (const entry of current.workflows) {
if (!entry.read.ok) {
return refusal('A reusable workflow could not be read', [
`Refusing to publish a reference: ${entry.read.reason}`,
'A reference whose interface nobody could read carries a note that cannot say what a caller must act on, so no reference is published at all.',
'Fix the workflow, or widen the reader in `scripts/reference-publish.mjs` and prove the new shape in `test/reference-publish.test.mjs`.',
]);
}
}
if (previous) {
for (const entry of previous.workflows) {
if (!entry.read.ok && entry.read.absent !== true) {
return refusal('An earlier reference could not be read', [
`Refusing to publish a reference: at \`${previous.reference}\`, ${entry.read.reason}`,
'The note owes a caller a comparison against the reference it is most likely to be on, and that comparison cannot be made against a workflow this reader cannot read.',
'Fix the reader in `scripts/reference-publish.mjs`; nothing that is already published may be edited to clear this.',
]);
}
}
}
const reference = referenceName({ date: current.date, commit: current.commit });
if (!reference) {
return refusal('The reference name cannot be composed', [
`Refusing to publish a reference: commit ${JSON.stringify(current.commit)} and date ${JSON.stringify(current.date)} do not compose a name of the form ${REFERENCE_PREFIX}YYYY-MM-DD-<12 hex>.`,
]);
}
// -- has anything a caller resolves actually moved? ----------------------------------------------
const nowFiles = digestMap(current.files);
const thenFiles = previous ? digestMap(previous.files) : new Map();
const changedFiles = [];
const addedFiles = [];
const removedFiles = [];
for (const [file, digest] of nowFiles) {
if (!thenFiles.has(file)) addedFiles.push(file);
else if (thenFiles.get(file) !== digest) changedFiles.push(file);
}
for (const file of thenFiles.keys()) {
if (!nowFiles.has(file)) removedFiles.push(file);
}
const moved = changedFiles.length + addedFiles.length + removedFiles.length;
if (previous && moved === 0) {
return {
action: 'none',
failing: false,
reference: null,
commit: current.commit,
note: null,
changes: [],
title: 'No new reference was needed',
message: [
`Nothing a caller resolves has changed since \`${previous.reference}\` (commit \`${previous.commit}\`).`,
'The six reusable workflows and every file under `scripts/` are byte identical at this commit, so a second reference would name the same state under a different name.',
`Callers stay on \`${previous.reference}\`.`,
].join('\n'),
outputs: { action: 'none', reference: '', commit: current.commit },
};
}
if (existingReferences.includes(reference)) {
return refusal('That reference already exists', [
`Refusing to publish \`${reference}\`: a reference of that name has already been published.`,
'It is NOT deleted, NOT force updated and NOT pointed at another commit. A published reference names one commit forever, and a name that looks immutable while moving is worse than a branch, which is the whole property this path exists to buy.',
'Someone has to decide what that existing reference is, by hand. Nothing here will decide it for them.',
]);
}
// -- what a caller adopting this reference must act on -------------------------------------------