@dzhechkov/harness-core 0.8.32 → 0.8.34
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/.dz-manifest.json +107 -47
- package/README.md +108 -0
- package/dist/agentdb-index.d.ts +17 -6
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +61 -17
- package/dist/agentdb-index.js.map +1 -1
- package/dist/apply-leg.d.ts +68 -5
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +466 -37
- package/dist/apply-leg.js.map +1 -1
- package/dist/embed-socket-path.d.ts +65 -0
- package/dist/embed-socket-path.d.ts.map +1 -0
- package/dist/embed-socket-path.js +100 -0
- package/dist/embed-socket-path.js.map +1 -0
- package/dist/index.d.ts +11 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -3
- package/dist/index.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +105 -3
- package/dist/operations.js.map +1 -1
- package/dist/packed-install-smoke.d.ts +108 -0
- package/dist/packed-install-smoke.d.ts.map +1 -0
- package/dist/packed-install-smoke.js +172 -0
- package/dist/packed-install-smoke.js.map +1 -0
- package/dist/publish-sibling-drift.d.ts +139 -0
- package/dist/publish-sibling-drift.d.ts.map +1 -0
- package/dist/publish-sibling-drift.js +408 -0
- package/dist/publish-sibling-drift.js.map +1 -0
- package/dist/publish.d.ts +44 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +243 -24
- package/dist/publish.js.map +1 -1
- package/dist/qe-bridge.d.ts +16 -0
- package/dist/qe-bridge.d.ts.map +1 -1
- package/dist/qe-bridge.js +1 -0
- package/dist/qe-bridge.js.map +1 -1
- package/dist/release.d.ts +91 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +317 -21
- package/dist/release.js.map +1 -1
- package/dist/setup.d.ts +36 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +96 -2
- package/dist/setup.js.map +1 -1
- package/dist/vector-tier.d.ts +27 -2
- package/dist/vector-tier.d.ts.map +1 -1
- package/dist/vector-tier.js +112 -3
- package/dist/vector-tier.js.map +1 -1
- package/package.json +23 -23
- package/sbom.json +196 -46
- package/src/agentdb-index.ts +65 -17
- package/src/apply-leg.ts +461 -37
- package/src/embed-socket-path.ts +113 -0
- package/src/index.ts +44 -3
- package/src/operations.ts +104 -3
- package/src/packed-install-smoke.ts +273 -0
- package/src/publish-sibling-drift.ts +498 -0
- package/src/publish.ts +280 -25
- package/src/qe-bridge.ts +14 -0
- package/src/release.ts +366 -19
- package/src/setup.ts +108 -2
- package/src/vector-tier.ts +147 -5
package/dist/release.d.ts
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
* @packageDocumentation
|
|
22
22
|
*/
|
|
23
|
+
import type { PackedInstallPlan } from './packed-install-smoke.js';
|
|
23
24
|
/** The four HARD verify gates, in execution order. */
|
|
24
25
|
export type ReleaseGateId = 'tests' | 'audit' | 'syntax' | 'smoke';
|
|
25
26
|
/** Order the CLI executes and the verdict reports gates in. */
|
|
@@ -90,6 +91,15 @@ export interface GatePlan {
|
|
|
90
91
|
readonly skips: readonly GateSkip[];
|
|
91
92
|
/** Package names in the release set (dependency order). */
|
|
92
93
|
readonly packages: readonly string[];
|
|
94
|
+
/**
|
|
95
|
+
* AM-7 (feature publish-sibling-drift-gate): the packed-install sub-plan, carried through so
|
|
96
|
+
* {@link classifyGateExecutions} can re-judge its `bin-version`/`bin-exists` steps through the
|
|
97
|
+
* SAME judge `dz publish` uses (`judgePackedInstallSmoke`: exit 0 AND non-empty stdout, AND the
|
|
98
|
+
* declared bin must exist post-install) instead of the generic exit-code-only check every other
|
|
99
|
+
* step gets. Without this, a silently no-op bin could pass `dz release` while `dz publish`
|
|
100
|
+
* refuses it — the two doors would not be equal, contradicting FR-6's own claim.
|
|
101
|
+
*/
|
|
102
|
+
readonly packedInstallPlan?: PackedInstallPlan | undefined;
|
|
93
103
|
}
|
|
94
104
|
/** The CLI's record of running one exec step. */
|
|
95
105
|
export interface GateExecution {
|
|
@@ -105,6 +115,22 @@ export interface GateFailure {
|
|
|
105
115
|
readonly pkg?: string | undefined;
|
|
106
116
|
readonly reason: string;
|
|
107
117
|
readonly class: ReleaseFailureClass;
|
|
118
|
+
/**
|
|
119
|
+
* Feature release-gate-output-tail (AM-4): the last non-empty lines of the step's stdout and
|
|
120
|
+
* stderr, KEPT SEPARATE — each stream through {@link outputTail} on its own, never merged —
|
|
121
|
+
* so a reader can tell which stream a line came from. Set for `tests`/`syntax`/`smoke`
|
|
122
|
+
* EXIT_NONZERO/TIMEOUT failures; absent for `audit` (its own detail line already summarizes)
|
|
123
|
+
* and for failures with no execution record (e.g. UNEXECUTED_STEP).
|
|
124
|
+
*
|
|
125
|
+
* Scope honesty (AM-4): the two streams are captured independently, so a printed/issued
|
|
126
|
+
* `stdout:`/`stderr:` pair does NOT reconstruct the chronological interleaving of the two
|
|
127
|
+
* streams as the process actually emitted them — only each stream's own tail order is
|
|
128
|
+
* preserved. Documented in the CLI README (AM-8), not silently implied.
|
|
129
|
+
*/
|
|
130
|
+
readonly tails?: {
|
|
131
|
+
readonly stdout: string;
|
|
132
|
+
readonly stderr: string;
|
|
133
|
+
};
|
|
108
134
|
}
|
|
109
135
|
/** Per-gate verdict. `skip` = the gate had nothing to execute (still not a pass). */
|
|
110
136
|
export interface GateResult {
|
|
@@ -164,6 +190,15 @@ export interface PlanReleaseGatesOptions {
|
|
|
164
190
|
readonly auditTimeoutMs?: number | undefined;
|
|
165
191
|
readonly syntaxTimeoutMs?: number | undefined;
|
|
166
192
|
readonly smokeTimeoutMs?: number | undefined;
|
|
193
|
+
/**
|
|
194
|
+
* FR-6 (feature publish-sibling-drift-gate): real tmp dirs for the packed-install smoke,
|
|
195
|
+
* supplied by the CLI (planning stays pure — it never calls mkdtemp itself). Omitted ⇒ no
|
|
196
|
+
* packed-install steps are planned, byte-identical to the pre-feature behavior.
|
|
197
|
+
*/
|
|
198
|
+
readonly packedInstall?: {
|
|
199
|
+
readonly packDir: string;
|
|
200
|
+
readonly installDir: string;
|
|
201
|
+
} | undefined;
|
|
167
202
|
}
|
|
168
203
|
/**
|
|
169
204
|
* Plan the four gates from injected facts. Pure: same facts ⇒ byte-identical plan; nothing
|
|
@@ -176,6 +211,54 @@ export declare function planReleaseGates(facts: readonly ReleasePackageFacts[],
|
|
|
176
211
|
* Exported so the CLI reuses it for gh/tag periphery messages (G9 reuse-never-copy).
|
|
177
212
|
*/
|
|
178
213
|
export declare function firstOutputLine(...chunks: readonly unknown[]): string;
|
|
214
|
+
/**
|
|
215
|
+
* Feature release-gate-output-tail (FR-1, amended AM-2): a one-line-ish detail for a
|
|
216
|
+
* `tests`/`syntax`/`smoke` EXIT_NONZERO/TIMEOUT failure that names the ACTUAL failure — not
|
|
217
|
+
* just the first output line, which for `pnpm test`/vitest is routinely an unrelated
|
|
218
|
+
* vite/esbuild deprecation warning (MEASURED 2026-09-13 16:05/18:52).
|
|
219
|
+
*
|
|
220
|
+
* AM-2: ANSI escapes are stripped FIRST (a coloured runner must match the same patterns as a
|
|
221
|
+
* plain one). Recognised shapes, collected in this priority order and joined:
|
|
222
|
+
* 1. vitest summary lines (`Tests …`, `Test Files …`);
|
|
223
|
+
* 2. up to 5 `FAIL …` / `× …` / `❯ …` lines (failing test names/paths);
|
|
224
|
+
* 3. node:test (TAP) lines: `not ok N - name` and `# fail N`.
|
|
225
|
+
*
|
|
226
|
+
* If NONE of the above is present (a non-vitest, non-TAP failure, or empty output), fall back
|
|
227
|
+
* to the prior `firstLine` behavior, marked `(no test-runner summary recognised)` so a reader
|
|
228
|
+
* knows the detail is a guess, not a parsed summary — UNLESS `firstLine` itself is empty (no
|
|
229
|
+
* output at all), in which case the mark would manufacture a synthetic line where none existed
|
|
230
|
+
* and is withheld. Capped at 600 chars — a detail line, not a dump.
|
|
231
|
+
*/
|
|
232
|
+
export declare function testsFailureDetail(stdout: unknown, stderr: unknown): string;
|
|
233
|
+
/**
|
|
234
|
+
* Feature release-gate-output-tail (FR-2/FR-3, amended AM-3): the last non-empty lines of ONE
|
|
235
|
+
* stream (call separately for stdout and stderr — AM-4), bounded on BOTH axes (line count and
|
|
236
|
+
* byte size) so a runaway suite cannot blow up a report or an issue body.
|
|
237
|
+
*
|
|
238
|
+
* AM-3 bounds, each an explicit branch rather than an emergent `Array.slice(-0)` accident
|
|
239
|
+
* (`slice(-0)` returns the WHOLE array, not `[]` — the pre-amendment bug):
|
|
240
|
+
* - `maxLines <= 0` → `''`; `maxBytes <= 0` → `''`.
|
|
241
|
+
* - Whole-line selection: lines are pulled from the END while the running BYTE total (each
|
|
242
|
+
* line's UTF-8 byte length plus its joining `\n`) stays `<= maxBytes` — never a partial line.
|
|
243
|
+
* - A single most-recent line that ALONE exceeds `maxBytes` is truncated at a UTF-8 CHARACTER
|
|
244
|
+
* boundary (never splitting a multi-byte codepoint) and marked `… (line truncated)`.
|
|
245
|
+
*
|
|
246
|
+
* Empty/whitespace-only output → `''` (never a synthetic line).
|
|
247
|
+
*/
|
|
248
|
+
export declare function outputTail(stdout: unknown, stderr: unknown, maxLines?: number, maxBytes?: number): string;
|
|
249
|
+
/**
|
|
250
|
+
* Feature release-gate-output-tail (AM-1): redact secret-shaped substrings before ANY tail text
|
|
251
|
+
* reaches a GitHub issue body. Patterns, each independently redacted:
|
|
252
|
+
* - `token`/`secret`/`password` (case-insensitive) as a `key: value` or `key=value` pair — the
|
|
253
|
+
* KEY survives, only the value is replaced;
|
|
254
|
+
* - `Bearer <token>` HTTP auth headers;
|
|
255
|
+
* - vendor-prefixed tokens: `npm_…`, `ghp_…`, `sk-…`, `AKIA…`;
|
|
256
|
+
* - long opaque strings (base64/hex-ish, `[A-Za-z0-9+/=]{32,}`) that look like a key/secret even
|
|
257
|
+
* without a recognisable prefix.
|
|
258
|
+
* Order matters: prefixed/labelled patterns run BEFORE the generic long-opaque-string pattern so
|
|
259
|
+
* a `Bearer …` token is redacted as a whole rather than surviving as a shorter unlabelled blob.
|
|
260
|
+
*/
|
|
261
|
+
export declare function redactSecrets(text: string): string;
|
|
179
262
|
/**
|
|
180
263
|
* Merge plan + executions into the {@link ReleaseVerdict} — the single fail-closed decision
|
|
181
264
|
* point (ADR load-bearing property):
|
|
@@ -194,6 +277,14 @@ export interface FailureIssueContext {
|
|
|
194
277
|
/**
|
|
195
278
|
* gh-2.4-safe `gh issue create` payload (only `--title`/`--body` are assumed downstream).
|
|
196
279
|
* Pure + deterministic for a fixed verdict — the issue is the verdict's echo, never its judge.
|
|
280
|
+
*
|
|
281
|
+
* AM-1/AM-4/AM-5: every tail is (a) redacted (secret-shaped substrings replaced — see
|
|
282
|
+
* {@link redactSecrets}) and ANSI-stripped BEFORE it is ever considered for the body; (b) shown
|
|
283
|
+
* per STREAM, labelled `stdout:`/`stderr:` — AM-4's scope note applies here too: the two labelled
|
|
284
|
+
* blocks do NOT reconstruct chronological interleaving between the streams; (c) fenced so the
|
|
285
|
+
* payload cannot break out of its code block; (d) the WHOLE body is capped at
|
|
286
|
+
* {@link MAX_ISSUE_BODY_BYTES} — when it would exceed the cap, every tail is shrunk EVENLY
|
|
287
|
+
* (byte-proportional), not by dropping some tails whole while keeping others untouched.
|
|
197
288
|
*/
|
|
198
289
|
export declare function buildFailureIssue(verdict: ReleaseVerdict, ctx?: FailureIssueContext): {
|
|
199
290
|
title: string;
|
package/dist/release.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"release.d.ts","sourceRoot":"","sources":["../src/release.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAQH,sDAAsD;AACtD,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,OAAO,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEnE,+DAA+D;AAC/D,eAAO,MAAM,kBAAkB,EAAE,SAAS,aAAa,EAA0C,CAAC;AAElG;;;GAGG;AACH,MAAM,MAAM,mBAAmB,GAC3B,cAAc,GACd,SAAS,GACT,YAAY,GACZ,aAAa,GACb,YAAY,GACZ,cAAc,GACd,eAAe,GACf,aAAa,GACb,iBAAiB,CAAC;AAEtB,oFAAoF;AACpF,MAAM,MAAM,gBAAgB,GAAG,qBAAqB,GAAG,mBAAmB,CAAC;AAE3E,kEAAkE;AAClE,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sFAAsF;IACtF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;CAC1B;AAED,oFAAoF;AACpF,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC,gGAAgG;IAChG,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,SAAS,eAAe,EAAE,CAAC;IAC1C,0DAA0D;IAC1D,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,2FAA2F;IAC3F,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CACjD;AAED,yDAAyD;AACzD,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,gFAAgF;IAChF,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAAC;IACtD,4EAA4E;IAC5E,QAAQ,CAAC,SAAS,CAAC,EAAE,mBAAmB,GAAG,SAAS,CAAC;IACrD;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CACxC;AAED,wEAAwE;AACxE,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAC;CAClC;AAED,mFAAmF;AACnF,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,KAAK,EAAE,SAAS,QAAQ,EAAE,CAAC;IACpC,QAAQ,CAAC,KAAK,EAAE,SAAS,QAAQ,EAAE,CAAC;IACpC,2DAA2D;IAC3D,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"release.d.ts","sourceRoot":"","sources":["../src/release.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAQH,OAAO,KAAK,EAAE,iBAAiB,EAA0B,MAAM,2BAA2B,CAAC;AAE3F,sDAAsD;AACtD,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,OAAO,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEnE,+DAA+D;AAC/D,eAAO,MAAM,kBAAkB,EAAE,SAAS,aAAa,EAA0C,CAAC;AAElG;;;GAGG;AACH,MAAM,MAAM,mBAAmB,GAC3B,cAAc,GACd,SAAS,GACT,YAAY,GACZ,aAAa,GACb,YAAY,GACZ,cAAc,GACd,eAAe,GACf,aAAa,GACb,iBAAiB,CAAC;AAEtB,oFAAoF;AACpF,MAAM,MAAM,gBAAgB,GAAG,qBAAqB,GAAG,mBAAmB,CAAC;AAE3E,kEAAkE;AAClE,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,sFAAsF;IACtF,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,MAAM,EAAE,OAAO,CAAC;CAC1B;AAED,oFAAoF;AACpF,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC,gGAAgG;IAChG,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;IACjC,QAAQ,CAAC,IAAI,EAAE,SAAS,eAAe,EAAE,CAAC;IAC1C,0DAA0D;IAC1D,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,2FAA2F;IAC3F,QAAQ,CAAC,gBAAgB,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CACjD;AAED,yDAAyD;AACzD,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,gFAAgF;IAChF,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,gBAAgB,GAAG,SAAS,CAAC;IACtD,4EAA4E;IAC5E,QAAQ,CAAC,SAAS,CAAC,EAAE,mBAAmB,GAAG,SAAS,CAAC;IACrD;;;;;OAKG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CACxC;AAED,wEAAwE;AACxE,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,gBAAgB,CAAC;CAClC;AAED,mFAAmF;AACnF,MAAM,WAAW,QAAQ;IACvB,QAAQ,CAAC,KAAK,EAAE,SAAS,QAAQ,EAAE,CAAC;IACpC,QAAQ,CAAC,KAAK,EAAE,SAAS,QAAQ,EAAE,CAAC;IACpC,2DAA2D;IAC3D,QAAQ,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC;;;;;;;OAOG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,iBAAiB,GAAG,SAAS,CAAC;CAC5D;AAED,iDAAiD;AACjD,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;CACzC;AAED,4CAA4C;AAC5C,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,mBAAmB,CAAC;IACpC;;;;;;;;;;;OAWG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE;QAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;CACvE;AAED,qFAAqF;AACrF,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAC7B,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAAC;IAC1C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,SAAS,WAAW,EAAE,CAAC;IAC1C,QAAQ,CAAC,KAAK,EAAE,SAAS,QAAQ,EAAE,CAAC;CACrC;AAED,yEAAyE;AACzE,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,KAAK,EAAE,SAAS,UAAU,EAAE,CAAC;IACtC,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IACtC,QAAQ,CAAC,OAAO,EAAE,SAAS,QAAQ,EAAE,CAAC;IACtC,yFAAyF;IACzF,QAAQ,CAAC,aAAa,EAAE,SAAS,GAAG,SAAS,CAAC;IAC9C,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,mGAAmG;AACnG,eAAO,MAAM,gBAAgB;;;;;CAKnB,CAAC;AA4CX;;;;;;;;;;;GAWG;AACH,wBAAgB,mBAAmB,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,mBAAmB,EAAE,CA8C3G;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,EACtC,KAAK,EAAE,SAAS,mBAAmB,EAAE,GACpC,mBAAmB,EAAE,CAYvB;AAMD,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,uFAAuF;IACvF,QAAQ,CAAC,eAAe,EAAE,OAAO,CAAC;IAClC;;;;OAIG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,SAAS,CAAC;IAC9C,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC5C,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7C,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9C,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7C;;;;OAIG;IACH,QAAQ,CAAC,aAAa,CAAC,EAAE;QAAE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;KAAE,GAAG,SAAS,CAAC;CAChG;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,SAAS,mBAAmB,EAAE,EAAE,IAAI,EAAE,uBAAuB,GAAG,QAAQ,CAuN/G;AAyCD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,GAAG,MAAM,EAAE,SAAS,OAAO,EAAE,GAAG,MAAM,CAErE;AAyBD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,CAiB3E;AAaD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,SAAK,EAAE,QAAQ,SAAO,GAAG,MAAM,CA6BnG;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAUlD;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,QAAQ,EACd,UAAU,EAAE,SAAS,aAAa,EAAE,EACpC,GAAG,GAAE,IAAiB,GACrB,cAAc,CA+HhB;AAMD,MAAM,WAAW,mBAAmB;IAClC,sFAAsF;IACtF,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACzC,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACpC;AAoBD;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,cAAc,EAAE,GAAG,GAAE,mBAAwB,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAyFzH;AAED,kFAAkF;AAClF,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,EAAE,KAAK,SAAK,GAAG,MAAM,CAQpF;AAED,kFAAkF;AAClF,wBAAgB,cAAc,CAAC,GAAG,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAMlE"}
|
package/dist/release.js
CHANGED
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
* @packageDocumentation
|
|
22
22
|
*/
|
|
23
23
|
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
24
|
-
import { join } from 'node:path';
|
|
24
|
+
import { join, relative } from 'node:path';
|
|
25
25
|
import { discoverPackages, orderByDependencies } from './publish.js';
|
|
26
|
+
import { planPackedInstallSmoke, judgePackedInstallSmoke } from './packed-install-smoke.js';
|
|
26
27
|
/** Order the CLI executes and the verdict reports gates in. */
|
|
27
28
|
export const RELEASE_GATE_ORDER = ['tests', 'audit', 'syntax', 'smoke'];
|
|
28
29
|
/** Default per-step timeouts (NFR-4: a hung child is a classified failure, not a hung release). */
|
|
@@ -323,7 +324,46 @@ export function planReleaseGates(facts, opts) {
|
|
|
323
324
|
}
|
|
324
325
|
}
|
|
325
326
|
}
|
|
326
|
-
|
|
327
|
+
// FR-6 (feature publish-sibling-drift-gate): the packed-install smoke joins the SAME smoke
|
|
328
|
+
// gate `dz publish` runs (planPackedInstallSmoke, ADR-001 Decision 2) — so both doors apply
|
|
329
|
+
// the identical rule. Opt-in via `opts.packedInstall` (real tmp dirs, supplied by the CLI):
|
|
330
|
+
// omitted, this is byte-identical to the pre-feature plan, which every existing planner test
|
|
331
|
+
// relies on. Skipped entirely when nothing in the batch has a bin — packing siblings nobody
|
|
332
|
+
// will boot proves nothing a fresh `npm install` doesn't already cover elsewhere.
|
|
333
|
+
let packedInstallPlan;
|
|
334
|
+
if (opts.packedInstall !== undefined) {
|
|
335
|
+
const bins = facts.flatMap((f) => f.bins.map((b) => ({ pkg: f.name, binName: b.name, relPath: relative(f.dir, b.path) })));
|
|
336
|
+
if (bins.length > 0) {
|
|
337
|
+
const packages = facts.map((f) => ({ name: f.name, dir: f.dir, version: f.version }));
|
|
338
|
+
const smokePlan = planPackedInstallSmoke({
|
|
339
|
+
packages,
|
|
340
|
+
bins,
|
|
341
|
+
packDir: opts.packedInstall.packDir,
|
|
342
|
+
installDir: opts.packedInstall.installDir,
|
|
343
|
+
});
|
|
344
|
+
packedInstallPlan = smokePlan; // AM-7: kept for classifyGateExecutions's re-judge pass
|
|
345
|
+
for (const s of smokePlan.steps) {
|
|
346
|
+
const reason = s.kind === 'pack'
|
|
347
|
+
? `pack ${s.pkg} for the packed-install smoke — the tarball a consumer would actually receive`
|
|
348
|
+
: s.kind === 'install'
|
|
349
|
+
? 'install every packed tarball together in a clean dir — out-of-batch siblings resolve from the registry, exactly like a fresh user'
|
|
350
|
+
: s.kind === 'bin-exists'
|
|
351
|
+
? `bin "${s.binName}" must EXIST after the packed install (AM-8)`
|
|
352
|
+
: `bin "${s.binName}" must boot from the PACKED install (--version, exit 0, non-empty stdout)`;
|
|
353
|
+
steps.push({
|
|
354
|
+
id: `smoke:packed-install:${s.id}`,
|
|
355
|
+
gate: 'smoke',
|
|
356
|
+
pkg: s.pkg,
|
|
357
|
+
cmd: s.cmd,
|
|
358
|
+
cwd: s.cwd,
|
|
359
|
+
timeoutMs: s.timeoutMs,
|
|
360
|
+
reason,
|
|
361
|
+
kind: 'exec',
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return { steps, skips, packages: facts.map((f) => f.name), ...(packedInstallPlan !== undefined ? { packedInstallPlan } : {}) };
|
|
327
367
|
}
|
|
328
368
|
/* ------------------------------------------------------------------ */
|
|
329
369
|
/* VERIFY — pure classification */
|
|
@@ -374,6 +414,137 @@ function firstLine(...chunks) {
|
|
|
374
414
|
}
|
|
375
415
|
return '';
|
|
376
416
|
}
|
|
417
|
+
/**
|
|
418
|
+
* Strip ANSI/VT100 escape sequences (colour codes, cursor moves, OSC hyperlinks) so pattern
|
|
419
|
+
* matching sees the plain text a human reads on a non-colour terminal. AM-2/AM-1 precondition:
|
|
420
|
+
* `testsFailureDetail` and the issue-body redaction both run this FIRST, before any regex tries
|
|
421
|
+
* to recognise a runner's summary/FAIL lines or a secret value — a coloured `FAIL` token (e.g.
|
|
422
|
+
* `\x1b[31mFAIL\x1b[0m`) must still match `/^FAIL\b/` once stripped.
|
|
423
|
+
*/
|
|
424
|
+
// eslint-disable-next-line no-control-regex -- deliberately matching raw ESC control bytes
|
|
425
|
+
function stripAnsi(s) {
|
|
426
|
+
return s
|
|
427
|
+
.replace(/\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)/g, '') // OSC … BEL | OSC … ST
|
|
428
|
+
.replace(/\x1B[[()#;?]*[0-9]*(?:;[0-9]*)*[a-zA-Z@]/g, ''); // CSI/other short escapes
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Feature release-gate-output-tail (FR-1, amended AM-2): a one-line-ish detail for a
|
|
432
|
+
* `tests`/`syntax`/`smoke` EXIT_NONZERO/TIMEOUT failure that names the ACTUAL failure — not
|
|
433
|
+
* just the first output line, which for `pnpm test`/vitest is routinely an unrelated
|
|
434
|
+
* vite/esbuild deprecation warning (MEASURED 2026-09-13 16:05/18:52).
|
|
435
|
+
*
|
|
436
|
+
* AM-2: ANSI escapes are stripped FIRST (a coloured runner must match the same patterns as a
|
|
437
|
+
* plain one). Recognised shapes, collected in this priority order and joined:
|
|
438
|
+
* 1. vitest summary lines (`Tests …`, `Test Files …`);
|
|
439
|
+
* 2. up to 5 `FAIL …` / `× …` / `❯ …` lines (failing test names/paths);
|
|
440
|
+
* 3. node:test (TAP) lines: `not ok N - name` and `# fail N`.
|
|
441
|
+
*
|
|
442
|
+
* If NONE of the above is present (a non-vitest, non-TAP failure, or empty output), fall back
|
|
443
|
+
* to the prior `firstLine` behavior, marked `(no test-runner summary recognised)` so a reader
|
|
444
|
+
* knows the detail is a guess, not a parsed summary — UNLESS `firstLine` itself is empty (no
|
|
445
|
+
* output at all), in which case the mark would manufacture a synthetic line where none existed
|
|
446
|
+
* and is withheld. Capped at 600 chars — a detail line, not a dump.
|
|
447
|
+
*/
|
|
448
|
+
export function testsFailureDetail(stdout, stderr) {
|
|
449
|
+
const all = stripAnsi(`${stdout == null ? '' : String(stdout)}\n${stderr == null ? '' : String(stderr)}`);
|
|
450
|
+
const lines = all
|
|
451
|
+
.split('\n')
|
|
452
|
+
.map((l) => l.trim())
|
|
453
|
+
.filter((l) => l.length > 0);
|
|
454
|
+
const summaryLines = lines.filter((l) => /^(Tests|Test Files)\b/.test(l));
|
|
455
|
+
const failLines = lines.filter((l) => /^(FAIL\b|×|❯)/.test(l)).slice(0, 5);
|
|
456
|
+
const tapNotOkLines = lines.filter((l) => /^not ok \d+/.test(l)).slice(0, 5);
|
|
457
|
+
const tapFailCountLines = lines.filter((l) => /^# fail \d+/i.test(l));
|
|
458
|
+
const parts = [...summaryLines, ...failLines, ...tapNotOkLines, ...tapFailCountLines];
|
|
459
|
+
if (parts.length === 0) {
|
|
460
|
+
// lead r2: the fallback is derived from the ANSI-STRIPPED text, never the raw stream
|
|
461
|
+
const fl = lines[0] ?? '';
|
|
462
|
+
return fl.length === 0 ? '' : `${fl.slice(0, 200)} (no test-runner summary recognised)`;
|
|
463
|
+
}
|
|
464
|
+
return parts.join(' — ').slice(0, 600);
|
|
465
|
+
}
|
|
466
|
+
/** Truncate `s` to at most `maxBytes` UTF-8 bytes, never splitting a multi-byte character. */
|
|
467
|
+
function truncateToBytes(s, maxBytes) {
|
|
468
|
+
if (maxBytes <= 0)
|
|
469
|
+
return '';
|
|
470
|
+
const buf = Buffer.from(s, 'utf-8');
|
|
471
|
+
if (buf.length <= maxBytes)
|
|
472
|
+
return s;
|
|
473
|
+
let end = maxBytes;
|
|
474
|
+
// back off while the next byte is a UTF-8 continuation byte (10xxxxxx)
|
|
475
|
+
while (end > 0 && (buf[end] & 0xc0) === 0x80)
|
|
476
|
+
end -= 1;
|
|
477
|
+
return buf.subarray(0, end).toString('utf-8');
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Feature release-gate-output-tail (FR-2/FR-3, amended AM-3): the last non-empty lines of ONE
|
|
481
|
+
* stream (call separately for stdout and stderr — AM-4), bounded on BOTH axes (line count and
|
|
482
|
+
* byte size) so a runaway suite cannot blow up a report or an issue body.
|
|
483
|
+
*
|
|
484
|
+
* AM-3 bounds, each an explicit branch rather than an emergent `Array.slice(-0)` accident
|
|
485
|
+
* (`slice(-0)` returns the WHOLE array, not `[]` — the pre-amendment bug):
|
|
486
|
+
* - `maxLines <= 0` → `''`; `maxBytes <= 0` → `''`.
|
|
487
|
+
* - Whole-line selection: lines are pulled from the END while the running BYTE total (each
|
|
488
|
+
* line's UTF-8 byte length plus its joining `\n`) stays `<= maxBytes` — never a partial line.
|
|
489
|
+
* - A single most-recent line that ALONE exceeds `maxBytes` is truncated at a UTF-8 CHARACTER
|
|
490
|
+
* boundary (never splitting a multi-byte codepoint) and marked `… (line truncated)`.
|
|
491
|
+
*
|
|
492
|
+
* Empty/whitespace-only output → `''` (never a synthetic line).
|
|
493
|
+
*/
|
|
494
|
+
export function outputTail(stdout, stderr, maxLines = 40, maxBytes = 8192) {
|
|
495
|
+
if (maxLines <= 0 || maxBytes <= 0)
|
|
496
|
+
return '';
|
|
497
|
+
const all = `${stdout == null ? '' : String(stdout)}\n${stderr == null ? '' : String(stderr)}`;
|
|
498
|
+
const nonEmpty = all
|
|
499
|
+
.split('\n')
|
|
500
|
+
.map((l) => l.replace(/\r$/, ''))
|
|
501
|
+
.filter((l) => l.trim().length > 0);
|
|
502
|
+
const tailLines = nonEmpty.slice(-maxLines);
|
|
503
|
+
if (tailLines.length === 0)
|
|
504
|
+
return '';
|
|
505
|
+
const lastLine = tailLines[tailLines.length - 1];
|
|
506
|
+
if (Buffer.byteLength(lastLine, 'utf-8') > maxBytes) {
|
|
507
|
+
// lead r2: the marker lives INSIDE the byte budget, so the returned text never exceeds maxBytes
|
|
508
|
+
const marker = '… (line truncated)';
|
|
509
|
+
const room = Math.max(0, maxBytes - Buffer.byteLength(marker, 'utf-8'));
|
|
510
|
+
return `${truncateToBytes(lastLine, room)}${marker}`;
|
|
511
|
+
}
|
|
512
|
+
const selected = [];
|
|
513
|
+
let bytes = 0;
|
|
514
|
+
for (let i = tailLines.length - 1; i >= 0; i--) {
|
|
515
|
+
const line = tailLines[i];
|
|
516
|
+
const lineBytes = Buffer.byteLength(line, 'utf-8');
|
|
517
|
+
const joinerBytes = selected.length > 0 ? 1 : 0; // the '\n' this line adds once prepended
|
|
518
|
+
if (bytes + lineBytes + joinerBytes > maxBytes)
|
|
519
|
+
break;
|
|
520
|
+
selected.unshift(line);
|
|
521
|
+
bytes += lineBytes + joinerBytes;
|
|
522
|
+
}
|
|
523
|
+
return selected.join('\n');
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Feature release-gate-output-tail (AM-1): redact secret-shaped substrings before ANY tail text
|
|
527
|
+
* reaches a GitHub issue body. Patterns, each independently redacted:
|
|
528
|
+
* - `token`/`secret`/`password` (case-insensitive) as a `key: value` or `key=value` pair — the
|
|
529
|
+
* KEY survives, only the value is replaced;
|
|
530
|
+
* - `Bearer <token>` HTTP auth headers;
|
|
531
|
+
* - vendor-prefixed tokens: `npm_…`, `ghp_…`, `sk-…`, `AKIA…`;
|
|
532
|
+
* - long opaque strings (base64/hex-ish, `[A-Za-z0-9+/=]{32,}`) that look like a key/secret even
|
|
533
|
+
* without a recognisable prefix.
|
|
534
|
+
* Order matters: prefixed/labelled patterns run BEFORE the generic long-opaque-string pattern so
|
|
535
|
+
* a `Bearer …` token is redacted as a whole rather than surviving as a shorter unlabelled blob.
|
|
536
|
+
*/
|
|
537
|
+
export function redactSecrets(text) {
|
|
538
|
+
let out = text;
|
|
539
|
+
out = out.replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]');
|
|
540
|
+
out = out.replace(/\bnpm_[A-Za-z0-9]+/g, '[redacted]');
|
|
541
|
+
out = out.replace(/\bghp_[A-Za-z0-9]+/g, '[redacted]');
|
|
542
|
+
out = out.replace(/\bsk-[A-Za-z0-9]+/g, '[redacted]');
|
|
543
|
+
out = out.replace(/\bAKIA[A-Za-z0-9]+/g, '[redacted]');
|
|
544
|
+
out = out.replace(/\b(token|secret|password)(\s*[:=]\s*)(\S+)/gi, '$1$2[redacted]');
|
|
545
|
+
out = out.replace(/\b[A-Za-z0-9+/=]{32,}\b/g, '[redacted]');
|
|
546
|
+
return out;
|
|
547
|
+
}
|
|
377
548
|
/**
|
|
378
549
|
* Merge plan + executions into the {@link ReleaseVerdict} — the single fail-closed decision
|
|
379
550
|
* point (ADR load-bearing property):
|
|
@@ -389,6 +560,27 @@ export function classifyGateExecutions(plan, executions, now = new Date()) {
|
|
|
389
560
|
if (e != null && typeof e.stepId === 'string')
|
|
390
561
|
byId.set(e.stepId, e);
|
|
391
562
|
}
|
|
563
|
+
// AM-7: `bin-exists`/`bin-version` packed-install steps are judged through
|
|
564
|
+
// `judgePackedInstallSmoke` — the SAME rule `dz publish` applies (exit 0 AND non-empty stdout,
|
|
565
|
+
// AND the declared bin must exist post-install) — instead of the generic exit-code-only check
|
|
566
|
+
// every other step gets. The generic loop below SKIPS these step ids; the judged verdict is
|
|
567
|
+
// folded into the 'smoke' gate's failures/passed count after the loop.
|
|
568
|
+
const packedInstallPlan = plan?.packedInstallPlan;
|
|
569
|
+
const packedInstallBinStepIds = new Set((packedInstallPlan?.steps ?? []).filter((s) => s.kind === 'bin-exists' || s.kind === 'bin-version').map((s) => `smoke:packed-install:${s.id}`));
|
|
570
|
+
let packedInstallVerdict;
|
|
571
|
+
if (packedInstallPlan !== undefined) {
|
|
572
|
+
const prefix = 'smoke:packed-install:';
|
|
573
|
+
const translated = (executions ?? [])
|
|
574
|
+
.filter((e) => e != null && typeof e.stepId === 'string' && e.stepId.startsWith(prefix))
|
|
575
|
+
.map((e) => ({
|
|
576
|
+
stepId: e.stepId.slice(prefix.length),
|
|
577
|
+
exitCode: e.exitCode,
|
|
578
|
+
stdout: e.stdout ?? '',
|
|
579
|
+
stderr: e.stderr ?? '',
|
|
580
|
+
...(e.timedOut !== undefined ? { timedOut: e.timedOut } : {}),
|
|
581
|
+
}));
|
|
582
|
+
packedInstallVerdict = judgePackedInstallSmoke(packedInstallPlan, translated);
|
|
583
|
+
}
|
|
392
584
|
const gates = RELEASE_GATE_ORDER.map((gate) => {
|
|
393
585
|
const gateSteps = (plan?.steps ?? []).filter((s) => s?.gate === gate);
|
|
394
586
|
const gateSkips = (plan?.skips ?? []).filter((s) => s?.gate === gate);
|
|
@@ -396,6 +588,8 @@ export function classifyGateExecutions(plan, executions, now = new Date()) {
|
|
|
396
588
|
let passed = 0;
|
|
397
589
|
for (const step of gateSteps) {
|
|
398
590
|
try {
|
|
591
|
+
if (packedInstallBinStepIds.has(step.id))
|
|
592
|
+
continue; // judged separately below (AM-7)
|
|
399
593
|
if (step.kind === 'synthetic-fail') {
|
|
400
594
|
failures.push({ pkg: step.pkg, reason: step.reason, class: step.failClass ?? 'EXIT_NONZERO' });
|
|
401
595
|
continue;
|
|
@@ -414,6 +608,9 @@ export function classifyGateExecutions(plan, executions, now = new Date()) {
|
|
|
414
608
|
pkg: step.pkg,
|
|
415
609
|
reason: `timed out after ${step.timeoutMs}ms: ${step.cmd}`,
|
|
416
610
|
class: gate === 'smoke' ? 'SMOKE_TIMEOUT' : 'TIMEOUT',
|
|
611
|
+
// FR-3 / AM-4: a killed-by-timeout step still has whatever it printed before the
|
|
612
|
+
// kill — captured per-stream, never merged (see GateFailure.tails doc comment).
|
|
613
|
+
tails: { stdout: outputTail(exec.stdout, undefined), stderr: outputTail(undefined, exec.stderr) },
|
|
417
614
|
});
|
|
418
615
|
continue;
|
|
419
616
|
}
|
|
@@ -424,10 +621,15 @@ export function classifyGateExecutions(plan, executions, now = new Date()) {
|
|
|
424
621
|
failures.push({ pkg: step.pkg, reason: `${reason}${detail ? ` — ${detail}` : ''}`, class: cls });
|
|
425
622
|
}
|
|
426
623
|
else {
|
|
624
|
+
// FR-1: for tests/syntax/smoke, name the ACTUAL failure (summary + failing tests),
|
|
625
|
+
// not just the first output line — see testsFailureDetail's doc comment for why.
|
|
626
|
+
const detail = testsFailureDetail(exec.stderr, exec.stdout);
|
|
427
627
|
failures.push({
|
|
428
628
|
pkg: step.pkg,
|
|
429
|
-
reason: `exit ${String(exec.exitCode)}: ${step.cmd}${
|
|
629
|
+
reason: `exit ${String(exec.exitCode)}: ${step.cmd}${detail ? ` — ${detail}` : ''}`,
|
|
430
630
|
class: 'EXIT_NONZERO',
|
|
631
|
+
// AM-4: per-stream tails, never merged — see GateFailure.tails doc comment.
|
|
632
|
+
tails: { stdout: outputTail(exec.stdout, undefined), stderr: outputTail(undefined, exec.stderr) },
|
|
431
633
|
});
|
|
432
634
|
}
|
|
433
635
|
continue;
|
|
@@ -439,6 +641,22 @@ export function classifyGateExecutions(plan, executions, now = new Date()) {
|
|
|
439
641
|
failures.push({ pkg: step?.pkg, reason: 'unclassifiable step/execution record', class: 'EXIT_NONZERO' });
|
|
440
642
|
}
|
|
441
643
|
}
|
|
644
|
+
// AM-7: fold the packed-install bin verdicts (judged via judgePackedInstallSmoke, above) into
|
|
645
|
+
// the 'smoke' gate — the ONLY gate that ever plans packed-install steps.
|
|
646
|
+
if (gate === 'smoke' && packedInstallVerdict !== undefined) {
|
|
647
|
+
for (const bin of packedInstallVerdict.bins) {
|
|
648
|
+
if (bin.ok) {
|
|
649
|
+
passed += 1;
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
failures.push({
|
|
653
|
+
pkg: bin.pkg,
|
|
654
|
+
reason: `packed-install bin "${bin.binName}" ${bin.detail ?? 'failed'}`,
|
|
655
|
+
class: 'EXIT_NONZERO',
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
442
660
|
const status = failures.length > 0 ? 'fail' : passed > 0 ? 'pass' : 'skip';
|
|
443
661
|
return { gate, status, passed, failures, skips: gateSkips };
|
|
444
662
|
});
|
|
@@ -458,34 +676,112 @@ export function classifyGateExecutions(plan, executions, now = new Date()) {
|
|
|
458
676
|
timestamp: now.toISOString(),
|
|
459
677
|
};
|
|
460
678
|
}
|
|
679
|
+
/** AM-1: total issue-body cap — a courier never balloons into an unpostable payload. */
|
|
680
|
+
const MAX_ISSUE_BODY_BYTES = 60 * 1024;
|
|
681
|
+
/**
|
|
682
|
+
* AM-5: fence `text` so the payload can never prematurely close the code block — the fence is
|
|
683
|
+
* N+1 backticks, where N is the LONGEST run of consecutive backticks already present in `text`.
|
|
684
|
+
* Every content line (and the fence itself) carries `indent` so a multi-line block renders as a
|
|
685
|
+
* continuation of the enclosing markdown list item, not as a sibling paragraph.
|
|
686
|
+
*/
|
|
687
|
+
function fencedBlock(text, indent = ' ') {
|
|
688
|
+
const runs = text.match(/`+/g) ?? [];
|
|
689
|
+
const longestRun = runs.reduce((m, r) => Math.max(m, r.length), 0);
|
|
690
|
+
// GFM needs >= 3 backticks for a FENCED (block) code fence — fewer reads as inline code.
|
|
691
|
+
const fence = '`'.repeat(Math.max(3, longestRun + 1));
|
|
692
|
+
const contentLines = text.split('\n').map((l) => `${indent}${l}`);
|
|
693
|
+
return [`${indent}${fence}`, ...contentLines, `${indent}${fence}`];
|
|
694
|
+
}
|
|
461
695
|
/**
|
|
462
696
|
* gh-2.4-safe `gh issue create` payload (only `--title`/`--body` are assumed downstream).
|
|
463
697
|
* Pure + deterministic for a fixed verdict — the issue is the verdict's echo, never its judge.
|
|
698
|
+
*
|
|
699
|
+
* AM-1/AM-4/AM-5: every tail is (a) redacted (secret-shaped substrings replaced — see
|
|
700
|
+
* {@link redactSecrets}) and ANSI-stripped BEFORE it is ever considered for the body; (b) shown
|
|
701
|
+
* per STREAM, labelled `stdout:`/`stderr:` — AM-4's scope note applies here too: the two labelled
|
|
702
|
+
* blocks do NOT reconstruct chronological interleaving between the streams; (c) fenced so the
|
|
703
|
+
* payload cannot break out of its code block; (d) the WHOLE body is capped at
|
|
704
|
+
* {@link MAX_ISSUE_BODY_BYTES} — when it would exceed the cap, every tail is shrunk EVENLY
|
|
705
|
+
* (byte-proportional), not by dropping some tails whole while keeping others untouched.
|
|
464
706
|
*/
|
|
465
707
|
export function buildFailureIssue(verdict, ctx = {}) {
|
|
466
708
|
const failed = verdict.gates.filter((g) => g.status === 'fail').map((g) => g.gate);
|
|
467
709
|
const title = `dz release: gate failure — ${failed.length > 0 ? failed.join(', ') : 'nothing verified'}`;
|
|
468
|
-
const
|
|
469
|
-
`Verified release blocked at ${verdict.timestamp}.`,
|
|
470
|
-
'',
|
|
471
|
-
...(ctx.invocation ? [`Invocation: \`${ctx.invocation}\``, ''] : []),
|
|
472
|
-
...(ctx.repo ? [`Repo: ${ctx.repo}`, ''] : []),
|
|
473
|
-
'## Gate verdict',
|
|
474
|
-
'',
|
|
475
|
-
];
|
|
710
|
+
const refsByFailure = new Map();
|
|
476
711
|
for (const g of verdict.gates) {
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
712
|
+
for (const f of g.failures) {
|
|
713
|
+
if (f.tails === undefined)
|
|
714
|
+
continue;
|
|
715
|
+
const refs = [];
|
|
716
|
+
for (const stream of ['stdout', 'stderr']) {
|
|
717
|
+
const raw = f.tails[stream];
|
|
718
|
+
if (raw.length === 0)
|
|
719
|
+
continue;
|
|
720
|
+
const clean = redactSecrets(stripAnsi(raw));
|
|
721
|
+
refs.push({ stream, text: clean, rawBytes: Buffer.byteLength(clean, 'utf-8') });
|
|
722
|
+
}
|
|
723
|
+
if (refs.length > 0)
|
|
724
|
+
refsByFailure.set(f, refs);
|
|
725
|
+
}
|
|
481
726
|
}
|
|
482
|
-
|
|
483
|
-
lines
|
|
484
|
-
|
|
485
|
-
|
|
727
|
+
const render = () => {
|
|
728
|
+
const lines = [
|
|
729
|
+
`Verified release blocked at ${verdict.timestamp}.`,
|
|
730
|
+
'',
|
|
731
|
+
...(ctx.invocation ? [`Invocation: \`${redactSecrets(stripAnsi(ctx.invocation)).replace(/`/g, "'")}\``, ''] : []),
|
|
732
|
+
...(ctx.repo ? [`Repo: ${ctx.repo}`, ''] : []),
|
|
733
|
+
'## Gate verdict',
|
|
734
|
+
'',
|
|
735
|
+
];
|
|
736
|
+
for (const g of verdict.gates) {
|
|
737
|
+
const icon = g.status === 'pass' ? '✓' : g.status === 'fail' ? '✗' : '○';
|
|
738
|
+
lines.push(`- ${icon} **${g.gate}** — ${g.status} (${g.passed} passed, ${g.failures.length} failed, ${g.skips.length} skipped)`);
|
|
739
|
+
for (const f of g.failures) {
|
|
740
|
+
// lead r2 (HIGH): the reason is output-derived free text — strip ANSI and redact it like a tail
|
|
741
|
+
lines.push(` - [${f.class}] ${f.pkg ? `${f.pkg}: ` : ''}${redactSecrets(stripAnsi(f.reason))}`);
|
|
742
|
+
// FR-2 / AM-4 / AM-5: a labelled, fenced block per non-empty stream — the issue is the
|
|
743
|
+
// echo of the verdict, so a reader can see the actual failing output without re-running.
|
|
744
|
+
for (const ref of refsByFailure.get(f) ?? []) {
|
|
745
|
+
lines.push(` ${ref.stream}:`, ...fencedBlock(ref.text));
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if (verdict.skipped.length > 0) {
|
|
750
|
+
lines.push('', '## Skipped (honestly reported, never counted as passed)', '');
|
|
751
|
+
for (const s of verdict.skipped)
|
|
752
|
+
lines.push(`- [${s.class}] ${s.pkg}: ${s.reason}`);
|
|
753
|
+
}
|
|
754
|
+
lines.push('', `Blocked by: ${verdict.blockedBy.join('; ')}`, '', '_Auto-created by `dz release` (best-effort; the release verdict is independent of this issue)._');
|
|
755
|
+
return lines.join('\n');
|
|
756
|
+
};
|
|
757
|
+
let body = render();
|
|
758
|
+
let bodyBytes = Buffer.byteLength(body, 'utf-8');
|
|
759
|
+
if (bodyBytes > MAX_ISSUE_BODY_BYTES && refsByFailure.size > 0) {
|
|
760
|
+
const allRefs = [...refsByFailure.values()].flat();
|
|
761
|
+
let overage = bodyBytes - MAX_ISSUE_BODY_BYTES;
|
|
762
|
+
// Bounded iteration: each pass's cut is based on the LATEST measured overage (markup like
|
|
763
|
+
// "… (truncated)" adds a few bytes back per ref, so one pass rarely lands exactly) — a few
|
|
764
|
+
// passes converge; the safety net below closes any pathological remainder.
|
|
765
|
+
for (let pass = 0; pass < 3 && overage > 0; pass++) {
|
|
766
|
+
const perRefCut = Math.ceil(overage / allRefs.length);
|
|
767
|
+
for (const ref of allRefs) {
|
|
768
|
+
const targetBytes = Math.max(0, ref.rawBytes - perRefCut);
|
|
769
|
+
if (Buffer.byteLength(ref.text, 'utf-8') > targetBytes) {
|
|
770
|
+
ref.text = `${truncateToBytes(ref.text, targetBytes)}… (truncated)`;
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
body = render();
|
|
774
|
+
bodyBytes = Buffer.byteLength(body, 'utf-8');
|
|
775
|
+
overage = bodyBytes - MAX_ISSUE_BODY_BYTES;
|
|
776
|
+
}
|
|
777
|
+
// Safety net: a pathological shape (a huge non-tail skeleton, tiny/no tails) can still exceed
|
|
778
|
+
// the cap after every tail is wiped — hard-truncate the whole body as the last resort so the
|
|
779
|
+
// cap is an INVARIANT, never a best-effort.
|
|
780
|
+
if (bodyBytes > MAX_ISSUE_BODY_BYTES) {
|
|
781
|
+
body = `${truncateToBytes(body, MAX_ISSUE_BODY_BYTES - 20)}\n… (truncated)`;
|
|
782
|
+
}
|
|
486
783
|
}
|
|
487
|
-
|
|
488
|
-
return { title, body: lines.join('\n') };
|
|
784
|
+
return { title, body };
|
|
489
785
|
}
|
|
490
786
|
/** Short, bounded release notes from injected `git log --oneline`-style lines. */
|
|
491
787
|
export function buildReleaseNotes(gitLogLines, limit = 15) {
|