@dzhechkov/harness-cli 0.8.24 → 0.8.25
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 +8 -8
- package/README.md +149 -21
- package/dist/cli.d.ts +37 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +524 -159
- package/dist/cli.js.map +1 -1
- package/package.json +2 -2
- package/sbom.json +7 -7
- package/src/cli.ts +507 -114
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @packageDocumentation
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
6
|
+
import { parseNpmPackInventory } from '@dzhechkov/harness-core';
|
|
7
|
+
import { appendFileSync, chmodSync, closeSync, constants as fsConstants, cpSync, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync, writeSync } from 'node:fs';
|
|
7
8
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
8
9
|
import { fileURLToPath } from 'node:url';
|
|
9
10
|
import { request as httpsRequest } from 'node:https';
|
|
@@ -6236,7 +6237,85 @@ function mirrorFailureMessage(error) {
|
|
|
6236
6237
|
function packedInstallScratchRoot() {
|
|
6237
6238
|
return existsSync('/var/tmp') ? '/var/tmp' : tmpdir();
|
|
6238
6239
|
}
|
|
6239
|
-
|
|
6240
|
+
/**
|
|
6241
|
+
* FR-1 (feature release-smoke-staged-pack): stage every target's `package.json` exactly like a
|
|
6242
|
+
* live publish packs it — `workspace:*` sibling specs rewritten to the exact sibling version
|
|
6243
|
+
* (`rewriteWorkspaceSpecs`), `scripts.prepublishOnly` dropped — run `fn`, then ALWAYS restore the
|
|
6244
|
+
* original bytes in a `finally`, whatever `fn` does or throws. A restore failure is reported
|
|
6245
|
+
* through `write` (with the path), never swallowed — the "absence of a receipt is not success"
|
|
6246
|
+
* rule this file follows everywhere else.
|
|
6247
|
+
*
|
|
6248
|
+
* `cmdPublish`'s dry-run preview and `cmdRelease`'s packed-install-smoke `pack` steps both go
|
|
6249
|
+
* through this ONE helper, so the two doors that ask "what would the registry receive?" pack the
|
|
6250
|
+
* exact same bytes (MEASURED 2026-09-13 16:05: `dz release` packed the live `workspace:*`
|
|
6251
|
+
* package.json and its smoke install died with EUNSUPPORTEDPROTOCOL — `dz publish`'s preview
|
|
6252
|
+
* already staged around this and release never got that).
|
|
6253
|
+
*/
|
|
6254
|
+
function withStagedPackageJson(targets, workspaceVersions, write, fn, label = 'dz') {
|
|
6255
|
+
// Lead edits after Codex review (2026-09-13, findings 1/5/6): every write — the staged text and
|
|
6256
|
+
// the restore — goes through a sibling temp file + rename, so a reader never sees a truncated
|
|
6257
|
+
// package.json; a restore that FAILS is an error the caller must see (thrown after fn, or attached
|
|
6258
|
+
// to fn's own error), never a warning that lets a run "succeed" on a damaged tree; and the
|
|
6259
|
+
// diagnostic keeps the calling command's name (`label`).
|
|
6260
|
+
const atomicWrite = (path, text) => {
|
|
6261
|
+
// Codex round 2: an EXCLUSIVE, randomized sibling temp — never a shared pid-named file
|
|
6262
|
+
const tmp = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.staged.tmp`;
|
|
6263
|
+
writeFileSync(tmp, text, { flag: 'wx' });
|
|
6264
|
+
renameSync(tmp, path);
|
|
6265
|
+
};
|
|
6266
|
+
const stagedOriginals = [];
|
|
6267
|
+
let fnError;
|
|
6268
|
+
let fnThrew = false;
|
|
6269
|
+
try {
|
|
6270
|
+
for (const p of targets) {
|
|
6271
|
+
const pkgJsonPath = join(p.dir, 'package.json');
|
|
6272
|
+
const original = readFileSync(pkgJsonPath, 'utf-8');
|
|
6273
|
+
const rewritten = JSON.parse(rewriteWorkspaceSpecs(original, workspaceVersions));
|
|
6274
|
+
const scripts = rewritten['scripts'];
|
|
6275
|
+
if (scripts !== null && typeof scripts === 'object' && !Array.isArray(scripts))
|
|
6276
|
+
delete scripts['prepublishOnly'];
|
|
6277
|
+
stagedOriginals.push({ path: pkgJsonPath, text: original });
|
|
6278
|
+
atomicWrite(pkgJsonPath, JSON.stringify(rewritten, null, 2) + '\n');
|
|
6279
|
+
}
|
|
6280
|
+
return fn();
|
|
6281
|
+
}
|
|
6282
|
+
catch (err) {
|
|
6283
|
+
fnThrew = true;
|
|
6284
|
+
fnError = err;
|
|
6285
|
+
throw err;
|
|
6286
|
+
}
|
|
6287
|
+
finally {
|
|
6288
|
+
const restoreFailures = [];
|
|
6289
|
+
for (const o of stagedOriginals) {
|
|
6290
|
+
try {
|
|
6291
|
+
atomicWrite(o.path, o.text);
|
|
6292
|
+
}
|
|
6293
|
+
catch (err) {
|
|
6294
|
+
const msg = `${label}: ✗ could not restore ${o.path} after staged packing: ${formatPublishError(err)} — the tree is left STAGED, restore it by hand`;
|
|
6295
|
+
try {
|
|
6296
|
+
write(msg);
|
|
6297
|
+
}
|
|
6298
|
+
catch { /* a throwing writer must not mask the restore failure */ }
|
|
6299
|
+
restoreFailures.push(msg);
|
|
6300
|
+
}
|
|
6301
|
+
}
|
|
6302
|
+
if (restoreFailures.length > 0) {
|
|
6303
|
+
// Codex round 2: one aggregate error carrying BOTH fn's own failure (if any) and the restore
|
|
6304
|
+
// failures — never a bare message assignment that could itself throw out of finally.
|
|
6305
|
+
const fnPart = fnThrew ? `\n(during: ${fnError instanceof Error ? fnError.message : String(fnError)})` : '';
|
|
6306
|
+
// eslint-disable-next-line no-unsafe-finally -- a damaged tree must not read as success
|
|
6307
|
+
throw new Error(`${restoreFailures.join('\n')}${fnPart}`);
|
|
6308
|
+
}
|
|
6309
|
+
}
|
|
6310
|
+
}
|
|
6311
|
+
function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDriftFetcher, packedInstallRunner, publishExecRunner, gateAuditFsLayer,
|
|
6312
|
+
/**
|
|
6313
|
+
* AM-5 (feature publish-gate-audit-durable): test seam for the sibling-drift gate's `npm pack
|
|
6314
|
+
* --dry-run --json` call — production leaves it unset (real `execFileSync`). Takes the package
|
|
6315
|
+
* dir, returns raw stdout, or THROWS to simulate a real `npm` failure — a test can then prove the
|
|
6316
|
+
* failure reaches `parseNpmPackInventory`'s caller as `unavailable`, never a real subprocess.
|
|
6317
|
+
*/
|
|
6318
|
+
npmPackRunner) {
|
|
6240
6319
|
const json = flags.has('json');
|
|
6241
6320
|
// Under --json stdout carries exactly one JSON document, so every human line — guard notes, refusals,
|
|
6242
6321
|
// progress — goes to stderr instead of being dropped: a refusal that prints nothing is the silent
|
|
@@ -6379,19 +6458,57 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDrift
|
|
|
6379
6458
|
return null;
|
|
6380
6459
|
}
|
|
6381
6460
|
});
|
|
6382
|
-
// AM-
|
|
6383
|
-
//
|
|
6384
|
-
//
|
|
6385
|
-
//
|
|
6386
|
-
|
|
6387
|
-
|
|
6388
|
-
|
|
6389
|
-
|
|
6390
|
-
|
|
6461
|
+
// AM-4: `npm pack --dry-run --json` is a real subprocess — cache it for the lifetime of this
|
|
6462
|
+
// ENTIRE run (keyed by resolved dir), NOT per package being checked (round-1 review, finding 5):
|
|
6463
|
+
// the cache used to be re-created inside the per-package loop body, so two different dependents
|
|
6464
|
+
// of the SAME sibling packed it twice. `npmPackRunner` (AM-5) is a test seam — production leaves
|
|
6465
|
+
// it unset and runs the real subprocess; a test injects a stub that throws to prove a real `npm`
|
|
6466
|
+
// failure reaches the caller as `unavailable`, without spawning anything.
|
|
6467
|
+
// Lead fix after the fix-round's live dry-run (2026-09-14 01:02, MEASURED on the hub): the
|
|
6468
|
+
// workspace side is now PACKED BY THE LIVE TRANSPORT — `pnpm pack` into a per-run temp dir,
|
|
6469
|
+
// unpacked, and handed to core as a `packedDir` that core hashes with the SAME full walk it uses
|
|
6470
|
+
// for the published tarball. `npm pack --dry-run --json` (kept behind the `npmPackRunner` test
|
|
6471
|
+
// seam) never lists the LICENSE pnpm synthesises from the workspace root into a package whose own
|
|
6472
|
+
// tree has none, so two siblings unchanged since publication (harness-presets, scout) read as
|
|
6473
|
+
// "LICENSE only in the published copy" — a false drift the fix-round's inventory could not see.
|
|
6474
|
+
// Honest limit: the seam path (tests) still parses npm's JSON; only production takes the pnpm path.
|
|
6475
|
+
const npmPackInventoryCache = new Map();
|
|
6476
|
+
let packTmpDir;
|
|
6477
|
+
const npmPackInventory = (dir) => {
|
|
6478
|
+
const key = resolve(dir);
|
|
6479
|
+
const hit = npmPackInventoryCache.get(key);
|
|
6480
|
+
if (hit !== undefined)
|
|
6481
|
+
return hit;
|
|
6482
|
+
let out;
|
|
6483
|
+
try {
|
|
6484
|
+
if (npmPackRunner !== undefined) {
|
|
6485
|
+
out = parseNpmPackInventory(npmPackRunner(dir));
|
|
6486
|
+
}
|
|
6487
|
+
else {
|
|
6488
|
+
packTmpDir ??= mkdtempSync(join(tmpdir(), 'dz-drift-pack-'));
|
|
6489
|
+
out = { packedDir: extractIntoTempDir(dir, mkdtempSync(join(packTmpDir, 'p-'))).dir };
|
|
6490
|
+
}
|
|
6391
6491
|
}
|
|
6392
|
-
|
|
6393
|
-
|
|
6492
|
+
catch (err) {
|
|
6493
|
+
const how = npmPackRunner !== undefined ? 'npm pack --dry-run --json' : 'pnpm pack';
|
|
6494
|
+
out = { unavailable: `${how} failed: ${err.message.split('\n')[0]}` };
|
|
6495
|
+
}
|
|
6496
|
+
npmPackInventoryCache.set(key, out);
|
|
6497
|
+
return out;
|
|
6394
6498
|
};
|
|
6499
|
+
const localInventorySource = npmPackRunner !== undefined ? 'npm-pack' : 'pnpm-pack';
|
|
6500
|
+
// AM-3 (Codex round-1 review, finding 4, high): sibling-drift audit records are EXACTLY one per
|
|
6501
|
+
// package per rule per RUN. The old code appended one JSONL record per SIBLING a package depends
|
|
6502
|
+
// on (a package with two drifted deps wrote two rows under the same rule), and the `unavailable`
|
|
6503
|
+
// branch without `--allow-sibling-drift` wrote NO record at all. Every sibling outcome for a
|
|
6504
|
+
// package is now aggregated first (`pkParts`/`pkVerdict`/`pkOverrideUsed`) and written ONCE —
|
|
6505
|
+
// `block` if any sibling blocks, else `warn` if the only issues were resolved via
|
|
6506
|
+
// `--allow-sibling-drift`, else `pass` — with a detail naming every sibling and its status.
|
|
6507
|
+
// `siblingDriftAudited` guarantees the single write even though `--include-drifted`'s fixed-point
|
|
6508
|
+
// loop can revisit the SAME package across rounds: a package's own `dependencies` never change
|
|
6509
|
+
// between rounds, so a later round can only ever re-derive a SUBSET of what the first pass
|
|
6510
|
+
// already covered (its siblings that drifted got folded into the batch and are now skipped).
|
|
6511
|
+
const siblingDriftAudited = new Set();
|
|
6395
6512
|
let driftBlocked = 0;
|
|
6396
6513
|
const extraBatch = new Set();
|
|
6397
6514
|
// AM-2: --include-drifted must reach a FIXED POINT over transitive drifted siblings — a sibling
|
|
@@ -6399,92 +6516,156 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDrift
|
|
|
6399
6516
|
// (finding 2) showed the single pass never re-checked an EXPANDED batch's own new edges. Capped at
|
|
6400
6517
|
// `allPackages.length + 1` rounds (the plan's own "цикл с потолком = число пакетов").
|
|
6401
6518
|
const maxRounds = allPackages.length + 1;
|
|
6402
|
-
|
|
6403
|
-
|
|
6404
|
-
|
|
6405
|
-
|
|
6406
|
-
|
|
6407
|
-
|
|
6408
|
-
|
|
6409
|
-
|
|
6410
|
-
|
|
6411
|
-
// cannot build — it must BLOCK, never silently degrade to "no dependencies" (which used to
|
|
6412
|
-
// read as a clean n/a).
|
|
6413
|
-
const reason = `package.json unreadable/invalid (${err.message.split('\n')[0]})`;
|
|
6414
|
-
if (allowSiblingDrift) {
|
|
6415
|
-
if (auditedOverride(`${pk.name}: ${reason}`, `sibling drift check unavailable for ${pk.name} (${reason})`, pk.name))
|
|
6416
|
-
driftBlocked++;
|
|
6519
|
+
// Codex round-3 (2026-09-14): the per-run pack scratch is released in a `finally`, so an
|
|
6520
|
+
// exception thrown while hashing or auditing cannot leak a `dz-drift-pack-*` dir under tmpdir.
|
|
6521
|
+
try {
|
|
6522
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
6523
|
+
let addedThisRound = false;
|
|
6524
|
+
for (const pk of targets) {
|
|
6525
|
+
let manifestObj;
|
|
6526
|
+
try {
|
|
6527
|
+
manifestObj = JSON.parse(readFileSync(join(pk.dir, 'package.json'), 'utf-8'));
|
|
6417
6528
|
}
|
|
6418
|
-
|
|
6419
|
-
|
|
6420
|
-
|
|
6529
|
+
catch (err) {
|
|
6530
|
+
// AM-3: an unreadable/invalid package.json for a BATCH package is an input this HARD gate
|
|
6531
|
+
// cannot build — it must BLOCK, never silently degrade to "no dependencies" (which used to
|
|
6532
|
+
// read as a clean n/a). AND (finding 4) the non-override branch below used to print a
|
|
6533
|
+
// BLOCKED line with NO audit record behind it — an `unavailable` outcome is logged exactly
|
|
6534
|
+
// like every other outcome, override or not.
|
|
6535
|
+
const reason = `package.json unreadable/invalid (${err.message.split('\n')[0]})`;
|
|
6536
|
+
if (!siblingDriftAudited.has(pk.name)) {
|
|
6537
|
+
siblingDriftAudited.add(pk.name);
|
|
6538
|
+
if (allowSiblingDrift) {
|
|
6539
|
+
const wrote = appendPublishGateAudit(cwd, 'sibling-drift', 'warn', `${pk.name}: ${reason} — allowed via --allow-sibling-drift`, [{ name: pk.name, version: pk.version }], '--allow-sibling-drift', gateAuditFsLayer);
|
|
6540
|
+
if (wrote.logged) {
|
|
6541
|
+
write(`dz publish: ⚠ sibling drift check unavailable for ${pk.name} (${reason}) — allowed via --allow-sibling-drift (logged)`);
|
|
6542
|
+
}
|
|
6543
|
+
else {
|
|
6544
|
+
write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable for ${pk.name} (${reason}), and the override could not be recorded (audit write failed: ${wrote.reason ?? 'unknown reason'}); refusing rather than proceeding unlogged`);
|
|
6545
|
+
driftBlocked++;
|
|
6546
|
+
}
|
|
6547
|
+
}
|
|
6548
|
+
else {
|
|
6549
|
+
const wrote = appendPublishGateAudit(cwd, 'sibling-drift', 'block', `${pk.name}: ${reason}`, [{ name: pk.name, version: pk.version }], undefined, gateAuditFsLayer);
|
|
6550
|
+
write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable (${reason}); add --allow-sibling-drift to override (logged) or fix the manifest${auditSuffix(wrote)}`);
|
|
6551
|
+
driftBlocked++;
|
|
6552
|
+
}
|
|
6553
|
+
}
|
|
6554
|
+
continue;
|
|
6421
6555
|
}
|
|
6422
|
-
|
|
6423
|
-
|
|
6424
|
-
|
|
6425
|
-
|
|
6426
|
-
|
|
6427
|
-
|
|
6428
|
-
|
|
6429
|
-
|
|
6430
|
-
|
|
6431
|
-
|
|
6432
|
-
|
|
6433
|
-
|
|
6434
|
-
|
|
6435
|
-
|
|
6436
|
-
|
|
6437
|
-
|
|
6438
|
-
|
|
6439
|
-
|
|
6440
|
-
|
|
6441
|
-
|
|
6442
|
-
|
|
6443
|
-
|
|
6444
|
-
|
|
6445
|
-
|
|
6446
|
-
|
|
6447
|
-
|
|
6556
|
+
const deps = manifestObj?.dependencies ?? {};
|
|
6557
|
+
const peerDeps = manifestObj?.peerDependencies ?? {};
|
|
6558
|
+
const optionalDeps = manifestObj?.optionalDependencies ?? {};
|
|
6559
|
+
// AM-6: a package with no EXTERNAL sibling to check at all is n/a for THIS gate — recorded as
|
|
6560
|
+
// a pass note ("no external siblings"), not silence (FR-6 compatibility: this branch prints
|
|
6561
|
+
// nothing to stdout, matching the pre-existing behavior). "External" covers BOTH "no
|
|
6562
|
+
// workspace: dependency declared" and "every workspace: dependency is inside THIS batch"
|
|
6563
|
+
// (publishing fresh, nothing stale to drift from) — both used to leave this package with no
|
|
6564
|
+
// audit record at all when every dep resolved to the second case.
|
|
6565
|
+
const anyWorkspaceDep = [...Object.values(deps), ...Object.values(peerDeps), ...Object.values(optionalDeps)]
|
|
6566
|
+
.some((spec) => String(spec).startsWith('workspace:'));
|
|
6567
|
+
const pkParts = [];
|
|
6568
|
+
let pkVerdict = 'pass';
|
|
6569
|
+
let pkOverrideUsed = false;
|
|
6570
|
+
if (anyWorkspaceDep) {
|
|
6571
|
+
const drifts = detectSiblingDrift({
|
|
6572
|
+
localInventory: npmPackInventory,
|
|
6573
|
+
localInventorySource,
|
|
6574
|
+
dependencies: deps,
|
|
6575
|
+
peerDependencies: peerDeps,
|
|
6576
|
+
optionalDependencies: optionalDeps,
|
|
6577
|
+
workspaceVersions,
|
|
6578
|
+
workspaceDirs,
|
|
6579
|
+
batch: batchNames,
|
|
6580
|
+
fetchPublished,
|
|
6581
|
+
});
|
|
6582
|
+
for (const r of drifts) {
|
|
6583
|
+
if (r.status === 'same') {
|
|
6584
|
+
pkParts.push(`${r.name}@${r.version}: same`);
|
|
6585
|
+
write(`dz publish: ✓ sibling drift: none (${r.name}@${r.version} = workspace)`);
|
|
6586
|
+
}
|
|
6587
|
+
else if (r.status === 'unavailable') {
|
|
6588
|
+
if (allowSiblingDrift) {
|
|
6589
|
+
pkParts.push(`${r.name}@${r.version}: unavailable (${r.reason}) — allowed via --allow-sibling-drift`);
|
|
6590
|
+
if (pkVerdict !== 'block')
|
|
6591
|
+
pkVerdict = 'warn';
|
|
6592
|
+
pkOverrideUsed = true;
|
|
6593
|
+
}
|
|
6594
|
+
else {
|
|
6595
|
+
pkParts.push(`${r.name}@${r.version}: unavailable (${r.reason})`);
|
|
6596
|
+
pkVerdict = 'block';
|
|
6597
|
+
write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable (${r.reason}); add --allow-sibling-drift to override (logged) or check network/registry access`);
|
|
6598
|
+
driftBlocked++;
|
|
6599
|
+
}
|
|
6600
|
+
}
|
|
6601
|
+
else if (includeDrifted) {
|
|
6602
|
+
pkParts.push(`${r.name}@${r.version}: drift (${r.changedFiles.length} file(s)) — auto-included via --include-drifted`);
|
|
6603
|
+
if (!batchNames.has(r.name) && !extraBatch.has(r.name)) {
|
|
6604
|
+
extraBatch.add(r.name);
|
|
6605
|
+
addedThisRound = true;
|
|
6606
|
+
write(`dz publish: → sibling drift: ${r.name}@${r.version} differs from the workspace (${r.changedFiles.length} file(s)) — adding to the batch via --include-drifted${r.missingExports.length > 0 ? ` (missing exports: ${r.missingExports.join(', ')})` : ''}`);
|
|
6607
|
+
}
|
|
6608
|
+
}
|
|
6609
|
+
else if (allowSiblingDrift) {
|
|
6610
|
+
pkParts.push(`${r.name}@${r.version}: drift (${r.changedFiles.length} file(s)) — allowed via --allow-sibling-drift`);
|
|
6611
|
+
if (pkVerdict !== 'block')
|
|
6612
|
+
pkVerdict = 'warn';
|
|
6613
|
+
pkOverrideUsed = true;
|
|
6614
|
+
}
|
|
6615
|
+
else {
|
|
6616
|
+
pkParts.push(`${r.name}@${r.version}: drift (${r.changedFiles.length} file(s))`);
|
|
6617
|
+
pkVerdict = 'block';
|
|
6618
|
+
const suggestFilter = filterStr !== undefined ? `${filterStr},${r.name}` : `${pk.name},${r.name}`;
|
|
6619
|
+
write(`dz publish: BLOCKED ${pk.name} — sibling drift: @dzhechkov/${r.name.replace(/^@dzhechkov\//, '')}@${r.version} on the registry differs from the workspace (${r.changedFiles.length} file(s)); add ${r.name} to the batch (--filter ${suggestFilter}) or publish it first`);
|
|
6620
|
+
driftBlocked++;
|
|
6621
|
+
}
|
|
6622
|
+
}
|
|
6448
6623
|
}
|
|
6449
|
-
|
|
6450
|
-
|
|
6451
|
-
|
|
6624
|
+
// AM-3/AM-6: the single, aggregated audit write for this package — "no external siblings"
|
|
6625
|
+
// when nothing was ever checked, otherwise every sibling's status joined into one detail.
|
|
6626
|
+
if (!siblingDriftAudited.has(pk.name)) {
|
|
6627
|
+
siblingDriftAudited.add(pk.name);
|
|
6628
|
+
const detail = pkParts.length > 0 ? pkParts.join('; ') : 'no external siblings';
|
|
6629
|
+
// AM-6: an override reason is attached only when the FINAL verdict is 'warn' — if some
|
|
6630
|
+
// OTHER sibling still stands as a live block, the override never actually excused the run.
|
|
6631
|
+
const overrideReason = pkOverrideUsed && pkVerdict !== 'block' ? '--allow-sibling-drift' : undefined;
|
|
6632
|
+
const wrote = appendPublishGateAudit(cwd, 'sibling-drift', pkVerdict, detail, [{ name: pk.name, version: pk.version }], overrideReason, gateAuditFsLayer);
|
|
6633
|
+
if (pkOverrideUsed) {
|
|
6634
|
+
// AM-6: the override is only real once ITS audit row is durable — a write failure must
|
|
6635
|
+
// refuse the publish rather than print "(logged)" about a record that never landed.
|
|
6636
|
+
if (wrote.logged) {
|
|
6637
|
+
write(`dz publish: ⚠ sibling drift override recorded for ${pk.name} — allowed via --allow-sibling-drift (logged)`);
|
|
6638
|
+
}
|
|
6639
|
+
else {
|
|
6640
|
+
write(`dz publish: BLOCKED ${pk.name} — the --allow-sibling-drift override could not be recorded (audit write failed: ${wrote.reason ?? 'unknown reason'}); refusing rather than proceeding unlogged`);
|
|
6452
6641
|
driftBlocked++;
|
|
6642
|
+
}
|
|
6453
6643
|
}
|
|
6454
|
-
else {
|
|
6455
|
-
write(`dz publish:
|
|
6456
|
-
driftBlocked++;
|
|
6644
|
+
else if (pkParts.length > 0) {
|
|
6645
|
+
write(`dz publish: ℹ sibling drift audit for ${pk.name}${auditSuffix(wrote)}`);
|
|
6457
6646
|
}
|
|
6458
|
-
|
|
6459
|
-
|
|
6460
|
-
|
|
6461
|
-
|
|
6462
|
-
addedThisRound = true;
|
|
6463
|
-
write(`dz publish: → sibling drift: ${r.name}@${r.version} differs from the workspace (${r.changedFiles.length} file(s)) — adding to the batch via --include-drifted${r.missingExports.length > 0 ? ` (missing exports: ${r.missingExports.join(', ')})` : ''}`);
|
|
6647
|
+
else if (!wrote.logged) {
|
|
6648
|
+
// Codex round-2 (2026-09-14), AM-6 residual: the "no external siblings" pass note stays
|
|
6649
|
+
// silent on stdout ONLY while its record actually landed — a failed audit write is said.
|
|
6650
|
+
write(`dz publish: ℹ sibling drift audit for ${pk.name} (no external siblings)${auditSuffix(wrote)}`);
|
|
6464
6651
|
}
|
|
6465
6652
|
}
|
|
6466
|
-
else if (allowSiblingDrift) {
|
|
6467
|
-
if (auditedOverride(`${r.name}@${r.version}: ${r.changedFiles.length} file(s) differ from the workspace`, `sibling drift: ${r.name}@${r.version} differs from the workspace (${r.changedFiles.length} file(s))`, pk.name))
|
|
6468
|
-
driftBlocked++;
|
|
6469
|
-
}
|
|
6470
|
-
else {
|
|
6471
|
-
appendPublishGateAudit(cwd, 'sibling-drift', 'block', `${pk.name} depends on ${r.name}@${r.version}; ${r.changedFiles.length} file(s) differ from the workspace`);
|
|
6472
|
-
const suggestFilter = filterStr !== undefined ? `${filterStr},${r.name}` : `${pk.name},${r.name}`;
|
|
6473
|
-
write(`dz publish: BLOCKED ${pk.name} — sibling drift: @dzhechkov/${r.name.replace(/^@dzhechkov\//, '')}@${r.version} on the registry differs from the workspace (${r.changedFiles.length} file(s)); add ${r.name} to the batch (--filter ${suggestFilter}) or publish it first`);
|
|
6474
|
-
driftBlocked++;
|
|
6475
|
-
}
|
|
6476
6653
|
}
|
|
6654
|
+
if (driftBlocked > 0)
|
|
6655
|
+
break; // nothing to expand into a run that already refuses
|
|
6656
|
+
if (!includeDrifted || !addedThisRound)
|
|
6657
|
+
break; // no auto-expand requested, or fixed point reached
|
|
6658
|
+
// FR-4: --include-drifted folds the drifted sibling(s) into the batch — they bump patch like
|
|
6659
|
+
// any other package in `publishPackages`' own (unchanged) bump logic. Re-loop: the newly
|
|
6660
|
+
// folded-in sibling(s) may themselves depend on a drifted sibling outside the (now bigger) batch.
|
|
6661
|
+
filter = filter === undefined ? [...batchNames, ...extraBatch] : [...filter, ...extraBatch];
|
|
6662
|
+
targets = allPackages.filter(matchesFilter);
|
|
6663
|
+
batchNames = new Set(targets.map((p) => p.name));
|
|
6477
6664
|
}
|
|
6478
|
-
|
|
6479
|
-
|
|
6480
|
-
if (
|
|
6481
|
-
|
|
6482
|
-
// FR-4: --include-drifted folds the drifted sibling(s) into the batch — they bump patch like
|
|
6483
|
-
// any other package in `publishPackages`' own (unchanged) bump logic. Re-loop: the newly
|
|
6484
|
-
// folded-in sibling(s) may themselves depend on a drifted sibling outside the (now bigger) batch.
|
|
6485
|
-
filter = filter === undefined ? [...batchNames, ...extraBatch] : [...filter, ...extraBatch];
|
|
6486
|
-
targets = allPackages.filter(matchesFilter);
|
|
6487
|
-
batchNames = new Set(targets.map((p) => p.name));
|
|
6665
|
+
}
|
|
6666
|
+
finally {
|
|
6667
|
+
if (packTmpDir !== undefined)
|
|
6668
|
+
rmSync(packTmpDir, { recursive: true, force: true });
|
|
6488
6669
|
}
|
|
6489
6670
|
const siblingDriftFailed = driftBlocked > 0;
|
|
6490
6671
|
if (siblingDriftFailed && !dryRun) {
|
|
@@ -6523,8 +6704,8 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDrift
|
|
|
6523
6704
|
let packedInstallSmokePreviewFailed = false;
|
|
6524
6705
|
if (dryRun) {
|
|
6525
6706
|
if (bins.length === 0) {
|
|
6526
|
-
appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin');
|
|
6527
|
-
write(
|
|
6707
|
+
const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin', targets.map((p) => ({ name: p.name, version: p.version })), undefined, gateAuditFsLayer);
|
|
6708
|
+
write(`dz publish: ○ packed install smoke: n/a (nothing in the batch declares a bin)${auditSuffix(wrote)}`);
|
|
6528
6709
|
}
|
|
6529
6710
|
else {
|
|
6530
6711
|
const scratchRoot = packedInstallScratchRoot();
|
|
@@ -6557,33 +6738,19 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDrift
|
|
|
6557
6738
|
// Lead edit after the live dry-run (13.09 12:05): the preview packed the WORKING directory with
|
|
6558
6739
|
// `workspace:^` specs still inside, so `npm install <tgz>` died with EUNSUPPORTEDPROTOCOL — the
|
|
6559
6740
|
// preview must stage package.json exactly as the live packedTransport does (sibling pins via
|
|
6560
|
-
// rewriteWorkspaceSpecs, prepublishOnly dropped) and restore the originals afterwards.
|
|
6561
|
-
|
|
6741
|
+
// rewriteWorkspaceSpecs, prepublishOnly dropped) and restore the originals afterwards. Shared
|
|
6742
|
+
// with `dz release` via `withStagedPackageJson` (feature release-smoke-staged-pack).
|
|
6562
6743
|
try {
|
|
6563
|
-
|
|
6564
|
-
const
|
|
6565
|
-
|
|
6566
|
-
|
|
6567
|
-
const scripts = rewritten['scripts'];
|
|
6568
|
-
if (scripts !== null && typeof scripts === 'object' && !Array.isArray(scripts))
|
|
6569
|
-
delete scripts['prepublishOnly'];
|
|
6570
|
-
stagedOriginals.push({ path: pkgJsonPath, text: original });
|
|
6571
|
-
writeFileSync(pkgJsonPath, JSON.stringify(rewritten, null, 2) + '\n');
|
|
6572
|
-
}
|
|
6573
|
-
for (const step of smokePlan.steps) {
|
|
6574
|
-
const r = runSmoke(step.cmd, { cwd: step.cwd, timeoutMs: step.timeoutMs });
|
|
6575
|
-
smokeExecutions.push({ stepId: step.id, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, ...(r.timedOut !== undefined ? { timedOut: r.timedOut } : {}) });
|
|
6576
|
-
}
|
|
6577
|
-
}
|
|
6578
|
-
finally {
|
|
6579
|
-
for (const o of stagedOriginals) {
|
|
6580
|
-
try {
|
|
6581
|
-
writeFileSync(o.path, o.text);
|
|
6744
|
+
withStagedPackageJson(targets, workspaceVersions, write, () => {
|
|
6745
|
+
for (const step of smokePlan.steps) {
|
|
6746
|
+
const r = runSmoke(step.cmd, { cwd: step.cwd, timeoutMs: step.timeoutMs });
|
|
6747
|
+
smokeExecutions.push({ stepId: step.id, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, ...(r.timedOut !== undefined ? { timedOut: r.timedOut } : {}) });
|
|
6582
6748
|
}
|
|
6583
|
-
|
|
6584
|
-
|
|
6585
|
-
|
|
6586
|
-
|
|
6749
|
+
}, 'dz publish');
|
|
6750
|
+
}
|
|
6751
|
+
catch (err) {
|
|
6752
|
+
// a restore failure is a preview failure (Codex finding 1): never a green preview on a staged tree
|
|
6753
|
+
smokeExecutions.push({ stepId: 'staged-restore', exitCode: 1, stdout: '', stderr: formatPublishError(err) });
|
|
6587
6754
|
}
|
|
6588
6755
|
const smokeVerdict = judgePackedInstallSmoke(smokePlan, smokeExecutions);
|
|
6589
6756
|
try {
|
|
@@ -6595,13 +6762,13 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDrift
|
|
|
6595
6762
|
}
|
|
6596
6763
|
catch { /* best-effort cleanup */ }
|
|
6597
6764
|
if (smokeVerdict.ok) {
|
|
6598
|
-
appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'preview: pack/install/--version all clean');
|
|
6599
|
-
write(
|
|
6765
|
+
const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'preview: pack/install/--version all clean', targets.map((p) => ({ name: p.name, version: p.version })), undefined, gateAuditFsLayer);
|
|
6766
|
+
write(`dz publish: ✓ packed install smoke (preview)${auditSuffix(wrote)}`);
|
|
6600
6767
|
}
|
|
6601
6768
|
else {
|
|
6602
6769
|
const detail = smokeVerdict.failureDetail ?? smokeVerdict.bins.find((b) => !b.ok)?.detail ?? '(no detail)';
|
|
6603
|
-
appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail);
|
|
6604
|
-
write(`dz publish: BLOCKED — packed install smoke failed (preview): ${detail}`);
|
|
6770
|
+
const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail, targets.map((p) => ({ name: p.name, version: p.version })), undefined, gateAuditFsLayer);
|
|
6771
|
+
write(`dz publish: BLOCKED — packed install smoke failed (preview): ${detail}${auditSuffix(wrote)}`);
|
|
6605
6772
|
for (const b of smokeVerdict.bins.filter((b) => !b.ok))
|
|
6606
6773
|
write(` ✗ ${b.pkg} (${b.binName}): ${b.detail ?? '(no detail)'}`);
|
|
6607
6774
|
packedInstallSmokePreviewFailed = true;
|
|
@@ -6658,23 +6825,37 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDrift
|
|
|
6658
6825
|
// `pnpm publish` and skipped re-signing (ADR-001, features/publish-gate-verifies-the-tarball).
|
|
6659
6826
|
let cleanupGate = null;
|
|
6660
6827
|
try {
|
|
6661
|
-
const
|
|
6662
|
-
|
|
6663
|
-
|
|
6664
|
-
|
|
6665
|
-
|
|
6666
|
-
|
|
6667
|
-
|
|
6668
|
-
|
|
6669
|
-
|
|
6670
|
-
|
|
6671
|
-
// claim about an object that was never built. Say why, and block.
|
|
6672
|
-
write(`dz publish: could not pack ${pk.name} (${err.message.split('\n')[0]}) — the artifact was never built, so its signature was not checked`);
|
|
6828
|
+
const parsedManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
6829
|
+
// FR-4 (feature publish-gate-audit-durable): a manifest that PARSES but is not a plain
|
|
6830
|
+
// object — `null`, an array, a bare string — is UNAVAILABLE, never "no signature". Those
|
|
6831
|
+
// are different failures with different fixes: "no signature" means run `dz sign`; a
|
|
6832
|
+
// malformed manifest means the file itself is corrupt/wrong-shaped and re-signing alone
|
|
6833
|
+
// would silently paper over that. Mirrors `readManifest`'s shape guard in
|
|
6834
|
+
// publish-sibling-drift.ts (null/array/non-object ⇒ cannot be used, say so).
|
|
6835
|
+
if (parsedManifest === null || typeof parsedManifest !== 'object' || Array.isArray(parsedManifest)) {
|
|
6836
|
+
const gotShape = parsedManifest === null ? 'null' : Array.isArray(parsedManifest) ? 'an array' : typeof parsedManifest;
|
|
6837
|
+
write(`dz publish: ${pk.name}'s ${MANIFEST_NAME} is not a JSON object (got ${gotShape}) — its signature is unavailable, not merely absent`);
|
|
6673
6838
|
artifactUnavailable = true;
|
|
6674
6839
|
}
|
|
6675
|
-
|
|
6676
|
-
|
|
6677
|
-
|
|
6840
|
+
else {
|
|
6841
|
+
const signed = parsedManifest;
|
|
6842
|
+
let extracted;
|
|
6843
|
+
try {
|
|
6844
|
+
extracted = extractPublishTarball(pk.dir);
|
|
6845
|
+
cleanupGate = extracted.cleanup;
|
|
6846
|
+
}
|
|
6847
|
+
catch (err) {
|
|
6848
|
+
// Cross-family review (codex `gpt-5.6-sol`, 2026-08-22): falling back to the working
|
|
6849
|
+
// TREE here fails the gate OPEN. The gate's whole claim is "what ships matches the
|
|
6850
|
+
// signature"; with no artifact, nothing was compared, and reporting a pass would be a
|
|
6851
|
+
// claim about an object that was never built. Say why, and block.
|
|
6852
|
+
write(`dz publish: could not pack ${pk.name} (${err.message.split('\n')[0]}) — the artifact was never built, so its signature was not checked`);
|
|
6853
|
+
artifactUnavailable = true;
|
|
6854
|
+
}
|
|
6855
|
+
verifyOk =
|
|
6856
|
+
extracted !== undefined &&
|
|
6857
|
+
verifyManifest(extracted.dir, signed, readFileSync(trustRoot, 'utf8')).ok;
|
|
6858
|
+
}
|
|
6678
6859
|
}
|
|
6679
6860
|
catch {
|
|
6680
6861
|
verifyOk = false;
|
|
@@ -6722,9 +6903,10 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDrift
|
|
|
6722
6903
|
smoke: (artifacts) => {
|
|
6723
6904
|
for (const a of artifacts)
|
|
6724
6905
|
write(`dz publish: tarball ${a.name}@${a.newVersion} sha256:${a.sha256}`);
|
|
6906
|
+
const auditPackages = artifacts.map((a) => ({ name: a.name, version: a.newVersion, tarballSha256: a.sha256 }));
|
|
6725
6907
|
if (bins.length === 0) {
|
|
6726
|
-
appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin');
|
|
6727
|
-
write(
|
|
6908
|
+
const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin', auditPackages, undefined, gateAuditFsLayer);
|
|
6909
|
+
write(`dz publish: ○ packed install smoke: n/a (nothing in the batch declares a bin)${auditSuffix(wrote)}`);
|
|
6728
6910
|
return { ok: true };
|
|
6729
6911
|
}
|
|
6730
6912
|
const scratchRoot = packedInstallScratchRoot();
|
|
@@ -6766,13 +6948,13 @@ function cmdPublish(options, flags, cwd, writeOutput, mirrorRunner, siblingDrift
|
|
|
6766
6948
|
}
|
|
6767
6949
|
catch { /* best-effort cleanup */ }
|
|
6768
6950
|
if (verdict.ok) {
|
|
6769
|
-
appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'pack/install/--version all clean (live, packedTransport)');
|
|
6770
|
-
write(
|
|
6951
|
+
const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'pack/install/--version all clean (live, packedTransport)', auditPackages, undefined, gateAuditFsLayer);
|
|
6952
|
+
write(`dz publish: ✓ packed install smoke${auditSuffix(wrote)}`);
|
|
6771
6953
|
return { ok: true };
|
|
6772
6954
|
}
|
|
6773
6955
|
const detail = verdict.failureDetail ?? verdict.bins.find((b) => !b.ok)?.detail ?? '(no detail)';
|
|
6774
|
-
appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail);
|
|
6775
|
-
write(`dz publish: BLOCKED — packed install smoke failed: ${detail}`);
|
|
6956
|
+
const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail, auditPackages, undefined, gateAuditFsLayer);
|
|
6957
|
+
write(`dz publish: BLOCKED — packed install smoke failed: ${detail}${auditSuffix(wrote)}`);
|
|
6776
6958
|
for (const b of verdict.bins.filter((b) => !b.ok))
|
|
6777
6959
|
write(` ✗ ${b.pkg} (${b.binName}): ${b.detail ?? '(no detail)'}`);
|
|
6778
6960
|
return { ok: false, reason: detail };
|
|
@@ -7352,7 +7534,20 @@ function cmdRelease(options, flags, cwd, write, runner) {
|
|
|
7352
7534
|
let smokeTmp;
|
|
7353
7535
|
const execSteps = plan.steps.filter((s) => s.kind !== 'synthetic-fail');
|
|
7354
7536
|
say(`\ndz release — executing ${execSteps.length} gate step(s) across ${plan.packages.length} package(s)…`);
|
|
7355
|
-
|
|
7537
|
+
// FR-1/FR-2 (feature release-smoke-staged-pack): the packed-install smoke's `pack` steps must
|
|
7538
|
+
// pack the SAME staged bytes `dz publish`'s preview does — sibling `workspace:*` deps rewritten
|
|
7539
|
+
// to the exact sibling version, `prepublishOnly` dropped — or `npm install` on the resulting
|
|
7540
|
+
// tarballs dies with EUNSUPPORTEDPROTOCOL (MEASURED 2026-09-13 16:05). `workspaceVersions` is
|
|
7541
|
+
// built like publish's (from the FULL workspace, not just this release's filtered batch — an
|
|
7542
|
+
// out-of-batch sibling still needs its real version). Only the `pack` sub-steps are staged; the
|
|
7543
|
+
// `install`/`bin-exists`/`bin-version` steps already run against the packed tarballs and need no
|
|
7544
|
+
// staging.
|
|
7545
|
+
const allPackages = discoverPackages(cwd);
|
|
7546
|
+
const workspaceVersions = new Map(allPackages.map((p) => [p.name, p.version]));
|
|
7547
|
+
const packStepIds = new Set((plan.packedInstallPlan?.steps ?? [])
|
|
7548
|
+
.filter((s) => s.kind === 'pack')
|
|
7549
|
+
.map((s) => `smoke:packed-install:${s.id}`));
|
|
7550
|
+
const runGateStep = (step) => {
|
|
7356
7551
|
let stepCwd = step.cwd;
|
|
7357
7552
|
if (step.tempCwd === true) {
|
|
7358
7553
|
// AM-4: boot bins in a throwaway cwd so an installer-style bin cannot mutate the workspace.
|
|
@@ -7370,6 +7565,37 @@ function cmdRelease(options, flags, cwd, write, runner) {
|
|
|
7370
7565
|
durationMs: Date.now() - started,
|
|
7371
7566
|
timedOut: r.timedOut,
|
|
7372
7567
|
});
|
|
7568
|
+
};
|
|
7569
|
+
// Plan/execution order is load-bearing (see the plan/execution skew guard test) — steps run in
|
|
7570
|
+
// exactly the order `plan.steps` lists them, one loop, no reordering. Only the `pack` steps are
|
|
7571
|
+
// wrapped in the staged window, individually, so where they fall in that order never changes.
|
|
7572
|
+
let announcedStagedPack = false;
|
|
7573
|
+
for (const step of execSteps) {
|
|
7574
|
+
if (packStepIds.has(step.id)) {
|
|
7575
|
+
if (!announcedStagedPack) {
|
|
7576
|
+
say('dz release: smoke: packed tarballs staged like the live publish (workspace:* → exact sibling versions)');
|
|
7577
|
+
announcedStagedPack = true;
|
|
7578
|
+
}
|
|
7579
|
+
// Codex finding 2: stage ONLY the package this pack step packs — sibling pins come from
|
|
7580
|
+
// workspaceVersions, and `npm pack` reads the packed package's manifest alone.
|
|
7581
|
+
const packed = factsList.filter((f) => f.name === step.pkg);
|
|
7582
|
+
try {
|
|
7583
|
+
withStagedPackageJson(packed, workspaceVersions, write, () => runGateStep(step), 'dz release');
|
|
7584
|
+
}
|
|
7585
|
+
catch (err) {
|
|
7586
|
+
// a staging/restore failure is a FAILED step with the reason, never a silent skip — and ONE
|
|
7587
|
+
// record per step: a run already recorded by runGateStep is replaced, not duplicated (Codex r2)
|
|
7588
|
+
const failed = { stepId: step.id, exitCode: 1, stdout: '', stderr: formatPublishError(err), durationMs: 0, timedOut: false };
|
|
7589
|
+
const at = executions.findIndex((e) => e.stepId === step.id);
|
|
7590
|
+
if (at >= 0)
|
|
7591
|
+
executions[at] = failed;
|
|
7592
|
+
else
|
|
7593
|
+
executions.push(failed);
|
|
7594
|
+
}
|
|
7595
|
+
}
|
|
7596
|
+
else {
|
|
7597
|
+
runGateStep(step);
|
|
7598
|
+
}
|
|
7373
7599
|
}
|
|
7374
7600
|
if (smokeTmp !== undefined) {
|
|
7375
7601
|
try {
|
|
@@ -7384,8 +7610,24 @@ function cmdRelease(options, flags, cwd, write, runner) {
|
|
|
7384
7610
|
for (const g of verdict.gates) {
|
|
7385
7611
|
const icon = g.status === 'pass' ? '✓' : g.status === 'fail' ? '✗' : '○';
|
|
7386
7612
|
say(` ${icon} ${g.gate.padEnd(7)} ${g.status.toUpperCase().padEnd(4)} ${g.passed} passed, ${g.failures.length} failed, ${g.skips.length} skipped`);
|
|
7387
|
-
for (const f of g.failures)
|
|
7613
|
+
for (const f of g.failures) {
|
|
7388
7614
|
say(` [${f.class}] ${f.pkg !== undefined ? `${f.pkg}: ` : ''}${f.reason}`);
|
|
7615
|
+
// FR-2 / AM-4 / AM-6 (feature release-gate-output-tail): print each non-empty stream's
|
|
7616
|
+
// tail under the failure line, labelled `stdout:`/`stderr:` at the failure's own 6-space
|
|
7617
|
+
// indent, with its content lines at an 8-space CONTINUATION indent so a reader can tell a
|
|
7618
|
+
// tail line from a new failure/skip bullet at a glance; --json carries the same `tails`
|
|
7619
|
+
// object as-is (present, possibly with empty strings, on every executed failure).
|
|
7620
|
+
if (f.tails !== undefined) {
|
|
7621
|
+
for (const stream of ['stdout', 'stderr']) {
|
|
7622
|
+
const t = f.tails[stream];
|
|
7623
|
+
if (t.length === 0)
|
|
7624
|
+
continue;
|
|
7625
|
+
say(` ${stream}:`);
|
|
7626
|
+
for (const tailLine of t.split('\n'))
|
|
7627
|
+
say(` ${tailLine}`);
|
|
7628
|
+
}
|
|
7629
|
+
}
|
|
7630
|
+
}
|
|
7389
7631
|
for (const sk of g.skips)
|
|
7390
7632
|
say(` [${sk.class}] ${sk.pkg}: ${sk.reason}`);
|
|
7391
7633
|
}
|
|
@@ -10211,31 +10453,154 @@ function runGuardEvaluation(root, op, text, overrideReason, publishFilter) {
|
|
|
10211
10453
|
return result;
|
|
10212
10454
|
}
|
|
10213
10455
|
/**
|
|
10214
|
-
*
|
|
10456
|
+
* AM-2: loop `writeSync` until every byte of `data` has been accepted, checking the RETURNED
|
|
10457
|
+
* length on every call (Codex round-1 review, finding 1: the old code called `writeSync` once and
|
|
10458
|
+
* ignored the return value — a short write followed by `fsyncSync` durably persists a TRUNCATED,
|
|
10459
|
+
* unparseable JSON line into an append-only log every future read walks). A call that reports zero
|
|
10460
|
+
* or negative progress can never complete the buffer and would spin forever — that is treated as a
|
|
10461
|
+
* failure, not a retry target.
|
|
10462
|
+
*/
|
|
10463
|
+
function writeAllSync(fsLayer, fd, data) {
|
|
10464
|
+
let remaining = data;
|
|
10465
|
+
while (remaining.length > 0) {
|
|
10466
|
+
const n = fsLayer.writeSync(fd, remaining);
|
|
10467
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) {
|
|
10468
|
+
throw new Error(`writeSync made no progress (returned ${n}) with ${remaining.length} byte(s) still pending`);
|
|
10469
|
+
}
|
|
10470
|
+
// Codex round-2 (2026-09-14) finding 3: a layer claiming MORE bytes than it was given is lying
|
|
10471
|
+
// about the record, and must not reach "logged: true" through a subarray that just goes empty.
|
|
10472
|
+
if (n > remaining.length) {
|
|
10473
|
+
throw new Error(`writeSync claimed ${n} byte(s) written but only ${remaining.length} were supplied`);
|
|
10474
|
+
}
|
|
10475
|
+
remaining = remaining.subarray(n);
|
|
10476
|
+
}
|
|
10477
|
+
}
|
|
10478
|
+
const REAL_PUBLISH_GATE_AUDIT_FS = { existsSync, mkdirSync, openSync, writeSync, fsyncSync, closeSync };
|
|
10479
|
+
/**
|
|
10480
|
+
* Feature `publish-sibling-drift-gate` (FR-5/AM-6), durability + per-package identity added by
|
|
10481
|
+
* feature `publish-gate-audit-durable` (FR-1/FR-2): both the sibling-drift and packed-install-smoke
|
|
10215
10482
|
* gates write to the SAME append-only, hash-chained `.dz/guard-audit.jsonl` the declarative
|
|
10216
10483
|
* `dz guard` rules use — visibility for `dz guard promote`/`dz compounding` never depends on
|
|
10217
10484
|
* which mechanism produced the finding. `pass` records go through as an informational `note`
|
|
10218
10485
|
* (never a violation, so they can never flip the row's own verdict) so a clean check is ALSO on
|
|
10219
10486
|
* the record, not just a block or an override (AM-6: "аудит без записи = не аудит").
|
|
10220
10487
|
*
|
|
10221
|
-
*
|
|
10222
|
-
*
|
|
10223
|
-
*
|
|
10224
|
-
* it (
|
|
10488
|
+
* FR-1: one JSONL record PER PACKAGE in `packages`, each naming the package, its version, and the
|
|
10489
|
+
* tarball's sha256 (or the explicit `sha256:n/a` before a tarball exists — a dry-run preview). FR-2:
|
|
10490
|
+
* the write is `openSync('a') → writeSync → fsyncSync(fd) → closeSync`, plus an `fsyncSync` of the
|
|
10491
|
+
* `.dz` directory itself the one time this call CREATES it (a file's own fsync durably persists its
|
|
10492
|
+
* bytes; the directory entry that makes the file findable after a crash needs its own fsync — the
|
|
10493
|
+
* same lesson `integration-apply.ts`'s `fsyncDirectory` already encodes).
|
|
10494
|
+
*
|
|
10495
|
+
* Returns `{ logged, reason? }` — never a bare boolean, so a caller can print WHY a write failed,
|
|
10496
|
+
* not just that it did. Most callers are best-effort (a write failure never blocks a verdict already
|
|
10497
|
+
* decided) — the one exception is an `--allow-sibling-drift` OVERRIDE, whose caller MUST check
|
|
10498
|
+
* `logged`: an override is not real without a durable row behind it (AM-6's load-bearing property —
|
|
10499
|
+
* see `auditedOverride` in `cmdPublish`).
|
|
10225
10500
|
*/
|
|
10226
|
-
function appendPublishGateAudit(root, rule, verdict, detail, overrideReason) {
|
|
10501
|
+
function appendPublishGateAudit(root, rule, verdict, detail, packages, overrideReason, fsLayer = REAL_PUBLISH_GATE_AUDIT_FS) {
|
|
10502
|
+
if (packages.length === 0)
|
|
10503
|
+
return { logged: true }; // nothing to record about — not a failure
|
|
10504
|
+
const dzDir = join(root, '.dz');
|
|
10505
|
+
const auditPath = join(dzDir, 'guard-audit.jsonl');
|
|
10506
|
+
const dzDirExisted = fsLayer.existsSync(dzDir);
|
|
10507
|
+
// AM-2: tracked BEFORE the write — this is what decides whether the append call below is about
|
|
10508
|
+
// to CREATE the file (needing a `.dz` directory-entry fsync afterwards) or extend an existing one
|
|
10509
|
+
// (whose directory entry is already durable from a previous append).
|
|
10510
|
+
const auditFileExisted = fsLayer.existsSync(auditPath);
|
|
10227
10511
|
try {
|
|
10228
|
-
|
|
10229
|
-
? { op: 'publish', verdict, violations: [], checked: [rule], notEstablished: [], notes: [`${rule}: ${detail}`] }
|
|
10230
|
-
: { op: 'publish', verdict, violations: [{ rule, severity: 'hard', detail }], checked: [rule], notEstablished: [] }, new Date().toISOString(), overrideReason !== undefined ? { reason: overrideReason } : undefined);
|
|
10231
|
-
mkdirSync(join(root, '.dz'), { recursive: true });
|
|
10232
|
-
const auditPath = join(root, '.dz', 'guard-audit.jsonl');
|
|
10233
|
-
writeFileSync(auditPath, appendChainedLines([rec], readLogTail(auditPath)), { flag: 'a' });
|
|
10234
|
-
return true;
|
|
10512
|
+
fsLayer.mkdirSync(dzDir, { recursive: true });
|
|
10235
10513
|
}
|
|
10236
|
-
catch {
|
|
10237
|
-
return
|
|
10514
|
+
catch (err) {
|
|
10515
|
+
return { logged: false, reason: `could not create ${dzDir}: ${err.message.split('\n')[0]}` };
|
|
10516
|
+
}
|
|
10517
|
+
const records = packages.map((pkg) => {
|
|
10518
|
+
const sha = pkg.tarballSha256 !== undefined && pkg.tarballSha256 !== '' ? pkg.tarballSha256 : 'n/a';
|
|
10519
|
+
const label = `${rule}: ${pkg.name}@${pkg.version} sha256:${sha} — ${detail}`;
|
|
10520
|
+
return auditRecord(verdict === 'pass'
|
|
10521
|
+
? { op: 'publish', verdict, violations: [], checked: [rule], notEstablished: [], notes: [label] }
|
|
10522
|
+
: { op: 'publish', verdict, violations: [{ rule, severity: 'hard', detail: label }], checked: [rule], notEstablished: [] }, new Date().toISOString(), overrideReason !== undefined ? { reason: overrideReason } : undefined);
|
|
10523
|
+
});
|
|
10524
|
+
let bytes;
|
|
10525
|
+
try {
|
|
10526
|
+
bytes = appendChainedLines(records, readLogTail(auditPath));
|
|
10527
|
+
}
|
|
10528
|
+
catch (err) {
|
|
10529
|
+
return { logged: false, reason: `could not build the chained record: ${err.message.split('\n')[0]}` };
|
|
10530
|
+
}
|
|
10531
|
+
if (bytes === '')
|
|
10532
|
+
return { logged: true };
|
|
10533
|
+
const buf = Buffer.from(bytes, 'utf-8');
|
|
10534
|
+
let fd;
|
|
10535
|
+
try {
|
|
10536
|
+
fd = fsLayer.openSync(auditPath, fsConstants.O_APPEND | fsConstants.O_CREAT | fsConstants.O_WRONLY);
|
|
10537
|
+
writeAllSync(fsLayer, fd, buf); // AM-2: loops on a short write, throws on no progress
|
|
10538
|
+
fsLayer.fsyncSync(fd);
|
|
10539
|
+
fsLayer.closeSync(fd);
|
|
10540
|
+
fd = undefined;
|
|
10541
|
+
}
|
|
10542
|
+
catch (err) {
|
|
10543
|
+
return { logged: false, reason: err.message.split('\n')[0] ?? String(err) };
|
|
10544
|
+
}
|
|
10545
|
+
finally {
|
|
10546
|
+
if (fd !== undefined) {
|
|
10547
|
+
try {
|
|
10548
|
+
fsLayer.closeSync(fd);
|
|
10549
|
+
}
|
|
10550
|
+
catch { /* best-effort */ }
|
|
10551
|
+
}
|
|
10552
|
+
}
|
|
10553
|
+
// AM-2 (Codex round-1 review, finding 1, high): a directory-entry fsync is REQUIRED, not
|
|
10554
|
+
// best-effort, whenever THIS call is the reason the entry needed persisting — swallowing its
|
|
10555
|
+
// failure used to let `logged: true` go out about a record whose directory entry can vanish on a
|
|
10556
|
+
// crash before the next fsck. Two DIFFERENT entries can need persisting, tracked independently:
|
|
10557
|
+
if (!auditFileExisted) {
|
|
10558
|
+
// The audit FILE was just created by the open() above — `.dz` (its containing directory) needs
|
|
10559
|
+
// its own fsync so the new directory entry survives a crash; the file's own fsync (above) only
|
|
10560
|
+
// guarantees the file's DATA, not that anything can find it afterwards.
|
|
10561
|
+
let dfd;
|
|
10562
|
+
try {
|
|
10563
|
+
dfd = fsLayer.openSync(dzDir, fsConstants.O_RDONLY);
|
|
10564
|
+
fsLayer.fsyncSync(dfd);
|
|
10565
|
+
}
|
|
10566
|
+
catch (err) {
|
|
10567
|
+
return { logged: false, reason: `could not fsync ${dzDir} after creating ${auditPath}: ${err.message.split('\n')[0]}` };
|
|
10568
|
+
}
|
|
10569
|
+
finally {
|
|
10570
|
+
if (dfd !== undefined) {
|
|
10571
|
+
try {
|
|
10572
|
+
fsLayer.closeSync(dfd);
|
|
10573
|
+
}
|
|
10574
|
+
catch { /* best-effort, does not affect the verdict already returned */ }
|
|
10575
|
+
}
|
|
10576
|
+
}
|
|
10577
|
+
}
|
|
10578
|
+
if (!dzDirExisted) {
|
|
10579
|
+
// `.dz` ITSELF was just created by `mkdirSync` above — its PARENT (`root`) needs its own fsync
|
|
10580
|
+
// so `.dz`'s OWN directory entry survives a crash (the fsync of `.dz` just above only durably
|
|
10581
|
+
// persists entries INSIDE `.dz`, not the fact that `.dz` exists at all).
|
|
10582
|
+
let pfd;
|
|
10583
|
+
try {
|
|
10584
|
+
pfd = fsLayer.openSync(root, fsConstants.O_RDONLY);
|
|
10585
|
+
fsLayer.fsyncSync(pfd);
|
|
10586
|
+
}
|
|
10587
|
+
catch (err) {
|
|
10588
|
+
return { logged: false, reason: `could not fsync ${root} after creating ${dzDir}: ${err.message.split('\n')[0]}` };
|
|
10589
|
+
}
|
|
10590
|
+
finally {
|
|
10591
|
+
if (pfd !== undefined) {
|
|
10592
|
+
try {
|
|
10593
|
+
fsLayer.closeSync(pfd);
|
|
10594
|
+
}
|
|
10595
|
+
catch { /* best-effort, does not affect the verdict already returned */ }
|
|
10596
|
+
}
|
|
10597
|
+
}
|
|
10238
10598
|
}
|
|
10599
|
+
return { logged: true };
|
|
10600
|
+
}
|
|
10601
|
+
/** FR-2: the printed suffix a caller appends to its own verdict line — never silent about logging. */
|
|
10602
|
+
function auditSuffix(wrote) {
|
|
10603
|
+
return wrote.logged ? ' (logged)' : ` (audit NOT logged: ${wrote.reason ?? 'unknown reason'})`;
|
|
10239
10604
|
}
|
|
10240
10605
|
function renderGuardObservation(observation) {
|
|
10241
10606
|
const tag = observation.status === 'unknown' ? 'note' : 'observe';
|
|
@@ -19757,7 +20122,7 @@ export async function runCli(argv, io = {}) {
|
|
|
19757
20122
|
case 'auto-canonicalize':
|
|
19758
20123
|
return await cmdAutoCanonicalize(options, cwd, write);
|
|
19759
20124
|
case 'publish':
|
|
19760
|
-
return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner, io.publishSiblingDriftFetcher, io.publishPackedInstallRunner, io.publishExecRunner);
|
|
20125
|
+
return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner, io.publishSiblingDriftFetcher, io.publishPackedInstallRunner, io.publishExecRunner, io.publishGateAuditFsLayer, io.publishNpmPackRunner);
|
|
19761
20126
|
case 'release':
|
|
19762
20127
|
return cmdRelease(options, flags, cwd, write, io.releaseRunner);
|
|
19763
20128
|
case 'parity':
|