@intentius/chant 0.108.0 → 0.109.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +6 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/composite.d.ts +5 -0
- package/dist/composite.d.ts.map +1 -1
- package/dist/discovery/collect.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -0
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/param-deps.d.ts +30 -3
- package/dist/discovery/param-deps.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +10 -2
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/fold-provenance.d.ts +35 -2
- package/dist/fold-provenance.d.ts.map +1 -1
- package/dist/provenance.d.ts +38 -0
- package/dist/provenance.d.ts.map +1 -1
- package/dist/workspace/ci-green-cli.d.ts +1 -0
- package/dist/workspace/ci-green-cli.d.ts.map +1 -1
- package/dist/workspace/ci-green-workflow.d.ts +25 -0
- package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
- package/dist/workspace/ci-green.d.ts +6 -0
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/handlers/lifecycle.test.ts +106 -1
- package/src/cli/handlers/lifecycle.ts +26 -5
- package/src/cli/main.ts +15 -1
- package/src/cli/registry.ts +6 -0
- package/src/composite.ts +37 -0
- package/src/discovery/collect.ts +13 -3
- package/src/discovery/fold-import.ts +251 -9
- package/src/discovery/fold-package-composite.test.ts +14 -1
- package/src/discovery/fold-project-provenance.test.ts +532 -0
- package/src/discovery/param-deps.test.ts +120 -1
- package/src/discovery/param-deps.ts +73 -3
- package/src/fold/subset-public-export.test.ts +38 -0
- package/src/fold/subset.ts +10 -2
- package/src/fold-provenance.test.ts +179 -16
- package/src/fold-provenance.ts +136 -11
- package/src/provenance.ts +46 -1
- package/src/workspace/ci-green-cli.ts +20 -3
- package/src/workspace/ci-green-workflow.test.ts +90 -0
- package/src/workspace/ci-green-workflow.ts +37 -0
- package/src/workspace/ci-green.test.ts +27 -1
- package/src/workspace/ci-green.ts +17 -1
package/src/fold-provenance.ts
CHANGED
|
@@ -70,7 +70,14 @@
|
|
|
70
70
|
*/
|
|
71
71
|
|
|
72
72
|
import { isResourceDeclarable, type Declarable } from "./declarable";
|
|
73
|
-
import {
|
|
73
|
+
import { relative, isAbsolute } from "node:path";
|
|
74
|
+
import {
|
|
75
|
+
originOfPath,
|
|
76
|
+
type ArgumentLocation,
|
|
77
|
+
type EntityProvenance,
|
|
78
|
+
type PathOrigin,
|
|
79
|
+
type SourceLocation,
|
|
80
|
+
} from "./provenance";
|
|
74
81
|
import type { DeepEntityDrift, PropertyDrift } from "./lifecycle/deep-diff";
|
|
75
82
|
|
|
76
83
|
/** Why a field's origin could not be determined. Both are facts about the BUILD, not about the field. */
|
|
@@ -97,8 +104,17 @@ export type UnknownOriginReason =
|
|
|
97
104
|
* address and "the `api` call of `AutoscaledService`" is.
|
|
98
105
|
*/
|
|
99
106
|
export type FoldFieldOrigin =
|
|
100
|
-
| {
|
|
101
|
-
|
|
107
|
+
| {
|
|
108
|
+
kind: "composite-parameter";
|
|
109
|
+
composite: string;
|
|
110
|
+
instance?: string;
|
|
111
|
+
parameters: string[];
|
|
112
|
+
/** Where the composite call was written (chant #3597), when the build saw it. */
|
|
113
|
+
call?: SourceLocation;
|
|
114
|
+
/** Where each parameter's argument was written at that call, in `parameters` order. */
|
|
115
|
+
arguments?: ArgumentLocation[];
|
|
116
|
+
}
|
|
117
|
+
| { kind: "composite-literal"; composite: string; instance?: string; call?: SourceLocation }
|
|
102
118
|
| { kind: "direct" }
|
|
103
119
|
| { kind: "unknown"; reason: UnknownOriginReason };
|
|
104
120
|
|
|
@@ -110,6 +126,11 @@ export interface EntityFoldProvenance {
|
|
|
110
126
|
composite?: string;
|
|
111
127
|
/** The composite call (export name) it belongs to, when one expanded it. */
|
|
112
128
|
instance?: string;
|
|
129
|
+
/**
|
|
130
|
+
* Where that composite call was written (chant #3597), when the fold
|
|
131
|
+
* interpreted the factory at a call it saw as written.
|
|
132
|
+
*/
|
|
133
|
+
compositeCall?: SourceLocation;
|
|
113
134
|
/**
|
|
114
135
|
* Emitted property path to origin, in sorted key order. Every path the entity
|
|
115
136
|
* emits is present: a path with no attribution carries an `unknown` origin
|
|
@@ -178,6 +199,7 @@ export function classifyFieldOrigin(
|
|
|
178
199
|
): FoldFieldOrigin {
|
|
179
200
|
if (!provenance) return { kind: "unknown", reason: "no-provenance" };
|
|
180
201
|
const instance = provenance.compositeInstance;
|
|
202
|
+
const call = provenance.compositeCall;
|
|
181
203
|
|
|
182
204
|
switch (origin?.kind) {
|
|
183
205
|
case "composite-parameter":
|
|
@@ -186,15 +208,29 @@ export function classifyFieldOrigin(
|
|
|
186
208
|
composite: origin.composite,
|
|
187
209
|
...(instance ? { instance } : {}),
|
|
188
210
|
parameters: [...origin.parameters],
|
|
211
|
+
...(call ? { call: { ...call } } : {}),
|
|
212
|
+
...(origin.arguments ? { arguments: origin.arguments.map((a) => ({ ...a })) } : {}),
|
|
189
213
|
};
|
|
190
214
|
case "composite-literal":
|
|
191
|
-
return {
|
|
215
|
+
return {
|
|
216
|
+
kind: "composite-literal",
|
|
217
|
+
composite: origin.composite,
|
|
218
|
+
...(instance ? { instance } : {}),
|
|
219
|
+
...(call ? { call: { ...call } } : {}),
|
|
220
|
+
};
|
|
192
221
|
case "composite":
|
|
193
222
|
// The coarse kind: a composite expanded this, and nothing said which
|
|
194
223
|
// parameter. Not a parameter, not a fixed literal, and above all not a
|
|
195
224
|
// direct declaration.
|
|
196
225
|
return { kind: "unknown", reason: "composite-not-interpreted" };
|
|
197
226
|
case "authored":
|
|
227
|
+
// chant#3608 — `authored` is the file's own root record, and it reaches
|
|
228
|
+
// a path only when nothing finer did. On an entity a composite wrote (a
|
|
229
|
+
// member destructured from a composite the fold invoked) that means the
|
|
230
|
+
// fold could not attribute the path, which is `unknown`, never `direct`.
|
|
231
|
+
return provenance.composite
|
|
232
|
+
? { kind: "unknown", reason: "composite-not-interpreted" }
|
|
233
|
+
: { kind: "direct" };
|
|
198
234
|
case "build-param":
|
|
199
235
|
// A build parameter governs the expression the AUTHOR wrote in their own
|
|
200
236
|
// source, so the declaration is still theirs to edit.
|
|
@@ -224,6 +260,7 @@ export function foldProvenanceOfEntity(
|
|
|
224
260
|
...(provenance?.sourceFile ? { sourceFile: provenance.sourceFile } : {}),
|
|
225
261
|
...(provenance?.composite ? { composite: provenance.composite } : {}),
|
|
226
262
|
...(provenance?.compositeInstance ? { instance: provenance.compositeInstance } : {}),
|
|
263
|
+
...(provenance?.compositeCall ? { compositeCall: { ...provenance.compositeCall } } : {}),
|
|
227
264
|
fields,
|
|
228
265
|
};
|
|
229
266
|
}
|
|
@@ -278,9 +315,57 @@ function compositeCall(composite: string, instance: string | undefined): string
|
|
|
278
315
|
return instance ? `the \`${instance}\` call of composite ${composite}` : `composite ${composite}`;
|
|
279
316
|
}
|
|
280
317
|
|
|
281
|
-
/**
|
|
282
|
-
function
|
|
283
|
-
|
|
318
|
+
/** A file as a report prints it: relative to `root` when it is inside it, otherwise as recorded. */
|
|
319
|
+
function displayFile(file: string, root: string | undefined): string {
|
|
320
|
+
if (!root || !isAbsolute(file)) return file;
|
|
321
|
+
const rel = relative(root, file);
|
|
322
|
+
return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : file;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** `src/app.ts:12`, relative to `root` when it is inside it. */
|
|
326
|
+
export function formatSourceLocation(location: SourceLocation, root?: string): string {
|
|
327
|
+
return `${displayFile(location.file, root)}:${location.line}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** ` in src/app.infra.ts`, or nothing when the build recorded no file. */
|
|
331
|
+
function inFile(sourceFile: string | undefined, root: string | undefined): string {
|
|
332
|
+
return sourceFile ? ` in ${displayFile(sourceFile, root)}` : "";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The composite call as the author wrote the arguments that govern a field:
|
|
337
|
+
* `WebApp({ replicas: 3 })`, or `WebApp({ iam: { path: "/service/" } })` for a
|
|
338
|
+
* nested parameter. Only arguments actually written at the call appear; when
|
|
339
|
+
* none is, the call is shown as `WebApp(...)`.
|
|
340
|
+
*/
|
|
341
|
+
export function describeCompositeArguments(composite: string, locations: readonly ArgumentLocation[]): string {
|
|
342
|
+
const written = locations.filter((a) => a.text !== undefined);
|
|
343
|
+
if (written.length === 0) return `${composite}(...)`;
|
|
344
|
+
const parts = written.map((a) => {
|
|
345
|
+
const outer = a.parameter.split(".").slice(0, -1);
|
|
346
|
+
return outer.reduceRight((inner, key) => `${key}: { ${inner} }`, a.text as string);
|
|
347
|
+
});
|
|
348
|
+
return `${composite}({ ${parts.join(", ")} })`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Where a composite-parameter field was set, as `at src/app.ts:12`: the first
|
|
353
|
+
* argument written at the call, else the call, else the declaring file.
|
|
354
|
+
*/
|
|
355
|
+
function parameterSite(
|
|
356
|
+
origin: Extract<FoldFieldOrigin, { kind: "composite-parameter" }>,
|
|
357
|
+
sourceFile: string | undefined,
|
|
358
|
+
root: string | undefined,
|
|
359
|
+
): string {
|
|
360
|
+
const written = origin.arguments?.find((a) => a.text !== undefined);
|
|
361
|
+
const location = written ?? origin.call ?? origin.arguments?.[0];
|
|
362
|
+
if (location) return ` at ${formatSourceLocation(location, root)}`;
|
|
363
|
+
return inFile(sourceFile, root);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** ` at src/app.ts:10` for the composite call, else the declaring file. */
|
|
367
|
+
function callSite(call: SourceLocation | undefined, sourceFile: string | undefined, root: string | undefined): string {
|
|
368
|
+
return call ? ` at ${formatSourceLocation(call, root)}` : inFile(sourceFile, root);
|
|
284
369
|
}
|
|
285
370
|
|
|
286
371
|
/** A value as a report prints it. */
|
|
@@ -327,6 +412,12 @@ export interface FieldReconcile {
|
|
|
327
412
|
declared?: unknown;
|
|
328
413
|
live?: unknown;
|
|
329
414
|
origin: FoldFieldOrigin;
|
|
415
|
+
/**
|
|
416
|
+
* One line naming where the field came from (chant #3597):
|
|
417
|
+
* `spec.replicas on Deployment webDeployment comes from WebApp({ replicas: 3 }) at src/app.ts:12`.
|
|
418
|
+
* An `unknown` origin says so and why; it never reads as a direct declaration.
|
|
419
|
+
*/
|
|
420
|
+
summary: string;
|
|
330
421
|
resolution: DriftResolution;
|
|
331
422
|
}
|
|
332
423
|
|
|
@@ -337,18 +428,48 @@ export interface FieldReconcile {
|
|
|
337
428
|
* source declared, whose live value moved. Undeclared fields are held
|
|
338
429
|
* elsewhere and never reach here, so the only question left is which of the
|
|
339
430
|
* four origins produced the field.
|
|
431
|
+
*
|
|
432
|
+
* `entityType` names the resource in the summary line, and `root` (the
|
|
433
|
+
* project directory) makes the file names in it relative. Both are optional;
|
|
434
|
+
* without them the summary uses the entity name alone and absolute files.
|
|
340
435
|
*/
|
|
341
436
|
export function resolveDriftedField(input: {
|
|
342
437
|
entity: string;
|
|
438
|
+
entityType?: string;
|
|
343
439
|
path: string;
|
|
344
440
|
declared?: unknown;
|
|
345
441
|
live?: unknown;
|
|
346
442
|
origin?: PathOrigin;
|
|
347
443
|
provenance?: EntityProvenance;
|
|
444
|
+
root?: string;
|
|
348
445
|
}): FieldReconcile {
|
|
349
446
|
const origin = classifyFieldOrigin(input.origin, input.provenance);
|
|
350
447
|
const sourceFile = input.provenance?.sourceFile;
|
|
448
|
+
const root = input.root;
|
|
351
449
|
const move = `${renderValue(input.declared)} to ${renderValue(input.live)}`;
|
|
450
|
+
const subject = `${input.path} on ${input.entityType ? `${input.entityType} ` : ""}${input.entity}`;
|
|
451
|
+
|
|
452
|
+
const summary = ((): string => {
|
|
453
|
+
switch (origin.kind) {
|
|
454
|
+
case "composite-parameter":
|
|
455
|
+
return (
|
|
456
|
+
`${subject} comes from ${describeCompositeArguments(origin.composite, origin.arguments ?? [])}` +
|
|
457
|
+
`${parameterSite(origin, sourceFile, root)}` +
|
|
458
|
+
(origin.arguments?.some((a) => a.text !== undefined)
|
|
459
|
+
? ""
|
|
460
|
+
: ` (parameter ${origin.parameters.join(", ")}, not written at the call)`)
|
|
461
|
+
);
|
|
462
|
+
case "composite-literal":
|
|
463
|
+
return (
|
|
464
|
+
`${subject} is fixed inside composite ${origin.composite}; ` +
|
|
465
|
+
`no argument to the call${callSite(origin.call, sourceFile, root)} moves it`
|
|
466
|
+
);
|
|
467
|
+
case "direct":
|
|
468
|
+
return `${subject} is declared directly${inFile(sourceFile, root)}`;
|
|
469
|
+
case "unknown":
|
|
470
|
+
return `${subject} has an unknown origin: ${unknownOriginText(origin.reason)}`;
|
|
471
|
+
}
|
|
472
|
+
})();
|
|
352
473
|
|
|
353
474
|
const resolution: DriftResolution = ((): DriftResolution => {
|
|
354
475
|
switch (origin.kind) {
|
|
@@ -363,7 +484,7 @@ export function resolveDriftedField(input: {
|
|
|
363
484
|
description:
|
|
364
485
|
`change ${parameters.length === 1 ? "parameter" : "parameters"} ` +
|
|
365
486
|
`${parameters.map((p) => `\`${p}\``).join(", ")} of ` +
|
|
366
|
-
`${compositeCall(origin.composite, origin.instance)}${
|
|
487
|
+
`${compositeCall(origin.composite, origin.instance)}${parameterSite(origin, sourceFile, root)} ` +
|
|
367
488
|
`so \`${input.path}\` moves from ${move}. The composite stays.`,
|
|
368
489
|
};
|
|
369
490
|
}
|
|
@@ -375,14 +496,14 @@ export function resolveDriftedField(input: {
|
|
|
375
496
|
...(sourceFile ? { sourceFile } : {}),
|
|
376
497
|
description:
|
|
377
498
|
`refused: \`${input.path}\` is fixed by ${compositeCall(origin.composite, origin.instance)}` +
|
|
378
|
-
`${
|
|
499
|
+
`${callSite(origin.call, sourceFile, root)}, so no argument at the call site moves it. ` +
|
|
379
500
|
`Parameterize the field in composite ${origin.composite}, or stop using the composite here.`,
|
|
380
501
|
};
|
|
381
502
|
case "direct":
|
|
382
503
|
return {
|
|
383
504
|
kind: "edit-declaration",
|
|
384
505
|
...(sourceFile ? { sourceFile } : {}),
|
|
385
|
-
description: `change the declared value of \`${input.path}\`${inFile(sourceFile)} from ${move}.`,
|
|
506
|
+
description: `change the declared value of \`${input.path}\`${inFile(sourceFile, root)} from ${move}.`,
|
|
386
507
|
};
|
|
387
508
|
case "unknown":
|
|
388
509
|
return {
|
|
@@ -391,7 +512,7 @@ export function resolveDriftedField(input: {
|
|
|
391
512
|
...(sourceFile ? { sourceFile } : {}),
|
|
392
513
|
description:
|
|
393
514
|
`origin unknown (${unknownOriginText(origin.reason)}), so falling back to changing the ` +
|
|
394
|
-
`declared value of \`${input.path}\`${inFile(sourceFile)} from ${move}.`,
|
|
515
|
+
`declared value of \`${input.path}\`${inFile(sourceFile, root)} from ${move}.`,
|
|
395
516
|
};
|
|
396
517
|
}
|
|
397
518
|
})();
|
|
@@ -402,6 +523,7 @@ export function resolveDriftedField(input: {
|
|
|
402
523
|
...("declared" in input ? { declared: input.declared } : {}),
|
|
403
524
|
...("live" in input ? { live: input.live } : {}),
|
|
404
525
|
origin,
|
|
526
|
+
summary,
|
|
405
527
|
resolution,
|
|
406
528
|
};
|
|
407
529
|
}
|
|
@@ -420,6 +542,7 @@ export function resolveDriftedField(input: {
|
|
|
420
542
|
export function resolveDeepDrift(
|
|
421
543
|
drifted: readonly DeepEntityDrift[],
|
|
422
544
|
provenanceOf: (entity: string) => EntityProvenance | undefined,
|
|
545
|
+
options: { root?: string } = {},
|
|
423
546
|
): FieldReconcile[] {
|
|
424
547
|
const out: FieldReconcile[] = [];
|
|
425
548
|
for (const entity of drifted) {
|
|
@@ -428,11 +551,13 @@ export function resolveDeepDrift(
|
|
|
428
551
|
out.push(
|
|
429
552
|
resolveDriftedField({
|
|
430
553
|
entity: entity.name,
|
|
554
|
+
entityType: entity.type,
|
|
431
555
|
path: change.path,
|
|
432
556
|
...("declared" in change ? { declared: change.declared } : {}),
|
|
433
557
|
...("live" in change ? { live: change.live } : {}),
|
|
434
558
|
...(change.origin ? { origin: change.origin } : {}),
|
|
435
559
|
...(provenance ? { provenance } : {}),
|
|
560
|
+
...(options.root ? { root: options.root } : {}),
|
|
436
561
|
}),
|
|
437
562
|
);
|
|
438
563
|
}
|
package/src/provenance.ts
CHANGED
|
@@ -32,6 +32,13 @@ export interface EntityProvenance {
|
|
|
32
32
|
* collapse a composite to a single node at coarse diagram detail levels (#494).
|
|
33
33
|
*/
|
|
34
34
|
compositeInstance?: string;
|
|
35
|
+
/**
|
|
36
|
+
* Where the composite call that expanded this entity was written (chant
|
|
37
|
+
* #3597): the `WebApp({...})` expression, not the factory body. Recorded
|
|
38
|
+
* only when the fold interpreted the factory at a call it could see; the
|
|
39
|
+
* run path and a nested call made from already-folded values leave it unset.
|
|
40
|
+
*/
|
|
41
|
+
compositeCall?: SourceLocation;
|
|
35
42
|
/**
|
|
36
43
|
* Per-path origins within the entity's `props` (chant #1443). Read with
|
|
37
44
|
* {@link originOfPath}, which does the prefix resolution these keys are
|
|
@@ -90,9 +97,46 @@ export type PathOrigin =
|
|
|
90
97
|
| { kind: "authored" }
|
|
91
98
|
| { kind: "composite"; composite: string; instance: string }
|
|
92
99
|
| { kind: "build-param"; params: string[] }
|
|
93
|
-
| {
|
|
100
|
+
| {
|
|
101
|
+
kind: "composite-parameter";
|
|
102
|
+
composite: string;
|
|
103
|
+
parameters: string[];
|
|
104
|
+
/**
|
|
105
|
+
* Where each parameter's argument was written at the composite call
|
|
106
|
+
* (chant #3597), in `parameters` order. Absent when the build did not
|
|
107
|
+
* see the call; see {@link ArgumentLocation} for the fallback.
|
|
108
|
+
*/
|
|
109
|
+
arguments?: ArgumentLocation[];
|
|
110
|
+
}
|
|
94
111
|
| { kind: "composite-literal"; composite: string };
|
|
95
112
|
|
|
113
|
+
/**
|
|
114
|
+
* A place in a source file. `file` is absolute, as
|
|
115
|
+
* {@link EntityProvenance.sourceFile} is; `line` and `column` are 1-based, the
|
|
116
|
+
* way an editor and `file:line:column` print them.
|
|
117
|
+
*/
|
|
118
|
+
export interface SourceLocation {
|
|
119
|
+
file: string;
|
|
120
|
+
line: number;
|
|
121
|
+
column: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Where one composite parameter's argument was written at the call (chant
|
|
126
|
+
* #3597). When the call's argument object has a property at the parameter's
|
|
127
|
+
* path, the location is that property and `text` is the property as written
|
|
128
|
+
* (`replicas: 3`). When it does not (the parameter was left out, or arrived
|
|
129
|
+
* through a spread or a non-literal argument), the location is the call's own
|
|
130
|
+
* and `text` is absent: the call is the nearest place that governs it, and
|
|
131
|
+
* nothing claims a property that is not there.
|
|
132
|
+
*/
|
|
133
|
+
export interface ArgumentLocation extends SourceLocation {
|
|
134
|
+
/** The factory parameter path, as in `parameters` (`replicas`, `iam.path`). */
|
|
135
|
+
parameter: string;
|
|
136
|
+
/** The innermost property as written at the call, when one is. */
|
|
137
|
+
text?: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
96
140
|
/** True when `prefix` addresses `path` or an ancestor of it, on a segment boundary. */
|
|
97
141
|
function isPathPrefix(prefix: string, path: string): boolean {
|
|
98
142
|
if (prefix === "") return true;
|
|
@@ -114,6 +158,7 @@ export function setProvenance(entity: object, prov: EntityProvenance): void {
|
|
|
114
158
|
existing.sourceFile ??= prov.sourceFile;
|
|
115
159
|
existing.composite ??= prov.composite;
|
|
116
160
|
existing.compositeInstance ??= prov.compositeInstance;
|
|
161
|
+
existing.compositeCall ??= prov.compositeCall;
|
|
117
162
|
if (prov.paths) {
|
|
118
163
|
existing.paths ??= {};
|
|
119
164
|
for (const [path, origin] of Object.entries(prov.paths)) {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* ci last-green [--json] the newest commit on the branch with a green tag and no revoked tag
|
|
5
5
|
* ci tick [--dry-run] [--forge github] tag the commits that turned green, revoke the ones that turned red
|
|
6
6
|
* ci workflow [--workflow <name>]... [--output <file>] [--chant <command>] [--install <command>]
|
|
7
|
+
* [--token-secret <NAME> | --app-id-var <VAR> --app-key-secret <NAME>]
|
|
7
8
|
* write the root CI file that runs the tick
|
|
8
9
|
*
|
|
9
10
|
* `last-green --json` prints `ci-last-green.schema.json`. The tick and the
|
|
@@ -17,12 +18,12 @@ import type { CommandContext } from "../cli/registry";
|
|
|
17
18
|
import { findWorkspaceRoot } from "../project-root";
|
|
18
19
|
import { ciTick, CiGreenError, formatTick, lastGreen } from "./ci-green";
|
|
19
20
|
import { ciForgeFromEnv } from "./ci-green-forge";
|
|
20
|
-
import { CI_GREEN_WORKFLOW_FILE, ciGreenHeader, readWorkflowSources, renderCiGreenWorkflow, workflowsHolding, writeWorkflowFile } from "./ci-green-workflow";
|
|
21
|
+
import { ACTIONS_NAME, CI_GREEN_WORKFLOW_FILE, ciGreenHeader, type PushToken, readWorkflowSources, renderCiGreenWorkflow, workflowsHolding, writeWorkflowFile } from "./ci-green-workflow";
|
|
21
22
|
import { readDeclaration, readerVersion, WorkspaceReadError } from "./declaration";
|
|
22
23
|
import { recordGeneratedFiles, resolveMemberContext, shellArg } from "./member-pipeline";
|
|
23
24
|
import { gitTop, workingTree } from "./tree";
|
|
24
25
|
|
|
25
|
-
const USAGE = "chant ci last-green [--json] | ci tick [--dry-run] [--forge github] | ci workflow [--workflow <name>]... [--output <file>] [--chant <command>] [--install <command>]";
|
|
26
|
+
const USAGE = "chant ci last-green [--json] | ci tick [--dry-run] [--forge github] | ci workflow [--workflow <name>]... [--output <file>] [--chant <command>] [--install <command>] [--token-secret <NAME> | --app-id-var <VAR> --app-key-secret <NAME>]";
|
|
26
27
|
|
|
27
28
|
export async function runCiLastGreen(ctx: CommandContext): Promise<number> {
|
|
28
29
|
const doc = lastGreen({ cwd: process.cwd() });
|
|
@@ -56,9 +57,23 @@ export async function runCiTick(ctx: CommandContext): Promise<number> {
|
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
/** The push token the flags ask for, or none for the Actions token. Throws on a half-given or clashing pair. */
|
|
61
|
+
function pushToken(args: CommandContext["args"]): PushToken | undefined {
|
|
62
|
+
const { ciTokenSecret: secret, ciAppIdVar: appIdVar, ciAppKeySecret: appKeySecret } = args;
|
|
63
|
+
for (const [flag, name] of [["--token-secret", secret], ["--app-id-var", appIdVar], ["--app-key-secret", appKeySecret]] as const) {
|
|
64
|
+
if (name !== undefined && !ACTIONS_NAME.test(name)) throw new Error(`${flag} takes a secret or variable name, letters, digits and underscores, not ${JSON.stringify(name)}`);
|
|
65
|
+
}
|
|
66
|
+
if (secret !== undefined && (appIdVar !== undefined || appKeySecret !== undefined)) throw new Error("--token-secret and --app-id-var/--app-key-secret each name the push token; pass one or the other");
|
|
67
|
+
if ((appIdVar === undefined) !== (appKeySecret === undefined)) throw new Error("a GitHub App token needs both the App's id and its key: --app-id-var <VAR> --app-key-secret <NAME>");
|
|
68
|
+
if (secret !== undefined) return { secret };
|
|
69
|
+
if (appIdVar !== undefined && appKeySecret !== undefined) return { appIdVar, appKeySecret };
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
59
73
|
export async function runCiWorkflow(ctx: CommandContext): Promise<number> {
|
|
60
74
|
const cwd = process.cwd();
|
|
61
75
|
try {
|
|
76
|
+
const push = pushToken(ctx.args);
|
|
62
77
|
const found = findWorkspaceRoot(cwd);
|
|
63
78
|
if (!found) throw new WorkspaceReadError("declaration-missing", "no chant.workspace.json or .jsonc between this directory and the git root");
|
|
64
79
|
const declaration = readDeclaration(workingTree(found.dir));
|
|
@@ -85,11 +100,13 @@ export async function runCiWorkflow(ctx: CommandContext): Promise<number> {
|
|
|
85
100
|
if (ctx.args.output) parts.push("--output", relative(base, target).split(sep).join("/"));
|
|
86
101
|
if (ctx.args.ciChant) parts.push("--chant", ctx.args.ciChant);
|
|
87
102
|
if (ctx.args.ciInstall) parts.push("--install", ctx.args.ciInstall);
|
|
103
|
+
if (push && "secret" in push) parts.push("--token-secret", push.secret);
|
|
104
|
+
if (push && "appIdVar" in push) parts.push("--app-id-var", push.appIdVar, "--app-key-secret", push.appKeySecret);
|
|
88
105
|
const command = parts.map(shellArg).join(" ");
|
|
89
106
|
const workspaceRoot = relative(repoRoot, root).split(sep).join("/") || ".";
|
|
90
107
|
// The install runs in the workspace root, so its lockfile there keys setup-node's npm cache.
|
|
91
108
|
const npmLock = ctx.args.ciInstall && existsSync(join(root, "package-lock.json")) ? (workspaceRoot === "." ? "package-lock.json" : `${workspaceRoot}/package-lock.json`) : undefined;
|
|
92
|
-
const yaml = renderCiGreenWorkflow({ green, workflows, chantVersion: readerVersion(), workspaceRoot, chant: ctx.args.ciChant, install: ctx.args.ciInstall, npmLock });
|
|
109
|
+
const yaml = renderCiGreenWorkflow({ green, workflows, chantVersion: readerVersion(), workspaceRoot, chant: ctx.args.ciChant, install: ctx.args.ciInstall, npmLock, push });
|
|
93
110
|
const written = writeWorkflowFile(target, ciGreenHeader(command, member ? "member" : "root") + yaml);
|
|
94
111
|
if (member) recordGeneratedFiles(member.memberRoot, [{ path: fromRepo, command }]);
|
|
95
112
|
console.log(`Wrote ${written}: ticks when ${workflows.join(", ")} complete${workflows.length === 1 ? "s" : ""} on ${green.branch}, and every 15 minutes.`);
|
|
@@ -115,6 +115,38 @@ describe("the workflows that hold required check runs (#3573)", () => {
|
|
|
115
115
|
});
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
+
describe("the token the tick pushes its tags with (#3573)", () => {
|
|
119
|
+
const base = { green, workflows: ["ci"], chantVersion: "0.108.0", workspaceRoot: "." };
|
|
120
|
+
|
|
121
|
+
test("without one, checkout persists the Actions token and the tick reads check runs with it", () => {
|
|
122
|
+
const steps = (parseYAMLDocument(renderCiGreenWorkflow(base)) as Yaml).jobs.tick.steps;
|
|
123
|
+
expect(steps[0].with.token).toBeUndefined();
|
|
124
|
+
expect(steps[2].env.GITHUB_TOKEN).toBe("${{ github.token }}");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("--token-secret checks out with the secret, so the push uses it, and nothing else changes", () => {
|
|
128
|
+
const plain = renderCiGreenWorkflow(base);
|
|
129
|
+
const text = renderCiGreenWorkflow({ ...base, push: { secret: "CI_GREEN_TOKEN" } });
|
|
130
|
+
const steps = (parseYAMLDocument(text) as Yaml).jobs.tick.steps;
|
|
131
|
+
expect(steps[0]).toEqual({ uses: "actions/checkout@v6", with: { ref: "main", "fetch-depth": 0, token: "${{ secrets.CI_GREEN_TOKEN }}" } });
|
|
132
|
+
// Check runs are still read with the Actions token: a fine-grained token can't call the Checks API.
|
|
133
|
+
expect(steps[2].env.GITHUB_TOKEN).toBe("${{ github.token }}");
|
|
134
|
+
expect((parseYAMLDocument(text) as Yaml).permissions).toEqual({ contents: "write", checks: "read" });
|
|
135
|
+
expect(text.replace(" token: ${{ secrets.CI_GREEN_TOKEN }}\n", "")).toBe(plain);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("--app-id-var and --app-key-secret mint an App token before the checkout and check out with it", () => {
|
|
139
|
+
const steps = (parseYAMLDocument(renderCiGreenWorkflow({ ...base, push: { appIdVar: "CI_GREEN_APP_ID", appKeySecret: "CI_GREEN_APP_KEY" } })) as Yaml).jobs.tick.steps;
|
|
140
|
+
expect(steps[0]).toMatchObject({
|
|
141
|
+
id: "app-token",
|
|
142
|
+
uses: "actions/create-github-app-token@v2",
|
|
143
|
+
with: { "app-id": "${{ vars.CI_GREEN_APP_ID }}", "private-key": "${{ secrets.CI_GREEN_APP_KEY }}" },
|
|
144
|
+
});
|
|
145
|
+
expect(steps[1].with.token).toBe("${{ steps.app-token.outputs.token }}");
|
|
146
|
+
expect(steps[3].env.GITHUB_TOKEN).toBe("${{ github.token }}");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
118
150
|
describe("chant ci workflow (#3573)", () => {
|
|
119
151
|
test("writes the file with its header, records it on the member it ran in, and writes the same bytes twice", async () => {
|
|
120
152
|
const root = scratchDir("chant-ci-workflow-");
|
|
@@ -179,6 +211,64 @@ describe("chant ci workflow (#3573)", () => {
|
|
|
179
211
|
}
|
|
180
212
|
});
|
|
181
213
|
|
|
214
|
+
test("--token-secret goes into the file and its header, and without it the bytes are what they were", async () => {
|
|
215
|
+
const root = scratchDir("chant-ci-workflow-");
|
|
216
|
+
git(root, "init", "-q");
|
|
217
|
+
writeFiles(root, { "chant.workspace.json": declaration([], { ci: { green: GREEN } }), ".github/workflows/ci.yml": CI, ".github/workflows/macos.yml": MACOS });
|
|
218
|
+
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
|
|
219
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
220
|
+
const error = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
221
|
+
try {
|
|
222
|
+
const file = join(root, CI_GREEN_WORKFLOW_FILE);
|
|
223
|
+
expect(await runCiWorkflow({ args: {} } as unknown as CommandContext)).toBe(0);
|
|
224
|
+
const plain = readFileSync(file, "utf-8");
|
|
225
|
+
expect(plain).not.toMatch(/token:|secrets\./);
|
|
226
|
+
|
|
227
|
+
expect(await runCiWorkflow({ args: { ciTokenSecret: "CI_GREEN_TOKEN" } } as unknown as CommandContext)).toBe(0);
|
|
228
|
+
const withToken = readFileSync(file, "utf-8");
|
|
229
|
+
expect(withToken.split("\n")[0]).toBe(`# ${GENERATED_MARKER}. Regenerate with: chant ci workflow --token-secret CI_GREEN_TOKEN (in the workspace root)`);
|
|
230
|
+
const steps = (parseYAMLDocument(withToken) as Yaml).jobs.tick.steps;
|
|
231
|
+
expect(steps[0].with.token).toBe("${{ secrets.CI_GREEN_TOKEN }}");
|
|
232
|
+
expect(steps[2].env.GITHUB_TOKEN).toBe("${{ github.token }}");
|
|
233
|
+
|
|
234
|
+
const app = { args: { ciAppIdVar: "CI_GREEN_APP_ID", ciAppKeySecret: "CI_GREEN_APP_KEY" } } as unknown as CommandContext;
|
|
235
|
+
expect(await runCiWorkflow(app)).toBe(0);
|
|
236
|
+
expect(readFileSync(file, "utf-8").split("\n")[0]).toBe(
|
|
237
|
+
`# ${GENERATED_MARKER}. Regenerate with: chant ci workflow --app-id-var CI_GREEN_APP_ID --app-key-secret CI_GREEN_APP_KEY (in the workspace root)`,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// Dropping the flag writes the bytes chant 0.108.0 wrote.
|
|
241
|
+
expect(await runCiWorkflow({ args: {} } as unknown as CommandContext)).toBe(0);
|
|
242
|
+
expect(readFileSync(file, "utf-8")).toBe(plain);
|
|
243
|
+
} finally {
|
|
244
|
+
cwd.mockRestore();
|
|
245
|
+
log.mockRestore();
|
|
246
|
+
error.mockRestore();
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("refuses a half-given App, both kinds of token at once, and a name GitHub wouldn't accept", async () => {
|
|
251
|
+
const root = scratchDir("chant-ci-workflow-");
|
|
252
|
+
git(root, "init", "-q");
|
|
253
|
+
writeFiles(root, { "chant.workspace.json": declaration([], { ci: { green: GREEN } }), ".github/workflows/ci.yml": CI });
|
|
254
|
+
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
|
|
255
|
+
const errors: string[] = [];
|
|
256
|
+
const error = vi.spyOn(console, "error").mockImplementation((s: unknown) => void errors.push(String(s)));
|
|
257
|
+
try {
|
|
258
|
+
const run = (args: Record<string, string>) => runCiWorkflow({ args } as unknown as CommandContext);
|
|
259
|
+
expect(await run({ ciAppIdVar: "APP_ID" })).toBe(1);
|
|
260
|
+
expect(errors.pop()).toMatch(/both the App's id and its key/);
|
|
261
|
+
expect(await run({ ciTokenSecret: "T", ciAppIdVar: "APP_ID", ciAppKeySecret: "APP_KEY" })).toBe(1);
|
|
262
|
+
expect(errors.pop()).toMatch(/one or the other/);
|
|
263
|
+
expect(await run({ ciTokenSecret: "ci-green token" })).toBe(1);
|
|
264
|
+
expect(errors.pop()).toMatch(/--token-secret takes a secret or variable name/);
|
|
265
|
+
expect(existsSync(join(root, CI_GREEN_WORKFLOW_FILE))).toBe(false);
|
|
266
|
+
} finally {
|
|
267
|
+
cwd.mockRestore();
|
|
268
|
+
error.mockRestore();
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
182
272
|
test("refuses to write a workflow nothing but the schedule would trigger", async () => {
|
|
183
273
|
const root = scratchDir("chant-ci-workflow-");
|
|
184
274
|
git(root, "init", "-q");
|
|
@@ -22,6 +22,16 @@
|
|
|
22
22
|
* the workspace root has a `package-lock.json`. chant's own repository uses
|
|
23
23
|
* both to run its source at the commit it checks out.
|
|
24
24
|
*
|
|
25
|
+
* The tick pushes its tags with the credentials actions/checkout leaves in
|
|
26
|
+
* the clone, the Actions token by default. GitHub treats a new ref to a
|
|
27
|
+
* commit whose workflow files differ from the default branch's as an update
|
|
28
|
+
* to those workflows, and the Actions token can never hold the `workflows`
|
|
29
|
+
* permission, so it is refused there. `--token-secret <NAME>` checks out
|
|
30
|
+
* with `secrets.<NAME>` instead, and `--app-id-var <VAR> --app-key-secret
|
|
31
|
+
* <NAME>` mints a GitHub App token first and checks out with that. The
|
|
32
|
+
* tick's own `GITHUB_TOKEN`, which reads check runs, stays the Actions token:
|
|
33
|
+
* a fine-grained personal access token can't call the Checks API.
|
|
34
|
+
*
|
|
25
35
|
* Like a member's pipelines (./member-pipeline.ts), the file starts with the
|
|
26
36
|
* generated-file header naming the command, and the member whose directory
|
|
27
37
|
* the command runs in records it in its generated-file record, so `WSP081`
|
|
@@ -143,14 +153,39 @@ export interface WorkflowInput {
|
|
|
143
153
|
npmLock?: string;
|
|
144
154
|
/** The workspace root relative to the repository root, `.` for the root itself. */
|
|
145
155
|
workspaceRoot: string;
|
|
156
|
+
/** How the tag push authenticates, when not with the Actions token. */
|
|
157
|
+
push?: PushToken;
|
|
146
158
|
}
|
|
147
159
|
|
|
160
|
+
/**
|
|
161
|
+
* A token the checkout persists for the tick's `git push`: a repository
|
|
162
|
+
* secret, or a GitHub App's installation token minted from its id (an
|
|
163
|
+
* Actions variable) and private key (a secret).
|
|
164
|
+
*/
|
|
165
|
+
export type PushToken = { secret: string } | { appIdVar: string; appKeySecret: string };
|
|
166
|
+
|
|
167
|
+
/** A secret or variable name GitHub accepts: letters, digits and underscores, not starting with a digit. */
|
|
168
|
+
export const ACTIONS_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
169
|
+
|
|
148
170
|
/** The workflow's YAML, without the generated-file header. Deterministic: the same input writes the same bytes. */
|
|
149
171
|
export function renderCiGreenWorkflow(input: WorkflowInput): string {
|
|
150
172
|
const { green } = input;
|
|
151
173
|
const where = input.workspaceRoot === "." ? [] : [` working-directory: ${q(input.workspaceRoot)}`];
|
|
152
174
|
const cache = input.install && input.npmLock ? [" cache: npm", ...(input.npmLock === "package-lock.json" ? [] : [` cache-dependency-path: ${q(input.npmLock)}`])] : [];
|
|
153
175
|
const tick = input.chant ? q(`${input.chant} ci tick`) : `npx --yes @intentius/chant@${input.chantVersion} ci tick`;
|
|
176
|
+
const push = input.push;
|
|
177
|
+
const mint =
|
|
178
|
+
push && "appIdVar" in push
|
|
179
|
+
? [
|
|
180
|
+
" - name: Mint a GitHub App token that may push tags to commits that change workflows",
|
|
181
|
+
" id: app-token",
|
|
182
|
+
" uses: actions/create-github-app-token@v2",
|
|
183
|
+
" with:",
|
|
184
|
+
` app-id: \${{ vars.${push.appIdVar} }}`,
|
|
185
|
+
` private-key: \${{ secrets.${push.appKeySecret} }}`,
|
|
186
|
+
]
|
|
187
|
+
: [];
|
|
188
|
+
const token = !push ? [] : ["secret" in push ? ` token: \${{ secrets.${push.secret} }}` : " token: ${{ steps.app-token.outputs.token }}"];
|
|
154
189
|
const lines = [
|
|
155
190
|
`name: ${CI_GREEN_WORKFLOW_NAME}`,
|
|
156
191
|
"",
|
|
@@ -182,10 +217,12 @@ export function renderCiGreenWorkflow(input: WorkflowInput): string {
|
|
|
182
217
|
" runs-on: ubuntu-latest",
|
|
183
218
|
" timeout-minutes: 15",
|
|
184
219
|
" steps:",
|
|
220
|
+
...mint,
|
|
185
221
|
" - uses: actions/checkout@v6",
|
|
186
222
|
" with:",
|
|
187
223
|
` ref: ${q(green.branch)}`,
|
|
188
224
|
" fetch-depth: 0",
|
|
225
|
+
...token,
|
|
189
226
|
" - uses: actions/setup-node@v6",
|
|
190
227
|
" with:",
|
|
191
228
|
" node-version: \"24\"",
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { execFileSync } from "node:child_process";
|
|
8
|
+
import { chmodSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
8
10
|
import { afterAll, describe, expect, test, vi } from "vitest";
|
|
9
11
|
import { parseDeclaration, type CiPhase } from "./declaration";
|
|
10
12
|
import { cleanScratch, contract, declaration, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
|
|
11
|
-
import { ciTick, evaluateCommit, evaluatePhase, latestAttempts, lastGreen, matchesCheckRun, windowMs, type LastGreenDocument } from "./ci-green";
|
|
13
|
+
import { ciTick, pushRefusalCause, evaluateCommit, evaluatePhase, latestAttempts, lastGreen, matchesCheckRun, windowMs, type LastGreenDocument } from "./ci-green";
|
|
12
14
|
import type { CheckRun, CiForge } from "./ci-green-forge";
|
|
13
15
|
import { runCiLastGreen } from "./ci-green-cli";
|
|
14
16
|
import lastGreenSchema from "./ci-last-green.schema.json";
|
|
@@ -281,6 +283,30 @@ describe("chant ci tick (#3573)", () => {
|
|
|
281
283
|
expect(remoteTags(origin)).toEqual([]);
|
|
282
284
|
expect(git(root, "tag", "-l")).toBe("");
|
|
283
285
|
});
|
|
286
|
+
|
|
287
|
+
test("a push GitHub refuses for lack of the workflows permission names the cause and the flags that fix it", async () => {
|
|
288
|
+
const { root, origin } = workspace();
|
|
289
|
+
const a = commitAt(root, 1, "app/a.txt");
|
|
290
|
+
push(root);
|
|
291
|
+
// The bare origin refuses tags the way GitHub does when the tagged commit changes a workflow file.
|
|
292
|
+
const hook = join(origin, "hooks", "pre-receive");
|
|
293
|
+
writeFileSync(
|
|
294
|
+
hook,
|
|
295
|
+
"#!/bin/sh\necho 'refusing to allow a GitHub App to create or update workflow `.github/workflows/check.yml` without `workflows` permission' >&2\nexit 1\n",
|
|
296
|
+
);
|
|
297
|
+
chmodSync(hook, 0o755);
|
|
298
|
+
const tick = ciTick({ cwd: root, forge: fakeForge(new Map([[a, PASSING]])), now: NOW });
|
|
299
|
+
await expect(tick).rejects.toThrow(/could not push ci\/green\/[0-9a-f]+ to origin: GitHub refused the tag push because the tagged commit changes workflow files, and GITHUB_TOKEN can't update workflows: regenerate the workflow with --token-secret <NAME>/);
|
|
300
|
+
await expect(ciTick({ cwd: root, forge: fakeForge(new Map([[a, PASSING]])), now: NOW })).rejects.toThrow(/without `workflows` permission/);
|
|
301
|
+
expect(remoteTags(origin)).toEqual([]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("other push refusals get no workflows hint", () => {
|
|
305
|
+
expect(pushRefusalCause("! [remote rejected] ci/green/abc -> ci/green/abc (refusing to allow a GitHub App to create or update workflow .github/workflows/check.yml without workflows permission)")).toMatch(/--token-secret/);
|
|
306
|
+
expect(pushRefusalCause("refusing to allow an OAuth App to create or update workflow `.github/workflows/x.yml` without `workflow` scope")).toMatch(/--app-id-var/);
|
|
307
|
+
expect(pushRefusalCause("! [rejected] ci/green/abc -> ci/green/abc (already exists)")).toBeNull();
|
|
308
|
+
expect(pushRefusalCause("remote: Permission to o/r.git denied to github-actions[bot].")).toBeNull();
|
|
309
|
+
});
|
|
284
310
|
});
|
|
285
311
|
|
|
286
312
|
// ── last-green ───────────────────────────────────────────────────────────────
|
|
@@ -326,12 +326,28 @@ export async function ciTick(req: TickRequest): Promise<TickResult> {
|
|
|
326
326
|
git(top, ["push", "--quiet", remote, ...due.map((c) => `refs/tags/${c.made!.tag}`)]);
|
|
327
327
|
} catch (err) {
|
|
328
328
|
// The local tags stay until the next tick's fetch prunes the ones the remote lacks.
|
|
329
|
-
|
|
329
|
+
const stderr = stderrOf(err);
|
|
330
|
+
const cause = pushRefusalCause(stderr);
|
|
331
|
+
throw new Error(`could not push ${due.map((c) => c.made!.tag).join(", ")} to ${remote}: ${cause ? `${cause}\n` : ""}${stderr}`);
|
|
330
332
|
}
|
|
331
333
|
}
|
|
332
334
|
return { branch: green.branch, window: green.window, ref: tracking, commits: planned, made: due.map((c) => c.made!.tag) };
|
|
333
335
|
}
|
|
334
336
|
|
|
337
|
+
/**
|
|
338
|
+
* Why the remote refused a tag push, when chant can tell. GitHub refuses a
|
|
339
|
+
* new ref to a commit whose workflow files differ from the default branch's
|
|
340
|
+
* unless the token may update workflows, and the Actions token never may.
|
|
341
|
+
*/
|
|
342
|
+
export function pushRefusalCause(stderr: string): string | null {
|
|
343
|
+
if (!/without [`']?workflows?[`']? (permission|scope)/i.test(stderr)) return null;
|
|
344
|
+
return (
|
|
345
|
+
"GitHub refused the tag push because the tagged commit changes workflow files, and GITHUB_TOKEN can't update workflows: " +
|
|
346
|
+
"regenerate the workflow with --token-secret <NAME>, naming a secret that holds a fine-grained token with Contents and Workflows read and write, " +
|
|
347
|
+
"or with --app-id-var <VAR> --app-key-secret <NAME> for a GitHub App's token"
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
335
351
|
/** A tagger for an annotated tag when git has none configured, as on a fresh CI runner. The environment's GIT_COMMITTER_* still win. */
|
|
336
352
|
function taggerConfig(top: string): string[] {
|
|
337
353
|
const out: string[] = [];
|