@starci/hfs 4.7.12 → 4.7.14
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/CHANGELOG.md +11 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/release/gate.mjs +388 -378
- package/release/lint-report.mjs +52 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
- package/runtime/knowledge/hfs/ci-standard.yaml +1 -1
- package/runtime/knowledge/hfs/rules.yaml +7 -6
- package/runtime/knowledge/patterns/be/cli.yaml +1 -0
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -0
- package/runtime/knowledge/patterns/be/webhooks.yaml +1 -0
- package/runtime/modules/kernel/failure-codes.yaml +24 -2
- package/runtime/packages/cli/src/catalog.generated.mjs +12 -6
- package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
- package/runtime/scripts/hfs/architecture/hfs.mjs +19 -4
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +6 -4
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +1 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +5 -2
- package/runtime/scripts/hfs/db-baseline-verb.mjs +5 -2
- package/runtime/scripts/hfs/doc-commands.mjs +2 -1
- package/runtime/scripts/hfs/rule-catalog.mjs +5 -2
- package/runtime/scripts/hfs/rules/coverage-below.mjs +5 -3
- package/runtime/scripts/hfs/rules/database-access.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-migrations.mjs +13 -3
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +1 -1
- package/runtime/scripts/hfs/rules/database-queues.mjs +2 -2
- package/runtime/scripts/hfs/rules/deploy-scan.mjs +50 -6
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +11 -1
- package/runtime/scripts/hfs/rules/doc-fresh.mjs +18 -3
- package/runtime/scripts/hfs/rules/docker-health.mjs +4 -1
- package/runtime/scripts/hfs/rules/env-parity.mjs +8 -7
- package/runtime/scripts/hfs/rules/env-value-shape.mjs +2 -4
- package/runtime/scripts/hfs/rules/fe-build-args.mjs +1 -1
- package/runtime/scripts/hfs/rules/release-provenance.mjs +8 -5
- package/runtime/scripts/hfs/rules/script-hygiene.mjs +23 -7
- package/runtime/scripts/hfs/rules/services.mjs +19 -5
- package/runtime/scripts/hfs/rules/stack-tooling.mjs +5 -2
- package/runtime/scripts/hfs/rules/stacks-swarm.mjs +5 -5
- package/runtime/scripts/hfs/rules/staged-specs.mjs +26 -0
- package/runtime/scripts/hfs/rules/supply-chain.mjs +3 -3
- package/runtime/scripts/hfs/rules/unused-symbol-properties.mjs +1 -1
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +8 -4
- package/runtime/scripts/hfs/sealed-members.mjs +1 -1
- package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +1 -1
- package/runtime/scripts/hfs/secret-files.mjs +1 -1
- package/runtime/scripts/hfs/secret-sops.mjs +1 -1
- package/runtime/scripts/hfs/stack-deploy-config.mjs +19 -2
- package/runtime/scripts/lib/env.mjs +2 -1
- package/runtime/scripts/lib/git-hooks-dir.mjs +30 -0
- package/runtime/scripts/lib/path-key.mjs +1 -1
- package/runtime/scripts/lib/roots.mjs +3 -3
- package/runtime/scripts/lib/secret-patterns.mjs +1 -1
- package/runtime/scripts/lib/swarm-stack.mjs +4 -2
- package/src/main.mjs +2 -2
- package/sync/ci-commands.mjs +275 -275
- package/sync/hygiene.mjs +19 -4
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -0
package/release/gate.mjs
CHANGED
|
@@ -1,378 +1,388 @@
|
|
|
1
|
-
// gate.mjs - `starci app release gate`: the honest release gate. It runs the app's own gates on a clean HEAD and when all required gates pass it
|
|
2
|
-
// writes the L4 record <git-common-dir>/starci-release/<HEAD sha>.l4.json (head, tag, per-gate log path and sha256) and creates the annotated tag.
|
|
3
|
-
// The canon pre-push hook lets main and v* tags through only for a HEAD with that tag and that record. Nothing is pushed here (see push.mjs).
|
|
4
|
-
// The gate list comes from the optional `release:` block of .starcistacks/application-stacks.yaml, else from the app's managed package scripts.
|
|
5
|
-
// Commands are argument arrays (never shell strings) run through ctx.spawn; a git tool call always goes through the same seam.
|
|
6
|
-
import { createHash } from 'node:crypto';
|
|
7
|
-
import fs from 'node:fs';
|
|
8
|
-
import path from 'node:path';
|
|
9
|
-
import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
|
|
10
|
-
import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
|
|
11
|
-
import { firstLine, lines, tool } from '../stack/context.mjs';
|
|
12
|
-
import { failure, refusal } from '../stack/errors.mjs';
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (!
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
if (!
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (run.
|
|
135
|
-
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
return { tag, state:
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const
|
|
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
|
-
const log = path.join(options.dir, `${gate.name}.log`);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
};
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (
|
|
267
|
-
|
|
268
|
-
return {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (
|
|
276
|
-
|
|
277
|
-
if (
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (
|
|
285
|
-
return
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
313
|
-
out.push(
|
|
314
|
-
return out;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function
|
|
318
|
-
const
|
|
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
|
-
return
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
const
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
return { ok: true, lines: [...plan,
|
|
378
|
-
}
|
|
1
|
+
// gate.mjs - `starci app release gate`: the honest release gate. It runs the app's own gates on a clean HEAD and when all required gates pass it
|
|
2
|
+
// writes the L4 record <git-common-dir>/starci-release/<HEAD sha>.l4.json (head, tag, per-gate log path and sha256) and creates the annotated tag.
|
|
3
|
+
// The canon pre-push hook lets main and v* tags through only for a HEAD with that tag and that record. Nothing is pushed here (see push.mjs).
|
|
4
|
+
// The gate list comes from the optional `release:` block of .starcistacks/application-stacks.yaml, else from the app's managed package scripts.
|
|
5
|
+
// Commands are argument arrays (never shell strings) run through ctx.spawn; a git tool call always goes through the same seam.
|
|
6
|
+
import { createHash } from 'node:crypto';
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
|
|
10
|
+
import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
|
|
11
|
+
import { firstLine, lines, tool } from '../stack/context.mjs';
|
|
12
|
+
import { failure, refusal } from '../stack/errors.mjs';
|
|
13
|
+
import { produceLintReports } from './lint-report.mjs';
|
|
14
|
+
|
|
15
|
+
export const RELEASE_CODE = 'RELEASE_CONFIG_INVALID';
|
|
16
|
+
const SONAR_UNREACHABLE = 'SONAR_UNREACHABLE';
|
|
17
|
+
|
|
18
|
+
const FIELDS = new Set(['gates', 'tagPrefix', 'defaultBranch', 'tagMatchesVersion']);
|
|
19
|
+
const GATE_FIELDS = new Set(['name', 'command', 'summary', 'env']);
|
|
20
|
+
const GATE_NAME = /^[a-z][a-z0-9-]*$/u;
|
|
21
|
+
const TOKEN = /^[A-Za-z0-9_@%+=:,./\\-]+$/u;
|
|
22
|
+
const ENV_KEY = /^[A-Z][A-Z0-9_]*$/u;
|
|
23
|
+
const REF_NAME = /^[A-Za-z0-9._/][A-Za-z0-9._/-]*$/u;
|
|
24
|
+
const PREFIX = /^[A-Za-z][A-Za-z0-9._-]{0,19}$/u;
|
|
25
|
+
export const VERSION = /^(\d+)\.(\d+)\.(\d+)$/u;
|
|
26
|
+
const THRESHOLD_MISS = /coverage threshold .* not met/gu;
|
|
27
|
+
const WRAPPED = new Set(['npm', 'npx']);
|
|
28
|
+
const SCRIPT_GATES = ['typecheck', 'build:be', 'lint', 'build:fe', 'test'];
|
|
29
|
+
const MAX_PATCH = 100;
|
|
30
|
+
|
|
31
|
+
/** True for a branch or remote name git can take as one argument (no leading dash, only path-like characters). */
|
|
32
|
+
export const refName = (value) => typeof value === 'string' && REF_NAME.test(value);
|
|
33
|
+
|
|
34
|
+
const scriptGate = (script) => ({
|
|
35
|
+
name: script.replaceAll(':', '-'),
|
|
36
|
+
command: ['npm', 'run', script],
|
|
37
|
+
summary: script === 'test' ? 'jest' : null,
|
|
38
|
+
env: {},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Default gates run app-check, the enabled audit, then every managed script present in the app. */
|
|
42
|
+
function defaultGates(scripts, audit) {
|
|
43
|
+
const present = SCRIPT_GATES.filter((script) => typeof scripts?.[script] === 'string');
|
|
44
|
+
return [{ name: 'app-check', command: ['npx', 'starci', 'app', 'check'], summary: null, env: {} }, ...(audit ? [audit] : []), ...present.map(scriptGate)];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Shared quality settings retain the release reader's audit validation, disabled mode and legacy string host compatibility. */
|
|
48
|
+
function releaseServices(services, options) {
|
|
49
|
+
const audit = services?.audit ?? {};
|
|
50
|
+
if (!plain(audit) || (audit.in !== undefined && (!Array.isArray(audit.in) || !audit.in.every((scope) => typeof scope === 'string'))) || (audit.omitDev !== undefined && typeof audit.omitDev !== 'boolean')) throw refusal(RELEASE_CODE, 'services.audit must be a mapping with a string-list in and boolean omitDev');
|
|
51
|
+
const sonar = services?.sonar;
|
|
52
|
+
const compatible = typeof sonar?.host === 'string' ? { ...sonar, host: { public: sonar.host } } : sonar;
|
|
53
|
+
const quality = qualityServices({ services: { ...services, sonar: compatible } }, options);
|
|
54
|
+
if (audit.mode === 'disabled') quality.audit = null;
|
|
55
|
+
return quality;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The shared audit settings preserve release scope, level and development-dependency selection. */
|
|
59
|
+
function auditGate(audit) {
|
|
60
|
+
if (!audit || !audit.in.includes('release-gate')) return null;
|
|
61
|
+
if (!['info', 'low', 'moderate', 'high', 'critical'].includes(audit.level)) throw refusal(RELEASE_CODE, 'services.audit.level must be an npm audit level');
|
|
62
|
+
return { name: 'audit', command: ['npm', 'audit', ...(audit.omitDev ? ['--omit=dev'] : []), `--audit-level=${audit.level}`], summary: null, env: {} };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A requested local Sonar scan uses only the declared host and token variable; the token never enters argv. */
|
|
66
|
+
function sonarGate(services, env) {
|
|
67
|
+
const configured = services?.sonar;
|
|
68
|
+
const sonar = configured?.provider === 'sonarcloud' ? { ...configured, host: 'https://sonarcloud.io' } : configured;
|
|
69
|
+
const tokenName = sonar?.token;
|
|
70
|
+
if (!sonar?.host || !ENV_KEY.test(tokenName) || !env[tokenName]) throw refusal('RELEASE_SONAR_UNAVAILABLE', 'declare an enabled services.sonar host and set its ci.token environment variable');
|
|
71
|
+
const command = ['npx', '--yes', '@sonar/scan@4.3.0', `-Dsonar.host.url=${sonar.host}`];
|
|
72
|
+
const organization = sonar.organization ?? (sonar.organizationVar ? env[sonar.organizationVar] : null);
|
|
73
|
+
if (organization) command.push(`-Dsonar.organization=${organization}`);
|
|
74
|
+
if (!command.every((word) => TOKEN.test(word))) throw refusal('RELEASE_SONAR_UNAVAILABLE', 'Sonar host and organization must be plain command arguments');
|
|
75
|
+
return { name: 'sonar', command, summary: null, env: { SONAR_TOKEN: env[tokenName] }, provider: sonar,
|
|
76
|
+
credentialNames: [...new Set(['SONAR_TOKEN', tokenName])] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function gateProblems(entry, at, seen) {
|
|
80
|
+
const found = [];
|
|
81
|
+
for (const key of Object.keys(entry)) if (!GATE_FIELDS.has(key)) found.push(`${at}.${key}: is not a field of a gate`);
|
|
82
|
+
if (typeof entry.name !== 'string' || !GATE_NAME.test(entry.name)) found.push(`${at}.name: must match ${GATE_NAME}`);
|
|
83
|
+
else if (seen.has(entry.name)) found.push(`${at}.name: ${entry.name} is declared twice`);
|
|
84
|
+
const command = entry.command;
|
|
85
|
+
if (!Array.isArray(command) || command.length === 0 || !command.every((word) => typeof word === 'string' && TOKEN.test(word))) found.push(`${at}.command: must be a non-empty list of plain words (no shell strings)`);
|
|
86
|
+
if (entry.summary !== undefined && entry.summary !== 'jest') found.push(`${at}.summary: must be jest`);
|
|
87
|
+
const env = entry.env ?? {};
|
|
88
|
+
if (!plain(env) || !Object.entries(env).every(([key, value]) => ENV_KEY.test(key) && typeof value === 'string')) found.push(`${at}.env: must map UPPER_CASE names to string values`);
|
|
89
|
+
return found;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function gatesOf(list, problems) {
|
|
93
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
94
|
+
problems.push('release.gates: must be a non-empty list');
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
const seen = new Set();
|
|
98
|
+
const gates = [];
|
|
99
|
+
list.forEach((entry, index) => {
|
|
100
|
+
const at = `release.gates[${index}]`;
|
|
101
|
+
const found = plain(entry) ? gateProblems(entry, at, seen) : [`${at}: must be a mapping`];
|
|
102
|
+
problems.push(...found);
|
|
103
|
+
if (found.length === 0) {
|
|
104
|
+
seen.add(entry.name);
|
|
105
|
+
gates.push({ name: entry.name, command: entry.command, summary: entry.summary ?? null, env: entry.env ?? {} });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
return gates;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Judges the optional `release:` block. Absence is fine (defaults). Returns { config } or { problems }. */
|
|
112
|
+
export function normalizeRelease(block, scripts = {}, services = {}, options = {}) {
|
|
113
|
+
const raw = block ?? {};
|
|
114
|
+
if (!plain(raw)) return { problems: ['release: must be a mapping'] };
|
|
115
|
+
const problems = Object.keys(raw).filter((key) => !FIELDS.has(key)).map((key) => `release.${key}: is not a field of the release block`);
|
|
116
|
+
const tagPrefix = raw.tagPrefix ?? 'v';
|
|
117
|
+
const defaultBranch = raw.defaultBranch ?? 'main';
|
|
118
|
+
const tagMatchesVersion = raw.tagMatchesVersion ?? false;
|
|
119
|
+
if (typeof tagPrefix !== 'string' || !PREFIX.test(tagPrefix)) problems.push(`release.tagPrefix: must match ${PREFIX}`);
|
|
120
|
+
if (!refName(defaultBranch)) problems.push(`release.defaultBranch: must match ${REF_NAME}`);
|
|
121
|
+
if (typeof tagMatchesVersion !== 'boolean') problems.push('release.tagMatchesVersion: must be true or false');
|
|
122
|
+
const quality = releaseServices(services, options);
|
|
123
|
+
const audit = auditGate(quality.audit);
|
|
124
|
+
const gates = raw.gates === undefined ? defaultGates(scripts, audit) : gatesOf(raw.gates, problems);
|
|
125
|
+
if (raw.gates !== undefined && audit && !gates.some((gate) => gate.name === 'audit')) gates.push(audit);
|
|
126
|
+
return problems.length === 0 ? { config: { gates, tagPrefix, defaultBranch, tagMatchesVersion, services: quality } } : { problems };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* ---- git through the seam */
|
|
130
|
+
|
|
131
|
+
/** Runs git in the app root; a missing git or (unless allowFail) a non-zero exit is a failure whose message names the first stderr line. */
|
|
132
|
+
export function git(ctx, args, { allowFail = false } = {}) {
|
|
133
|
+
const run = tool(ctx, 'git', args, { cwd: ctx.cwd, env: ctx.env });
|
|
134
|
+
if (run.error) throw failure('RELEASE_GIT_MISSING', `cannot run git (${run.error.message})`);
|
|
135
|
+
if (run.status !== 0 && !allowFail) throw failure('RELEASE_GIT_FAILED', `git ${args.slice(0, 2).join(' ')}: ${firstLine(run.stderr)}`);
|
|
136
|
+
return { code: run.status, stdout: String(run.stdout ?? '').trim(), stderr: String(run.stderr ?? '') };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The directory that holds the L4 records: <git common dir>/starci-release. */
|
|
140
|
+
export const releaseDir = (ctx) => path.join(path.resolve(ctx.cwd, git(ctx, ['rev-parse', '--git-common-dir']).stdout), 'starci-release');
|
|
141
|
+
|
|
142
|
+
/** Annotated tags `<prefix><digit>*` that point at HEAD (lightweight tags do not count: the hook ignores them). */
|
|
143
|
+
export function annotatedOnHead(ctx, prefix) {
|
|
144
|
+
const names = lines(git(ctx, ['tag', '--points-at', 'HEAD', '--list', `${prefix}[0-9]*`]).stdout);
|
|
145
|
+
return names.filter((name) => git(ctx, ['cat-file', '-t', `refs/tags/${name}`], { allowFail: true }).stdout === 'tag');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const dirtyPaths = (ctx) => lines(git(ctx, ['status', '--porcelain', '--untracked-files=normal']).stdout);
|
|
149
|
+
|
|
150
|
+
/** Tag names that exist locally or on the remote. */
|
|
151
|
+
function takenTags(ctx, prefix, remote) {
|
|
152
|
+
const local = lines(git(ctx, ['tag', '--list', `${prefix}*`]).stdout);
|
|
153
|
+
const listed = lines(git(ctx, ['ls-remote', '--tags', remote], { allowFail: true }).stdout);
|
|
154
|
+
const remoteNames = listed.map((line) => line.split('refs/tags/')[1]?.replace(/\^\{\}$/u, '')).filter(Boolean);
|
|
155
|
+
return new Set([...local, ...remoteNames]);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Next free `<prefix><major>.<minor>.<n>` for the package version. */
|
|
159
|
+
function nextFree(prefix, version, taken) {
|
|
160
|
+
const [, major, minor] = VERSION.exec(version);
|
|
161
|
+
for (let patch = 0; patch < MAX_PATCH; patch += 1) {
|
|
162
|
+
const candidate = `${prefix}${major}.${minor}.${patch}`;
|
|
163
|
+
if (!taken.has(candidate)) return candidate;
|
|
164
|
+
}
|
|
165
|
+
throw refusal('RELEASE_NO_FREE_TAG', `no free tag ${prefix}${major}.${minor}.0 to ${prefix}${major}.${minor}.${MAX_PATCH - 1}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The tag this release uses and its state: on-head (reused), free (will be created) or taken (exists on another commit). */
|
|
169
|
+
function chooseTag(ctx, config, { prefix, remote }) {
|
|
170
|
+
const onHead = annotatedOnHead(ctx, prefix);
|
|
171
|
+
const taken = takenTags(ctx, prefix, remote);
|
|
172
|
+
const exact = `${prefix}${config.version}`;
|
|
173
|
+
const tag = config.tagMatchesVersion ? exact : (onHead[0] ?? nextFree(prefix, config.version, taken));
|
|
174
|
+
if (onHead.includes(tag)) return { tag, state: 'on-head' };
|
|
175
|
+
return { tag, state: taken.has(tag) ? 'taken' : 'free' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* ---- gate results */
|
|
179
|
+
|
|
180
|
+
const sha256 = (file) => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
181
|
+
|
|
182
|
+
const lineAfter = (text, label) => {
|
|
183
|
+
const found = text.split('\n').find((line) => line.startsWith(label));
|
|
184
|
+
return found === undefined ? null : found.slice(label.length).trim();
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** The jest summary lines of a log: { suites, failedSuites, tests, failed, passed } or null when jest printed none. */
|
|
188
|
+
function jestSummary(text) {
|
|
189
|
+
const suites = lineAfter(text, 'Test Suites:');
|
|
190
|
+
const tests = lineAfter(text, 'Tests:');
|
|
191
|
+
if (suites === null || tests === null) return null;
|
|
192
|
+
const count = (line, word) => Number(new RegExp(String.raw`(\d+) ${word}`, 'u').exec(line)?.[1] ?? 0);
|
|
193
|
+
return {
|
|
194
|
+
suites: count(suites, 'total'), failedSuites: count(suites, 'failed'),
|
|
195
|
+
tests: count(tests, 'total'), failed: count(tests, 'failed'), passed: count(tests, 'passed'),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Judges a jest-style gate from its log: green needs suites that all passed, every test passed and no coverage threshold miss. */
|
|
200
|
+
function judgeJest(text) {
|
|
201
|
+
const summary = jestSummary(text);
|
|
202
|
+
const misses = (text.match(THRESHOLD_MISS) ?? []).length;
|
|
203
|
+
if (!summary) return { pass: false, detail: 'no jest summary in the log', summary: null, misses };
|
|
204
|
+
const pass = summary.suites > 0 && summary.failedSuites === 0 && summary.failed === 0 && summary.passed === summary.tests && misses === 0;
|
|
205
|
+
const detail = `suites ${summary.suites - summary.failedSuites}/${summary.suites}, tests ${summary.passed}/${summary.tests}, coverage-threshold misses ${misses}`;
|
|
206
|
+
return { pass, detail, summary, misses };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** [command, args] to start an argv; on Windows npm and npx are .cmd files, so they start through cmd.exe (words are validated, never a shell string). */
|
|
210
|
+
export function launch(argv, platform) {
|
|
211
|
+
if (platform === 'win32' && WRAPPED.has(argv[0])) return ['cmd.exe', ['/d', '/s', '/c', ...argv]];
|
|
212
|
+
return [argv[0], argv.slice(1)];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Sonar availability is public and bounded; HTTP uses the existing injected fetch seam. */
|
|
216
|
+
async function sonarAvailability(ctx, gate) {
|
|
217
|
+
const sonar = gate.provider;
|
|
218
|
+
if (!sonar) return null;
|
|
219
|
+
let up = false;
|
|
220
|
+
try {
|
|
221
|
+
const response = await ctx.fetch(sonar.host.replace(/\/$/u, '') + '/api/system/status', { signal: AbortSignal.timeout(10000) });
|
|
222
|
+
up = response.ok && (await response.json()).status === 'UP';
|
|
223
|
+
} catch { up = false; }
|
|
224
|
+
if (up) return null;
|
|
225
|
+
const detail = sonar.optional
|
|
226
|
+
? `notice: sonar skipped: ${sonar.host} not reachable (services.sonar.optional)`
|
|
227
|
+
: `${SONAR_UNREACHABLE}: ${sonar.host} did not report UP within ten seconds`;
|
|
228
|
+
return { skipped: sonar.optional === true, pass: false, detail };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Unreachable optional providers remain explicit skipped rows, with a real log and no scan invocation. */
|
|
232
|
+
async function runAvailableGate(ctx, gate, options) {
|
|
233
|
+
const availability = await sonarAvailability(ctx, gate);
|
|
234
|
+
const log = path.join(options.dir, `${gate.name}.log`);
|
|
235
|
+
if (!availability) {
|
|
236
|
+
if (!gate.provider) return runGate(ctx, gate, options);
|
|
237
|
+
// The scanner imports the app's declared external issues report: produce it first with the app's own lint, as the managed CI does.
|
|
238
|
+
const started = ctx.now();
|
|
239
|
+
const lint = produceLintReports(ctx, { launch, platform: options.platform, env: withoutCredentials(ctx.env, options.credentialNames) });
|
|
240
|
+
if (lint.pass) return runGate(ctx, gate, { ...options, preface: lint.text });
|
|
241
|
+
fs.writeFileSync(log, lint.text);
|
|
242
|
+
return { name: gate.name, command: gate.command.join(' '), pass: false, detail: 'lint report failed (see log)', seconds: Math.round((ctx.now() - started) / 1000),
|
|
243
|
+
summary: null, log: path.resolve(log), logSha256: sha256(log), env: Object.keys(gate.env) };
|
|
244
|
+
}
|
|
245
|
+
fs.writeFileSync(log, availability.detail + '\n');
|
|
246
|
+
return { name: gate.name, command: gate.command.join(' '), ...availability, seconds: 0,
|
|
247
|
+
summary: null, log: path.resolve(log), logSha256: sha256(log), env: Object.keys(gate.env) };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const withoutCredentials = (values, credentialNames) => {
|
|
251
|
+
const selected = new Set(credentialNames);
|
|
252
|
+
return Object.fromEntries(Object.entries(values ?? {}).filter(([key]) => !selected.has(key.toUpperCase())));
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
function runGate(ctx, gate, { dir, platform, credentialNames, preface = '' }) {
|
|
256
|
+
const log = path.join(dir, `${gate.name}.log`);
|
|
257
|
+
const started = ctx.now();
|
|
258
|
+
const [command, args] = launch(gate.command, platform);
|
|
259
|
+
// The native scanner alone consumes Sonar credentials; ordinary gates cannot inherit or reintroduce them.
|
|
260
|
+
const env = { ...ctx.env, ...(gate.provider ? gate.env : withoutCredentials(gate.env, credentialNames)) };
|
|
261
|
+
const run = tool(ctx, command, args, { cwd: ctx.cwd, env });
|
|
262
|
+
const output = `${run.stdout ?? ''}${run.stderr ?? ''}`;
|
|
263
|
+
const text = gate.name === 'sonar' && gate.env.SONAR_TOKEN ? output.replaceAll(gate.env.SONAR_TOKEN, '[REDACTED]') : output;
|
|
264
|
+
fs.writeFileSync(log, preface + text);
|
|
265
|
+
const verdict = judgeVerdict(run, gate, text);
|
|
266
|
+
if (gate.name === 'sonar' && gate.env.SONAR_TOKEN) verdict.detail = verdict.detail.replaceAll(gate.env.SONAR_TOKEN, '[REDACTED]');
|
|
267
|
+
const seconds = Math.round((ctx.now() - started) / 1000);
|
|
268
|
+
return {
|
|
269
|
+
name: gate.name, command: gate.command.join(' '), pass: verdict.pass, detail: verdict.detail, seconds,
|
|
270
|
+
summary: verdict.summary ?? null, log: path.resolve(log), logSha256: sha256(log), env: Object.keys(gate.env),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function judgeVerdict(run, gate, text) {
|
|
275
|
+
if (run.error) return { pass: false, detail: `cannot run (${run.error.message})` };
|
|
276
|
+
if (run.status !== 0) return { pass: false, detail: `exit ${run.status}` };
|
|
277
|
+
if (gate.summary === 'jest') return judgeJest(text);
|
|
278
|
+
return { pass: true, detail: 'exit 0' };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* ---- the gate */
|
|
282
|
+
|
|
283
|
+
function sourceProblem(ctx, source, head) {
|
|
284
|
+
if (source === 'HEAD') return null;
|
|
285
|
+
if (!refName(source)) return `--source ${source} is not a ref name`;
|
|
286
|
+
const resolved = git(ctx, ['rev-parse', '--verify', `${source}^{commit}`], { allowFail: true });
|
|
287
|
+
if (resolved.code !== 0) return `cannot resolve --source ${source}`;
|
|
288
|
+
if (resolved.stdout !== head) return `--source ${source} is ${resolved.stdout.slice(0, 10)} but HEAD is ${head.slice(0, 10)}: the hook and the L4 record are about HEAD; check the candidate out first`;
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function tagLine({ tag, state }) {
|
|
293
|
+
if (state === 'on-head') return `tag ${tag} (annotated, already on HEAD)`;
|
|
294
|
+
if (state === 'taken') return `tag ${tag} (TAKEN by another commit)`;
|
|
295
|
+
return `tag ${tag} (free: will be created, annotated)`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Everything that can be known without running a gate: the facts, the red preconditions and the lines that describe them. */
|
|
299
|
+
function inspect(ctx, config, options) {
|
|
300
|
+
const head = git(ctx, ['rev-parse', 'HEAD']).stdout;
|
|
301
|
+
const branch = git(ctx, ['rev-parse', '--abbrev-ref', 'HEAD'], { allowFail: true }).stdout;
|
|
302
|
+
const out = [`checkout ${ctx.cwd}`, `HEAD ${head} (${branch})`];
|
|
303
|
+
const red = [];
|
|
304
|
+
const problem = sourceProblem(ctx, options.source ?? 'HEAD', head);
|
|
305
|
+
if (problem) red.push(problem);
|
|
306
|
+
const dirty = dirtyPaths(ctx);
|
|
307
|
+
if (dirty.length > 0) red.push(`working tree is not clean (${dirty.length} path(s), first: ${dirty[0].slice(0, 100)}): the record would vouch for files that are not in HEAD`);
|
|
308
|
+
else out.push('worktree clean');
|
|
309
|
+
const prefix = options.tagPrefix ?? config.tagPrefix;
|
|
310
|
+
if (!PREFIX.test(prefix)) throw refusal('RELEASE_USAGE', `--tag-prefix must match ${PREFIX}`);
|
|
311
|
+
const chosen = chooseTag(ctx, config, { prefix, remote: 'origin' });
|
|
312
|
+
if (chosen.state === 'taken') red.push(`tag ${chosen.tag} already exists on another commit (local or remote)`);
|
|
313
|
+
out.push(tagLine(chosen));
|
|
314
|
+
return { head, chosen, red, out };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function planLines(config, facts, dir, recordPath) {
|
|
318
|
+
const out = [`L4 record ${recordPath}`, `logs ${dir}`, '', 'gates (required gates must pass, optional provider outages are skipped, in this order):'];
|
|
319
|
+
for (const gate of config.gates) {
|
|
320
|
+
const summary = gate.summary ? ` (summary: ${gate.summary})` : '';
|
|
321
|
+
out.push(` ${gate.name.padEnd(17)} ${gate.command.join(' ')}${summary}`);
|
|
322
|
+
}
|
|
323
|
+
out.push(`then: write ${path.basename(recordPath)} and \`git tag -a ${facts.chosen.tag}\` on ${facts.head.slice(0, 10)}; push it with \`starci app release push\`.`);
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function assertUnmoved(ctx, head) {
|
|
328
|
+
const now = git(ctx, ['rev-parse', 'HEAD']).stdout;
|
|
329
|
+
if (now !== head) throw failure('RELEASE_HEAD_MOVED', `HEAD moved during the gates (${head.slice(0, 10)} -> ${now.slice(0, 10)}). No record.`);
|
|
330
|
+
const dirty = dirtyPaths(ctx);
|
|
331
|
+
if (dirty.length > 0) throw failure('RELEASE_TREE_DIRTY', `the gates left ${dirty.length} changed or untracked path(s) (first: ${dirty[0].slice(0, 100)}): generated files differ from HEAD. Commit or ignore them properly, then rerun. No record.`);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function writeRecord(ctx, { head, tag, results, recordPath }) {
|
|
335
|
+
const record = {
|
|
336
|
+
schema: 'starci-app-l4-record/1', head, tag, createdAt: new Date(ctx.now()).toISOString(), producedBy: 'starci app release gate',
|
|
337
|
+
note: 'Required gates passed on a clean HEAD; optional provider skips are explicit and every entry links its log and sha256.',
|
|
338
|
+
gates: results,
|
|
339
|
+
};
|
|
340
|
+
fs.mkdirSync(path.dirname(recordPath), { recursive: true });
|
|
341
|
+
fs.writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Gates run sequentially because each failure prevents subsequent commands and record creation. */
|
|
345
|
+
async function runAll(ctx, config, options) {
|
|
346
|
+
fs.mkdirSync(options.dir, { recursive: true });
|
|
347
|
+
return config.gates.reduce(async (pending, gate) => {
|
|
348
|
+
const results = await pending;
|
|
349
|
+
if (results.some((result) => !result.pass && !result.skipped)) return results;
|
|
350
|
+
return [...results, await runAvailableGate(ctx, gate, options)];
|
|
351
|
+
}, Promise.resolve([]));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function summaryOf(result) {
|
|
355
|
+
let status = result.pass ? 'PASS' : 'FAIL';
|
|
356
|
+
if (result.skipped) status = 'SKIPPED';
|
|
357
|
+
return `${status} ${result.name} (${result.detail}, ${result.seconds}s) log ${result.log}`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* `starci app release gate`: dry run prints the preconditions, the gate list, the head and the tag; --execute runs every gate and, only if required gates pass,
|
|
362
|
+
* writes the L4 record and creates the annotated tag. options: { execute, sonar, source, tagPrefix, platform }.
|
|
363
|
+
*/
|
|
364
|
+
export async function releaseGate(ctx, config, options = {}) {
|
|
365
|
+
if (options.sonar) config = { ...config, gates: [...config.gates.filter((gate) => gate.name !== 'sonar'), sonarGate(config.services, ctx.env)] };
|
|
366
|
+
const credentialNames = config.gates.find(gate => gate.provider)?.credentialNames ?? ['SONAR_TOKEN', config.services?.sonar?.token].filter(Boolean);
|
|
367
|
+
ctx = Object.create(ctx, {env: {value: withoutCredentials(ctx.env, credentialNames)}});
|
|
368
|
+
// The CI verdict of a release is the candidate run of its own sha, judged by `starci app release push` (candidate-ci.mjs, OWNER RULING candidate-ci 2026-10-18):
|
|
369
|
+
// the gate no longer refuses over the latest default-branch run.
|
|
370
|
+
const facts = inspect(ctx, config, options);
|
|
371
|
+
const common = releaseDir(ctx);
|
|
372
|
+
const recordPath = path.join(common, `${facts.head}.l4.json`);
|
|
373
|
+
const dir = path.join(common, facts.head.slice(0, 12));
|
|
374
|
+
const plan = [...facts.out, ...planLines(config, facts, dir, recordPath)];
|
|
375
|
+
const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name) };
|
|
376
|
+
if (facts.red.length > 0) return { ok: false, lines: [...plan, ...facts.red.map((item) => `RED: ${item}`), 'nothing was run.'], data: { ...base, red: facts.red, executed: false } };
|
|
377
|
+
if (options.execute !== true) return { ok: true, lines: [...plan, '', 'dry run: no gate was run, nothing written. preconditions OK.'], data: { ...base, red: [], executed: false } };
|
|
378
|
+
const results = await runAll(ctx, config, { dir, platform: options.platform ?? process.platform, credentialNames });
|
|
379
|
+
const report = results.map(summaryOf);
|
|
380
|
+
const failed = results.filter((result) => !result.pass && !result.skipped);
|
|
381
|
+
if (failed.length > 0) return { ok: false, lines: [...plan, ...report, `gate(s) red: ${failed.map((item) => item.name).join(', ')}. No L4 record, no tag.`], data: { ...base, executed: true, results } };
|
|
382
|
+
assertUnmoved(ctx, facts.head);
|
|
383
|
+
writeRecord(ctx, { head: facts.head, tag: facts.chosen.tag, results, recordPath });
|
|
384
|
+
const created = facts.chosen.state !== 'on-head';
|
|
385
|
+
if (created) git(ctx, ['tag', '-a', facts.chosen.tag, '-m', `release ${facts.chosen.tag}: required gates passed on ${facts.head.slice(0, 12)} (L4 record ${path.basename(recordPath)})`, facts.head]);
|
|
386
|
+
const tagged = created ? `annotated tag ${facts.chosen.tag} created on ${facts.head.slice(0, 10)}` : `annotated tag ${facts.chosen.tag} already on ${facts.head.slice(0, 10)}`;
|
|
387
|
+
return { ok: true, lines: [...plan, ...report, `L4 record written: ${recordPath}`, tagged], data: { ...base, executed: true, results, tagCreated: created } };
|
|
388
|
+
}
|