@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/src/cli.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* @packageDocumentation
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { parseNpmPackInventory, type InventorySource, type LocalInventoryResult } from '@dzhechkov/harness-core';
|
|
8
|
+
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, type Dirent } from 'node:fs';
|
|
8
9
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
9
10
|
import { fileURLToPath } from 'node:url';
|
|
10
11
|
import { request as httpsRequest } from 'node:https';
|
|
@@ -955,6 +956,20 @@ export interface CliIo {
|
|
|
955
956
|
command: string,
|
|
956
957
|
options: { cwd?: string | URL | undefined; stdio?: unknown; encoding?: unknown; timeout?: number | undefined; env?: NodeJS.ProcessEnv | undefined },
|
|
957
958
|
) => string;
|
|
959
|
+
/**
|
|
960
|
+
* Test seam for `dz publish`'s gate-audit writer (feature `publish-gate-audit-durable`, FR-2):
|
|
961
|
+
* overrides the fs primitives `appendPublishGateAudit` uses for its durable append (production
|
|
962
|
+
* leaves it unset → the real `node:fs` functions). Lets a test make `fsyncSync` throw to prove
|
|
963
|
+
* `(audit NOT logged: …)` is printed and the write is reported as failed, without touching any
|
|
964
|
+
* other seam's filesystem.
|
|
965
|
+
*/
|
|
966
|
+
readonly publishGateAuditFsLayer?: PublishGateAuditFsLayer;
|
|
967
|
+
/**
|
|
968
|
+
* AM-5 (feature publish-gate-audit-durable): test seam for the sibling-drift gate's `npm pack
|
|
969
|
+
* --dry-run --json` call (production leaves it unset → real `execFileSync`). Takes the package
|
|
970
|
+
* dir, returns raw stdout, or throws to simulate a real `npm` failure without spawning anything.
|
|
971
|
+
*/
|
|
972
|
+
readonly publishNpmPackRunner?: (dir: string) => string;
|
|
958
973
|
/**
|
|
959
974
|
* Test seam for `dz install`: overrides the `npm install` subprocess (production leaves
|
|
960
975
|
* it unset → real `execSync`, stdio piped). A stub runner that pre-stages a fixture
|
|
@@ -7035,6 +7050,77 @@ function packedInstallScratchRoot(): string {
|
|
|
7035
7050
|
return existsSync('/var/tmp') ? '/var/tmp' : tmpdir();
|
|
7036
7051
|
}
|
|
7037
7052
|
|
|
7053
|
+
/**
|
|
7054
|
+
* FR-1 (feature release-smoke-staged-pack): stage every target's `package.json` exactly like a
|
|
7055
|
+
* live publish packs it — `workspace:*` sibling specs rewritten to the exact sibling version
|
|
7056
|
+
* (`rewriteWorkspaceSpecs`), `scripts.prepublishOnly` dropped — run `fn`, then ALWAYS restore the
|
|
7057
|
+
* original bytes in a `finally`, whatever `fn` does or throws. A restore failure is reported
|
|
7058
|
+
* through `write` (with the path), never swallowed — the "absence of a receipt is not success"
|
|
7059
|
+
* rule this file follows everywhere else.
|
|
7060
|
+
*
|
|
7061
|
+
* `cmdPublish`'s dry-run preview and `cmdRelease`'s packed-install-smoke `pack` steps both go
|
|
7062
|
+
* through this ONE helper, so the two doors that ask "what would the registry receive?" pack the
|
|
7063
|
+
* exact same bytes (MEASURED 2026-09-13 16:05: `dz release` packed the live `workspace:*`
|
|
7064
|
+
* package.json and its smoke install died with EUNSUPPORTEDPROTOCOL — `dz publish`'s preview
|
|
7065
|
+
* already staged around this and release never got that).
|
|
7066
|
+
*/
|
|
7067
|
+
function withStagedPackageJson<T>(
|
|
7068
|
+
targets: readonly { readonly dir: string }[],
|
|
7069
|
+
workspaceVersions: ReadonlyMap<string, string>,
|
|
7070
|
+
write: Write,
|
|
7071
|
+
fn: () => T,
|
|
7072
|
+
label = 'dz',
|
|
7073
|
+
): T {
|
|
7074
|
+
// Lead edits after Codex review (2026-09-13, findings 1/5/6): every write — the staged text and
|
|
7075
|
+
// the restore — goes through a sibling temp file + rename, so a reader never sees a truncated
|
|
7076
|
+
// package.json; a restore that FAILS is an error the caller must see (thrown after fn, or attached
|
|
7077
|
+
// to fn's own error), never a warning that lets a run "succeed" on a damaged tree; and the
|
|
7078
|
+
// diagnostic keeps the calling command's name (`label`).
|
|
7079
|
+
const atomicWrite = (path: string, text: string): void => {
|
|
7080
|
+
// Codex round 2: an EXCLUSIVE, randomized sibling temp — never a shared pid-named file
|
|
7081
|
+
const tmp = `${path}.${process.pid}.${randomBytes(6).toString('hex')}.staged.tmp`;
|
|
7082
|
+
writeFileSync(tmp, text, { flag: 'wx' });
|
|
7083
|
+
renameSync(tmp, path);
|
|
7084
|
+
};
|
|
7085
|
+
const stagedOriginals: Array<{ path: string; text: string }> = [];
|
|
7086
|
+
let fnError: unknown;
|
|
7087
|
+
let fnThrew = false;
|
|
7088
|
+
try {
|
|
7089
|
+
for (const p of targets) {
|
|
7090
|
+
const pkgJsonPath = join(p.dir, 'package.json');
|
|
7091
|
+
const original = readFileSync(pkgJsonPath, 'utf-8');
|
|
7092
|
+
const rewritten = JSON.parse(rewriteWorkspaceSpecs(original, workspaceVersions)) as Record<string, unknown>;
|
|
7093
|
+
const scripts = rewritten['scripts'];
|
|
7094
|
+
if (scripts !== null && typeof scripts === 'object' && !Array.isArray(scripts)) delete (scripts as Record<string, unknown>)['prepublishOnly'];
|
|
7095
|
+
stagedOriginals.push({ path: pkgJsonPath, text: original });
|
|
7096
|
+
atomicWrite(pkgJsonPath, JSON.stringify(rewritten, null, 2) + '\n');
|
|
7097
|
+
}
|
|
7098
|
+
return fn();
|
|
7099
|
+
} catch (err) {
|
|
7100
|
+
fnThrew = true;
|
|
7101
|
+
fnError = err;
|
|
7102
|
+
throw err;
|
|
7103
|
+
} finally {
|
|
7104
|
+
const restoreFailures: string[] = [];
|
|
7105
|
+
for (const o of stagedOriginals) {
|
|
7106
|
+
try {
|
|
7107
|
+
atomicWrite(o.path, o.text);
|
|
7108
|
+
} catch (err) {
|
|
7109
|
+
const msg = `${label}: ✗ could not restore ${o.path} after staged packing: ${formatPublishError(err)} — the tree is left STAGED, restore it by hand`;
|
|
7110
|
+
try { write(msg); } catch { /* a throwing writer must not mask the restore failure */ }
|
|
7111
|
+
restoreFailures.push(msg);
|
|
7112
|
+
}
|
|
7113
|
+
}
|
|
7114
|
+
if (restoreFailures.length > 0) {
|
|
7115
|
+
// Codex round 2: one aggregate error carrying BOTH fn's own failure (if any) and the restore
|
|
7116
|
+
// failures — never a bare message assignment that could itself throw out of finally.
|
|
7117
|
+
const fnPart = fnThrew ? `\n(during: ${fnError instanceof Error ? fnError.message : String(fnError)})` : '';
|
|
7118
|
+
// eslint-disable-next-line no-unsafe-finally -- a damaged tree must not read as success
|
|
7119
|
+
throw new Error(`${restoreFailures.join('\n')}${fnPart}`);
|
|
7120
|
+
}
|
|
7121
|
+
}
|
|
7122
|
+
}
|
|
7123
|
+
|
|
7038
7124
|
function cmdPublish(
|
|
7039
7125
|
options: Map<string, string>,
|
|
7040
7126
|
flags: Set<string>,
|
|
@@ -7047,6 +7133,14 @@ function cmdPublish(
|
|
|
7047
7133
|
command: string,
|
|
7048
7134
|
options: { cwd?: string | URL | undefined; stdio?: unknown; encoding?: unknown; timeout?: number | undefined; env?: NodeJS.ProcessEnv | undefined },
|
|
7049
7135
|
) => string,
|
|
7136
|
+
gateAuditFsLayer?: PublishGateAuditFsLayer,
|
|
7137
|
+
/**
|
|
7138
|
+
* AM-5 (feature publish-gate-audit-durable): test seam for the sibling-drift gate's `npm pack
|
|
7139
|
+
* --dry-run --json` call — production leaves it unset (real `execFileSync`). Takes the package
|
|
7140
|
+
* dir, returns raw stdout, or THROWS to simulate a real `npm` failure — a test can then prove the
|
|
7141
|
+
* failure reaches `parseNpmPackInventory`'s caller as `unavailable`, never a real subprocess.
|
|
7142
|
+
*/
|
|
7143
|
+
npmPackRunner?: (dir: string) => string,
|
|
7050
7144
|
): number {
|
|
7051
7145
|
const json = flags.has('json');
|
|
7052
7146
|
// Under --json stdout carries exactly one JSON document, so every human line — guard notes, refusals,
|
|
@@ -7187,19 +7281,55 @@ function cmdPublish(
|
|
|
7187
7281
|
}
|
|
7188
7282
|
});
|
|
7189
7283
|
|
|
7190
|
-
// AM-
|
|
7191
|
-
//
|
|
7192
|
-
//
|
|
7193
|
-
//
|
|
7194
|
-
|
|
7195
|
-
|
|
7196
|
-
|
|
7197
|
-
|
|
7198
|
-
|
|
7284
|
+
// AM-4: `npm pack --dry-run --json` is a real subprocess — cache it for the lifetime of this
|
|
7285
|
+
// ENTIRE run (keyed by resolved dir), NOT per package being checked (round-1 review, finding 5):
|
|
7286
|
+
// the cache used to be re-created inside the per-package loop body, so two different dependents
|
|
7287
|
+
// of the SAME sibling packed it twice. `npmPackRunner` (AM-5) is a test seam — production leaves
|
|
7288
|
+
// it unset and runs the real subprocess; a test injects a stub that throws to prove a real `npm`
|
|
7289
|
+
// failure reaches the caller as `unavailable`, without spawning anything.
|
|
7290
|
+
// Lead fix after the fix-round's live dry-run (2026-09-14 01:02, MEASURED on the hub): the
|
|
7291
|
+
// workspace side is now PACKED BY THE LIVE TRANSPORT — `pnpm pack` into a per-run temp dir,
|
|
7292
|
+
// unpacked, and handed to core as a `packedDir` that core hashes with the SAME full walk it uses
|
|
7293
|
+
// for the published tarball. `npm pack --dry-run --json` (kept behind the `npmPackRunner` test
|
|
7294
|
+
// seam) never lists the LICENSE pnpm synthesises from the workspace root into a package whose own
|
|
7295
|
+
// tree has none, so two siblings unchanged since publication (harness-presets, scout) read as
|
|
7296
|
+
// "LICENSE only in the published copy" — a false drift the fix-round's inventory could not see.
|
|
7297
|
+
// Honest limit: the seam path (tests) still parses npm's JSON; only production takes the pnpm path.
|
|
7298
|
+
const npmPackInventoryCache = new Map<string, LocalInventoryResult>();
|
|
7299
|
+
let packTmpDir: string | undefined;
|
|
7300
|
+
const npmPackInventory = (dir: string): LocalInventoryResult => {
|
|
7301
|
+
const key = resolve(dir);
|
|
7302
|
+
const hit = npmPackInventoryCache.get(key);
|
|
7303
|
+
if (hit !== undefined) return hit;
|
|
7304
|
+
let out: LocalInventoryResult;
|
|
7305
|
+
try {
|
|
7306
|
+
if (npmPackRunner !== undefined) {
|
|
7307
|
+
out = parseNpmPackInventory(npmPackRunner(dir));
|
|
7308
|
+
} else {
|
|
7309
|
+
packTmpDir ??= mkdtempSync(join(tmpdir(), 'dz-drift-pack-'));
|
|
7310
|
+
out = { packedDir: extractIntoTempDir(dir, mkdtempSync(join(packTmpDir, 'p-'))).dir };
|
|
7311
|
+
}
|
|
7312
|
+
} catch (err) {
|
|
7313
|
+
const how = npmPackRunner !== undefined ? 'npm pack --dry-run --json' : 'pnpm pack';
|
|
7314
|
+
out = { unavailable: `${how} failed: ${(err as Error).message.split('\n')[0]}` };
|
|
7199
7315
|
}
|
|
7200
|
-
|
|
7201
|
-
return
|
|
7316
|
+
npmPackInventoryCache.set(key, out);
|
|
7317
|
+
return out;
|
|
7202
7318
|
};
|
|
7319
|
+
const localInventorySource: InventorySource = npmPackRunner !== undefined ? 'npm-pack' : 'pnpm-pack';
|
|
7320
|
+
|
|
7321
|
+
// AM-3 (Codex round-1 review, finding 4, high): sibling-drift audit records are EXACTLY one per
|
|
7322
|
+
// package per rule per RUN. The old code appended one JSONL record per SIBLING a package depends
|
|
7323
|
+
// on (a package with two drifted deps wrote two rows under the same rule), and the `unavailable`
|
|
7324
|
+
// branch without `--allow-sibling-drift` wrote NO record at all. Every sibling outcome for a
|
|
7325
|
+
// package is now aggregated first (`pkParts`/`pkVerdict`/`pkOverrideUsed`) and written ONCE —
|
|
7326
|
+
// `block` if any sibling blocks, else `warn` if the only issues were resolved via
|
|
7327
|
+
// `--allow-sibling-drift`, else `pass` — with a detail naming every sibling and its status.
|
|
7328
|
+
// `siblingDriftAudited` guarantees the single write even though `--include-drifted`'s fixed-point
|
|
7329
|
+
// loop can revisit the SAME package across rounds: a package's own `dependencies` never change
|
|
7330
|
+
// between rounds, so a later round can only ever re-derive a SUBSET of what the first pass
|
|
7331
|
+
// already covered (its siblings that drifted got folded into the batch and are now skipped).
|
|
7332
|
+
const siblingDriftAudited = new Set<string>();
|
|
7203
7333
|
|
|
7204
7334
|
let driftBlocked = 0;
|
|
7205
7335
|
const extraBatch = new Set<string>();
|
|
@@ -7208,6 +7338,9 @@ function cmdPublish(
|
|
|
7208
7338
|
// (finding 2) showed the single pass never re-checked an EXPANDED batch's own new edges. Capped at
|
|
7209
7339
|
// `allPackages.length + 1` rounds (the plan's own "цикл с потолком = число пакетов").
|
|
7210
7340
|
const maxRounds = allPackages.length + 1;
|
|
7341
|
+
// Codex round-3 (2026-09-14): the per-run pack scratch is released in a `finally`, so an
|
|
7342
|
+
// exception thrown while hashing or auditing cannot leak a `dz-drift-pack-*` dir under tmpdir.
|
|
7343
|
+
try {
|
|
7211
7344
|
for (let round = 0; round < maxRounds; round++) {
|
|
7212
7345
|
let addedThisRound = false;
|
|
7213
7346
|
for (const pk of targets) {
|
|
@@ -7221,13 +7354,25 @@ function cmdPublish(
|
|
|
7221
7354
|
} catch (err) {
|
|
7222
7355
|
// AM-3: an unreadable/invalid package.json for a BATCH package is an input this HARD gate
|
|
7223
7356
|
// cannot build — it must BLOCK, never silently degrade to "no dependencies" (which used to
|
|
7224
|
-
// read as a clean n/a).
|
|
7357
|
+
// read as a clean n/a). AND (finding 4) the non-override branch below used to print a
|
|
7358
|
+
// BLOCKED line with NO audit record behind it — an `unavailable` outcome is logged exactly
|
|
7359
|
+
// like every other outcome, override or not.
|
|
7225
7360
|
const reason = `package.json unreadable/invalid (${(err as Error).message.split('\n')[0]})`;
|
|
7226
|
-
if (
|
|
7227
|
-
|
|
7228
|
-
|
|
7229
|
-
|
|
7230
|
-
|
|
7361
|
+
if (!siblingDriftAudited.has(pk.name)) {
|
|
7362
|
+
siblingDriftAudited.add(pk.name);
|
|
7363
|
+
if (allowSiblingDrift) {
|
|
7364
|
+
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);
|
|
7365
|
+
if (wrote.logged) {
|
|
7366
|
+
write(`dz publish: ⚠ sibling drift check unavailable for ${pk.name} (${reason}) — allowed via --allow-sibling-drift (logged)`);
|
|
7367
|
+
} else {
|
|
7368
|
+
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`);
|
|
7369
|
+
driftBlocked++;
|
|
7370
|
+
}
|
|
7371
|
+
} else {
|
|
7372
|
+
const wrote = appendPublishGateAudit(cwd, 'sibling-drift', 'block', `${pk.name}: ${reason}`, [{ name: pk.name, version: pk.version }], undefined, gateAuditFsLayer);
|
|
7373
|
+
write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable (${reason}); add --allow-sibling-drift to override (logged) or fix the manifest${auditSuffix(wrote)}`);
|
|
7374
|
+
driftBlocked++;
|
|
7375
|
+
}
|
|
7231
7376
|
}
|
|
7232
7377
|
continue;
|
|
7233
7378
|
}
|
|
@@ -7235,49 +7380,92 @@ function cmdPublish(
|
|
|
7235
7380
|
const peerDeps = manifestObj?.peerDependencies ?? {};
|
|
7236
7381
|
const optionalDeps = manifestObj?.optionalDependencies ?? {};
|
|
7237
7382
|
|
|
7238
|
-
// AM-6: a package with no
|
|
7239
|
-
// pass note, not silence (FR-6 compatibility:
|
|
7383
|
+
// AM-6: a package with no EXTERNAL sibling to check at all is n/a for THIS gate — recorded as
|
|
7384
|
+
// a pass note ("no external siblings"), not silence (FR-6 compatibility: this branch prints
|
|
7385
|
+
// nothing to stdout, matching the pre-existing behavior). "External" covers BOTH "no
|
|
7386
|
+
// workspace: dependency declared" and "every workspace: dependency is inside THIS batch"
|
|
7387
|
+
// (publishing fresh, nothing stale to drift from) — both used to leave this package with no
|
|
7388
|
+
// audit record at all when every dep resolved to the second case.
|
|
7240
7389
|
const anyWorkspaceDep = [...Object.values(deps), ...Object.values(peerDeps), ...Object.values(optionalDeps)]
|
|
7241
7390
|
.some((spec) => String(spec).startsWith('workspace:'));
|
|
7242
|
-
if (!anyWorkspaceDep) {
|
|
7243
|
-
appendPublishGateAudit(cwd, 'sibling-drift', 'pass', `${pk.name}: n/a — no workspace: dependency declared`);
|
|
7244
|
-
continue;
|
|
7245
|
-
}
|
|
7246
7391
|
|
|
7247
|
-
const
|
|
7248
|
-
|
|
7249
|
-
|
|
7250
|
-
|
|
7251
|
-
|
|
7252
|
-
|
|
7253
|
-
|
|
7254
|
-
|
|
7255
|
-
|
|
7392
|
+
const pkParts: string[] = [];
|
|
7393
|
+
let pkVerdict: 'pass' | 'warn' | 'block' = 'pass';
|
|
7394
|
+
let pkOverrideUsed = false;
|
|
7395
|
+
|
|
7396
|
+
if (anyWorkspaceDep) {
|
|
7397
|
+
const drifts = detectSiblingDrift({
|
|
7398
|
+
localInventory: npmPackInventory,
|
|
7399
|
+
localInventorySource,
|
|
7400
|
+
dependencies: deps,
|
|
7401
|
+
peerDependencies: peerDeps,
|
|
7402
|
+
optionalDependencies: optionalDeps,
|
|
7403
|
+
workspaceVersions,
|
|
7404
|
+
workspaceDirs,
|
|
7405
|
+
batch: batchNames,
|
|
7406
|
+
fetchPublished,
|
|
7407
|
+
});
|
|
7256
7408
|
|
|
7257
|
-
|
|
7258
|
-
|
|
7259
|
-
|
|
7260
|
-
|
|
7261
|
-
|
|
7262
|
-
|
|
7263
|
-
|
|
7409
|
+
for (const r of drifts) {
|
|
7410
|
+
if (r.status === 'same') {
|
|
7411
|
+
pkParts.push(`${r.name}@${r.version}: same`);
|
|
7412
|
+
write(`dz publish: ✓ sibling drift: none (${r.name}@${r.version} = workspace)`);
|
|
7413
|
+
} else if (r.status === 'unavailable') {
|
|
7414
|
+
if (allowSiblingDrift) {
|
|
7415
|
+
pkParts.push(`${r.name}@${r.version}: unavailable (${r.reason}) — allowed via --allow-sibling-drift`);
|
|
7416
|
+
if (pkVerdict !== 'block') pkVerdict = 'warn';
|
|
7417
|
+
pkOverrideUsed = true;
|
|
7418
|
+
} else {
|
|
7419
|
+
pkParts.push(`${r.name}@${r.version}: unavailable (${r.reason})`);
|
|
7420
|
+
pkVerdict = 'block';
|
|
7421
|
+
write(`dz publish: BLOCKED ${pk.name} — sibling drift check unavailable (${r.reason}); add --allow-sibling-drift to override (logged) or check network/registry access`);
|
|
7422
|
+
driftBlocked++;
|
|
7423
|
+
}
|
|
7424
|
+
} else if (includeDrifted) {
|
|
7425
|
+
pkParts.push(`${r.name}@${r.version}: drift (${r.changedFiles.length} file(s)) — auto-included via --include-drifted`);
|
|
7426
|
+
if (!batchNames.has(r.name) && !extraBatch.has(r.name)) {
|
|
7427
|
+
extraBatch.add(r.name);
|
|
7428
|
+
addedThisRound = true;
|
|
7429
|
+
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(', ')})` : ''}`);
|
|
7430
|
+
}
|
|
7431
|
+
} else if (allowSiblingDrift) {
|
|
7432
|
+
pkParts.push(`${r.name}@${r.version}: drift (${r.changedFiles.length} file(s)) — allowed via --allow-sibling-drift`);
|
|
7433
|
+
if (pkVerdict !== 'block') pkVerdict = 'warn';
|
|
7434
|
+
pkOverrideUsed = true;
|
|
7264
7435
|
} else {
|
|
7265
|
-
|
|
7436
|
+
pkParts.push(`${r.name}@${r.version}: drift (${r.changedFiles.length} file(s))`);
|
|
7437
|
+
pkVerdict = 'block';
|
|
7438
|
+
const suggestFilter = filterStr !== undefined ? `${filterStr},${r.name}` : `${pk.name},${r.name}`;
|
|
7439
|
+
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`);
|
|
7266
7440
|
driftBlocked++;
|
|
7267
7441
|
}
|
|
7268
|
-
}
|
|
7269
|
-
|
|
7270
|
-
|
|
7271
|
-
|
|
7272
|
-
|
|
7442
|
+
}
|
|
7443
|
+
}
|
|
7444
|
+
|
|
7445
|
+
// AM-3/AM-6: the single, aggregated audit write for this package — "no external siblings"
|
|
7446
|
+
// when nothing was ever checked, otherwise every sibling's status joined into one detail.
|
|
7447
|
+
if (!siblingDriftAudited.has(pk.name)) {
|
|
7448
|
+
siblingDriftAudited.add(pk.name);
|
|
7449
|
+
const detail = pkParts.length > 0 ? pkParts.join('; ') : 'no external siblings';
|
|
7450
|
+
// AM-6: an override reason is attached only when the FINAL verdict is 'warn' — if some
|
|
7451
|
+
// OTHER sibling still stands as a live block, the override never actually excused the run.
|
|
7452
|
+
const overrideReason = pkOverrideUsed && pkVerdict !== 'block' ? '--allow-sibling-drift' : undefined;
|
|
7453
|
+
const wrote = appendPublishGateAudit(cwd, 'sibling-drift', pkVerdict, detail, [{ name: pk.name, version: pk.version }], overrideReason, gateAuditFsLayer);
|
|
7454
|
+
if (pkOverrideUsed) {
|
|
7455
|
+
// AM-6: the override is only real once ITS audit row is durable — a write failure must
|
|
7456
|
+
// refuse the publish rather than print "(logged)" about a record that never landed.
|
|
7457
|
+
if (wrote.logged) {
|
|
7458
|
+
write(`dz publish: ⚠ sibling drift override recorded for ${pk.name} — allowed via --allow-sibling-drift (logged)`);
|
|
7459
|
+
} else {
|
|
7460
|
+
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`);
|
|
7461
|
+
driftBlocked++;
|
|
7273
7462
|
}
|
|
7274
|
-
} else if (
|
|
7275
|
-
|
|
7276
|
-
} else {
|
|
7277
|
-
|
|
7278
|
-
|
|
7279
|
-
write(`dz publish:
|
|
7280
|
-
driftBlocked++;
|
|
7463
|
+
} else if (pkParts.length > 0) {
|
|
7464
|
+
write(`dz publish: ℹ sibling drift audit for ${pk.name}${auditSuffix(wrote)}`);
|
|
7465
|
+
} else if (!wrote.logged) {
|
|
7466
|
+
// Codex round-2 (2026-09-14), AM-6 residual: the "no external siblings" pass note stays
|
|
7467
|
+
// silent on stdout ONLY while its record actually landed — a failed audit write is said.
|
|
7468
|
+
write(`dz publish: ℹ sibling drift audit for ${pk.name} (no external siblings)${auditSuffix(wrote)}`);
|
|
7281
7469
|
}
|
|
7282
7470
|
}
|
|
7283
7471
|
}
|
|
@@ -7292,6 +7480,9 @@ function cmdPublish(
|
|
|
7292
7480
|
targets = allPackages.filter(matchesFilter);
|
|
7293
7481
|
batchNames = new Set(targets.map((p) => p.name));
|
|
7294
7482
|
}
|
|
7483
|
+
} finally {
|
|
7484
|
+
if (packTmpDir !== undefined) rmSync(packTmpDir, { recursive: true, force: true });
|
|
7485
|
+
}
|
|
7295
7486
|
|
|
7296
7487
|
const siblingDriftFailed = driftBlocked > 0;
|
|
7297
7488
|
if (siblingDriftFailed && !dryRun) {
|
|
@@ -7328,8 +7519,8 @@ function cmdPublish(
|
|
|
7328
7519
|
let packedInstallSmokePreviewFailed = false;
|
|
7329
7520
|
if (dryRun) {
|
|
7330
7521
|
if (bins.length === 0) {
|
|
7331
|
-
appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin');
|
|
7332
|
-
write(
|
|
7522
|
+
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);
|
|
7523
|
+
write(`dz publish: ○ packed install smoke: n/a (nothing in the batch declares a bin)${auditSuffix(wrote)}`);
|
|
7333
7524
|
} else {
|
|
7334
7525
|
const scratchRoot = packedInstallScratchRoot();
|
|
7335
7526
|
const packDir = mkdtempSync(join(scratchRoot, 'dz-publish-pack-'));
|
|
@@ -7361,36 +7552,30 @@ function cmdPublish(
|
|
|
7361
7552
|
// Lead edit after the live dry-run (13.09 12:05): the preview packed the WORKING directory with
|
|
7362
7553
|
// `workspace:^` specs still inside, so `npm install <tgz>` died with EUNSUPPORTEDPROTOCOL — the
|
|
7363
7554
|
// preview must stage package.json exactly as the live packedTransport does (sibling pins via
|
|
7364
|
-
// rewriteWorkspaceSpecs, prepublishOnly dropped) and restore the originals afterwards.
|
|
7365
|
-
|
|
7555
|
+
// rewriteWorkspaceSpecs, prepublishOnly dropped) and restore the originals afterwards. Shared
|
|
7556
|
+
// with `dz release` via `withStagedPackageJson` (feature release-smoke-staged-pack).
|
|
7366
7557
|
try {
|
|
7367
|
-
|
|
7368
|
-
const
|
|
7369
|
-
|
|
7370
|
-
|
|
7371
|
-
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
|
|
7375
|
-
}
|
|
7376
|
-
for (const step of smokePlan.steps) {
|
|
7377
|
-
const r = runSmoke(step.cmd, { cwd: step.cwd, timeoutMs: step.timeoutMs });
|
|
7378
|
-
smokeExecutions.push({ stepId: step.id, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, ...(r.timedOut !== undefined ? { timedOut: r.timedOut } : {}) });
|
|
7379
|
-
}
|
|
7380
|
-
} finally {
|
|
7381
|
-
for (const o of stagedOriginals) { try { writeFileSync(o.path, o.text); } catch (err) { write(`dz publish: ⚠ could not restore ${o.path} after the preview smoke: ${formatPublishError(err)}`); } }
|
|
7558
|
+
withStagedPackageJson(targets, workspaceVersions, write, () => {
|
|
7559
|
+
for (const step of smokePlan.steps) {
|
|
7560
|
+
const r = runSmoke(step.cmd, { cwd: step.cwd, timeoutMs: step.timeoutMs });
|
|
7561
|
+
smokeExecutions.push({ stepId: step.id, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, ...(r.timedOut !== undefined ? { timedOut: r.timedOut } : {}) });
|
|
7562
|
+
}
|
|
7563
|
+
}, 'dz publish');
|
|
7564
|
+
} catch (err) {
|
|
7565
|
+
// a restore failure is a preview failure (Codex finding 1): never a green preview on a staged tree
|
|
7566
|
+
smokeExecutions.push({ stepId: 'staged-restore', exitCode: 1, stdout: '', stderr: formatPublishError(err) });
|
|
7382
7567
|
}
|
|
7383
7568
|
const smokeVerdict = judgePackedInstallSmoke(smokePlan, smokeExecutions);
|
|
7384
7569
|
try { rmSync(packDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
7385
7570
|
try { rmSync(installDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
7386
7571
|
|
|
7387
7572
|
if (smokeVerdict.ok) {
|
|
7388
|
-
appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'preview: pack/install/--version all clean');
|
|
7389
|
-
write(
|
|
7573
|
+
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);
|
|
7574
|
+
write(`dz publish: ✓ packed install smoke (preview)${auditSuffix(wrote)}`);
|
|
7390
7575
|
} else {
|
|
7391
7576
|
const detail = smokeVerdict.failureDetail ?? smokeVerdict.bins.find((b) => !b.ok)?.detail ?? '(no detail)';
|
|
7392
|
-
appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail);
|
|
7393
|
-
write(`dz publish: BLOCKED — packed install smoke failed (preview): ${detail}`);
|
|
7577
|
+
const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail, targets.map((p) => ({ name: p.name, version: p.version })), undefined, gateAuditFsLayer);
|
|
7578
|
+
write(`dz publish: BLOCKED — packed install smoke failed (preview): ${detail}${auditSuffix(wrote)}`);
|
|
7394
7579
|
for (const b of smokeVerdict.bins.filter((b) => !b.ok)) write(` ✗ ${b.pkg} (${b.binName}): ${b.detail ?? '(no detail)'}`);
|
|
7395
7580
|
packedInstallSmokePreviewFailed = true;
|
|
7396
7581
|
}
|
|
@@ -7453,22 +7638,35 @@ function cmdPublish(
|
|
|
7453
7638
|
// `pnpm publish` and skipped re-signing (ADR-001, features/publish-gate-verifies-the-tarball).
|
|
7454
7639
|
let cleanupGate: (() => void) | null = null;
|
|
7455
7640
|
try {
|
|
7456
|
-
const
|
|
7457
|
-
|
|
7458
|
-
|
|
7459
|
-
|
|
7460
|
-
|
|
7461
|
-
|
|
7462
|
-
|
|
7463
|
-
|
|
7464
|
-
|
|
7465
|
-
|
|
7466
|
-
write(`dz publish: could not pack ${pk.name} (${(err as Error).message.split('\n')[0]}) — the artifact was never built, so its signature was not checked`);
|
|
7641
|
+
const parsedManifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
7642
|
+
// FR-4 (feature publish-gate-audit-durable): a manifest that PARSES but is not a plain
|
|
7643
|
+
// object — `null`, an array, a bare string — is UNAVAILABLE, never "no signature". Those
|
|
7644
|
+
// are different failures with different fixes: "no signature" means run `dz sign`; a
|
|
7645
|
+
// malformed manifest means the file itself is corrupt/wrong-shaped and re-signing alone
|
|
7646
|
+
// would silently paper over that. Mirrors `readManifest`'s shape guard in
|
|
7647
|
+
// publish-sibling-drift.ts (null/array/non-object ⇒ cannot be used, say so).
|
|
7648
|
+
if (parsedManifest === null || typeof parsedManifest !== 'object' || Array.isArray(parsedManifest)) {
|
|
7649
|
+
const gotShape = parsedManifest === null ? 'null' : Array.isArray(parsedManifest) ? 'an array' : typeof parsedManifest;
|
|
7650
|
+
write(`dz publish: ${pk.name}'s ${MANIFEST_NAME} is not a JSON object (got ${gotShape}) — its signature is unavailable, not merely absent`);
|
|
7467
7651
|
artifactUnavailable = true;
|
|
7652
|
+
} else {
|
|
7653
|
+
const signed = parsedManifest;
|
|
7654
|
+
let extracted: { dir: string; cleanup: () => void } | undefined;
|
|
7655
|
+
try {
|
|
7656
|
+
extracted = extractPublishTarball(pk.dir);
|
|
7657
|
+
cleanupGate = extracted.cleanup;
|
|
7658
|
+
} catch (err) {
|
|
7659
|
+
// Cross-family review (codex `gpt-5.6-sol`, 2026-08-22): falling back to the working
|
|
7660
|
+
// TREE here fails the gate OPEN. The gate's whole claim is "what ships matches the
|
|
7661
|
+
// signature"; with no artifact, nothing was compared, and reporting a pass would be a
|
|
7662
|
+
// claim about an object that was never built. Say why, and block.
|
|
7663
|
+
write(`dz publish: could not pack ${pk.name} (${(err as Error).message.split('\n')[0]}) — the artifact was never built, so its signature was not checked`);
|
|
7664
|
+
artifactUnavailable = true;
|
|
7665
|
+
}
|
|
7666
|
+
verifyOk =
|
|
7667
|
+
extracted !== undefined &&
|
|
7668
|
+
verifyManifest(extracted.dir, signed, readFileSync(trustRoot, 'utf8')).ok;
|
|
7468
7669
|
}
|
|
7469
|
-
verifyOk =
|
|
7470
|
-
extracted !== undefined &&
|
|
7471
|
-
verifyManifest(extracted.dir, signed, readFileSync(trustRoot, 'utf8')).ok;
|
|
7472
7670
|
} catch {
|
|
7473
7671
|
verifyOk = false;
|
|
7474
7672
|
} finally {
|
|
@@ -7513,9 +7711,10 @@ function cmdPublish(
|
|
|
7513
7711
|
// batch resolves to.
|
|
7514
7712
|
smoke: (artifacts): { ok: boolean; reason?: string } => {
|
|
7515
7713
|
for (const a of artifacts) write(`dz publish: tarball ${a.name}@${a.newVersion} sha256:${a.sha256}`);
|
|
7714
|
+
const auditPackages = artifacts.map((a) => ({ name: a.name, version: a.newVersion, tarballSha256: a.sha256 }));
|
|
7516
7715
|
if (bins.length === 0) {
|
|
7517
|
-
appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin');
|
|
7518
|
-
|
|
7716
|
+
const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'n/a — nothing in the batch declares a bin', auditPackages, undefined, gateAuditFsLayer);
|
|
7717
|
+
write(`dz publish: ○ packed install smoke: n/a (nothing in the batch declares a bin)${auditSuffix(wrote)}`);
|
|
7519
7718
|
return { ok: true };
|
|
7520
7719
|
}
|
|
7521
7720
|
const scratchRoot = packedInstallScratchRoot();
|
|
@@ -7554,13 +7753,13 @@ function cmdPublish(
|
|
|
7554
7753
|
const verdict = judgePackedInstallSmoke(smokePlan, smokeExecutions);
|
|
7555
7754
|
try { rmSync(installDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
7556
7755
|
if (verdict.ok) {
|
|
7557
|
-
appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'pack/install/--version all clean (live, packedTransport)');
|
|
7558
|
-
write(
|
|
7756
|
+
const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'pass', 'pack/install/--version all clean (live, packedTransport)', auditPackages, undefined, gateAuditFsLayer);
|
|
7757
|
+
write(`dz publish: ✓ packed install smoke${auditSuffix(wrote)}`);
|
|
7559
7758
|
return { ok: true };
|
|
7560
7759
|
}
|
|
7561
7760
|
const detail = verdict.failureDetail ?? verdict.bins.find((b) => !b.ok)?.detail ?? '(no detail)';
|
|
7562
|
-
appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail);
|
|
7563
|
-
write(`dz publish: BLOCKED — packed install smoke failed: ${detail}`);
|
|
7761
|
+
const wrote = appendPublishGateAudit(cwd, 'packed-install-smoke', 'block', detail, auditPackages, undefined, gateAuditFsLayer);
|
|
7762
|
+
write(`dz publish: BLOCKED — packed install smoke failed: ${detail}${auditSuffix(wrote)}`);
|
|
7564
7763
|
for (const b of verdict.bins.filter((b) => !b.ok)) write(` ✗ ${b.pkg} (${b.binName}): ${b.detail ?? '(no detail)'}`);
|
|
7565
7764
|
return { ok: false, reason: detail };
|
|
7566
7765
|
},
|
|
@@ -8116,7 +8315,24 @@ function cmdRelease(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
8116
8315
|
let smokeTmp: string | undefined;
|
|
8117
8316
|
const execSteps: GateStep[] = plan.steps.filter((s) => s.kind !== 'synthetic-fail');
|
|
8118
8317
|
say(`\ndz release — executing ${execSteps.length} gate step(s) across ${plan.packages.length} package(s)…`);
|
|
8119
|
-
|
|
8318
|
+
|
|
8319
|
+
// FR-1/FR-2 (feature release-smoke-staged-pack): the packed-install smoke's `pack` steps must
|
|
8320
|
+
// pack the SAME staged bytes `dz publish`'s preview does — sibling `workspace:*` deps rewritten
|
|
8321
|
+
// to the exact sibling version, `prepublishOnly` dropped — or `npm install` on the resulting
|
|
8322
|
+
// tarballs dies with EUNSUPPORTEDPROTOCOL (MEASURED 2026-09-13 16:05). `workspaceVersions` is
|
|
8323
|
+
// built like publish's (from the FULL workspace, not just this release's filtered batch — an
|
|
8324
|
+
// out-of-batch sibling still needs its real version). Only the `pack` sub-steps are staged; the
|
|
8325
|
+
// `install`/`bin-exists`/`bin-version` steps already run against the packed tarballs and need no
|
|
8326
|
+
// staging.
|
|
8327
|
+
const allPackages = discoverPackages(cwd);
|
|
8328
|
+
const workspaceVersions = new Map(allPackages.map((p) => [p.name, p.version]));
|
|
8329
|
+
const packStepIds = new Set(
|
|
8330
|
+
(plan.packedInstallPlan?.steps ?? [])
|
|
8331
|
+
.filter((s) => s.kind === 'pack')
|
|
8332
|
+
.map((s) => `smoke:packed-install:${s.id}`),
|
|
8333
|
+
);
|
|
8334
|
+
|
|
8335
|
+
const runGateStep = (step: GateStep): void => {
|
|
8120
8336
|
let stepCwd = step.cwd;
|
|
8121
8337
|
if (step.tempCwd === true) {
|
|
8122
8338
|
// AM-4: boot bins in a throwaway cwd so an installer-style bin cannot mutate the workspace.
|
|
@@ -8133,7 +8349,35 @@ function cmdRelease(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
8133
8349
|
durationMs: Date.now() - started,
|
|
8134
8350
|
timedOut: r.timedOut,
|
|
8135
8351
|
});
|
|
8352
|
+
};
|
|
8353
|
+
|
|
8354
|
+
// Plan/execution order is load-bearing (see the plan/execution skew guard test) — steps run in
|
|
8355
|
+
// exactly the order `plan.steps` lists them, one loop, no reordering. Only the `pack` steps are
|
|
8356
|
+
// wrapped in the staged window, individually, so where they fall in that order never changes.
|
|
8357
|
+
let announcedStagedPack = false;
|
|
8358
|
+
for (const step of execSteps) {
|
|
8359
|
+
if (packStepIds.has(step.id)) {
|
|
8360
|
+
if (!announcedStagedPack) {
|
|
8361
|
+
say('dz release: smoke: packed tarballs staged like the live publish (workspace:* → exact sibling versions)');
|
|
8362
|
+
announcedStagedPack = true;
|
|
8363
|
+
}
|
|
8364
|
+
// Codex finding 2: stage ONLY the package this pack step packs — sibling pins come from
|
|
8365
|
+
// workspaceVersions, and `npm pack` reads the packed package's manifest alone.
|
|
8366
|
+
const packed = factsList.filter((f) => f.name === step.pkg);
|
|
8367
|
+
try {
|
|
8368
|
+
withStagedPackageJson(packed, workspaceVersions, write, () => runGateStep(step), 'dz release');
|
|
8369
|
+
} catch (err) {
|
|
8370
|
+
// a staging/restore failure is a FAILED step with the reason, never a silent skip — and ONE
|
|
8371
|
+
// record per step: a run already recorded by runGateStep is replaced, not duplicated (Codex r2)
|
|
8372
|
+
const failed = { stepId: step.id, exitCode: 1, stdout: '', stderr: formatPublishError(err), durationMs: 0, timedOut: false };
|
|
8373
|
+
const at = executions.findIndex((e) => e.stepId === step.id);
|
|
8374
|
+
if (at >= 0) executions[at] = failed; else executions.push(failed);
|
|
8375
|
+
}
|
|
8376
|
+
} else {
|
|
8377
|
+
runGateStep(step);
|
|
8378
|
+
}
|
|
8136
8379
|
}
|
|
8380
|
+
|
|
8137
8381
|
if (smokeTmp !== undefined) {
|
|
8138
8382
|
try { rmSync(smokeTmp, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
8139
8383
|
}
|
|
@@ -8146,7 +8390,22 @@ function cmdRelease(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
8146
8390
|
for (const g of verdict.gates) {
|
|
8147
8391
|
const icon = g.status === 'pass' ? '✓' : g.status === 'fail' ? '✗' : '○';
|
|
8148
8392
|
say(` ${icon} ${g.gate.padEnd(7)} ${g.status.toUpperCase().padEnd(4)} ${g.passed} passed, ${g.failures.length} failed, ${g.skips.length} skipped`);
|
|
8149
|
-
for (const f of g.failures)
|
|
8393
|
+
for (const f of g.failures) {
|
|
8394
|
+
say(` [${f.class}] ${f.pkg !== undefined ? `${f.pkg}: ` : ''}${f.reason}`);
|
|
8395
|
+
// FR-2 / AM-4 / AM-6 (feature release-gate-output-tail): print each non-empty stream's
|
|
8396
|
+
// tail under the failure line, labelled `stdout:`/`stderr:` at the failure's own 6-space
|
|
8397
|
+
// indent, with its content lines at an 8-space CONTINUATION indent so a reader can tell a
|
|
8398
|
+
// tail line from a new failure/skip bullet at a glance; --json carries the same `tails`
|
|
8399
|
+
// object as-is (present, possibly with empty strings, on every executed failure).
|
|
8400
|
+
if (f.tails !== undefined) {
|
|
8401
|
+
for (const stream of ['stdout', 'stderr'] as const) {
|
|
8402
|
+
const t = f.tails[stream];
|
|
8403
|
+
if (t.length === 0) continue;
|
|
8404
|
+
say(` ${stream}:`);
|
|
8405
|
+
for (const tailLine of t.split('\n')) say(` ${tailLine}`);
|
|
8406
|
+
}
|
|
8407
|
+
}
|
|
8408
|
+
}
|
|
8150
8409
|
for (const sk of g.skips) say(` [${sk.class}] ${sk.pkg}: ${sk.reason}`);
|
|
8151
8410
|
}
|
|
8152
8411
|
say(` verdict at ${verdict.timestamp}: ${verdict.publishAction === 'proceed' ? '✓ all gates green' : '✗ RELEASE BLOCKED'}`);
|
|
@@ -10865,41 +11124,175 @@ function runGuardEvaluation(root: string, op: string, text: string | undefined,
|
|
|
10865
11124
|
return result;
|
|
10866
11125
|
}
|
|
10867
11126
|
|
|
11127
|
+
/** A package this audit call concerns (feature `publish-gate-audit-durable`, FR-1). */
|
|
11128
|
+
interface PublishGateAuditPackage {
|
|
11129
|
+
readonly name: string;
|
|
11130
|
+
readonly version: string;
|
|
11131
|
+
/** Present once a tarball has actually been built (packedTransport/smoke); absent → `sha256:n/a`. */
|
|
11132
|
+
readonly tarballSha256?: string;
|
|
11133
|
+
}
|
|
11134
|
+
|
|
11135
|
+
/**
|
|
11136
|
+
* fs primitives `appendPublishGateAudit` needs for its durable write (FR-2), injectable so a test
|
|
11137
|
+
* can make `fsyncSync` throw without touching the real filesystem underneath every OTHER seam this
|
|
11138
|
+
* function shares with production. Left unset in production → the real `node:fs` functions above.
|
|
11139
|
+
*/
|
|
11140
|
+
interface PublishGateAuditFsLayer {
|
|
11141
|
+
readonly existsSync: (path: string) => boolean;
|
|
11142
|
+
readonly mkdirSync: (path: string, opts: { recursive: boolean }) => void;
|
|
11143
|
+
readonly openSync: (path: string, flags: number) => number;
|
|
11144
|
+
/**
|
|
11145
|
+
* AM-2 (Codex round-1 review, finding 2, high): `Buffer`, not `string` — a SHORT write must
|
|
11146
|
+
* resume at the exact BYTE it stopped at, and a string-based API cannot express that safely once
|
|
11147
|
+
* the data contains any multi-byte UTF-8 character (re-encoding a slice of an already-partial
|
|
11148
|
+
* string can silently produce different bytes than the ones actually pending). The real
|
|
11149
|
+
* `node:fs.writeSync` accepts a `Buffer` directly (no re-encoding), so this changes nothing about
|
|
11150
|
+
* what production writes.
|
|
11151
|
+
*/
|
|
11152
|
+
readonly writeSync: (fd: number, data: Buffer) => number;
|
|
11153
|
+
readonly fsyncSync: (fd: number) => void;
|
|
11154
|
+
readonly closeSync: (fd: number) => void;
|
|
11155
|
+
}
|
|
11156
|
+
|
|
10868
11157
|
/**
|
|
10869
|
-
*
|
|
11158
|
+
* AM-2: loop `writeSync` until every byte of `data` has been accepted, checking the RETURNED
|
|
11159
|
+
* length on every call (Codex round-1 review, finding 1: the old code called `writeSync` once and
|
|
11160
|
+
* ignored the return value — a short write followed by `fsyncSync` durably persists a TRUNCATED,
|
|
11161
|
+
* unparseable JSON line into an append-only log every future read walks). A call that reports zero
|
|
11162
|
+
* or negative progress can never complete the buffer and would spin forever — that is treated as a
|
|
11163
|
+
* failure, not a retry target.
|
|
11164
|
+
*/
|
|
11165
|
+
function writeAllSync(fsLayer: PublishGateAuditFsLayer, fd: number, data: Buffer): void {
|
|
11166
|
+
let remaining = data;
|
|
11167
|
+
while (remaining.length > 0) {
|
|
11168
|
+
const n = fsLayer.writeSync(fd, remaining);
|
|
11169
|
+
if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) {
|
|
11170
|
+
throw new Error(`writeSync made no progress (returned ${n}) with ${remaining.length} byte(s) still pending`);
|
|
11171
|
+
}
|
|
11172
|
+
// Codex round-2 (2026-09-14) finding 3: a layer claiming MORE bytes than it was given is lying
|
|
11173
|
+
// about the record, and must not reach "logged: true" through a subarray that just goes empty.
|
|
11174
|
+
if (n > remaining.length) {
|
|
11175
|
+
throw new Error(`writeSync claimed ${n} byte(s) written but only ${remaining.length} were supplied`);
|
|
11176
|
+
}
|
|
11177
|
+
remaining = remaining.subarray(n);
|
|
11178
|
+
}
|
|
11179
|
+
}
|
|
11180
|
+
|
|
11181
|
+
const REAL_PUBLISH_GATE_AUDIT_FS: PublishGateAuditFsLayer = { existsSync, mkdirSync, openSync, writeSync, fsyncSync, closeSync };
|
|
11182
|
+
|
|
11183
|
+
/**
|
|
11184
|
+
* Feature `publish-sibling-drift-gate` (FR-5/AM-6), durability + per-package identity added by
|
|
11185
|
+
* feature `publish-gate-audit-durable` (FR-1/FR-2): both the sibling-drift and packed-install-smoke
|
|
10870
11186
|
* gates write to the SAME append-only, hash-chained `.dz/guard-audit.jsonl` the declarative
|
|
10871
11187
|
* `dz guard` rules use — visibility for `dz guard promote`/`dz compounding` never depends on
|
|
10872
11188
|
* which mechanism produced the finding. `pass` records go through as an informational `note`
|
|
10873
11189
|
* (never a violation, so they can never flip the row's own verdict) so a clean check is ALSO on
|
|
10874
11190
|
* the record, not just a block or an override (AM-6: "аудит без записи = не аудит").
|
|
10875
11191
|
*
|
|
10876
|
-
*
|
|
10877
|
-
*
|
|
10878
|
-
*
|
|
10879
|
-
* it (
|
|
11192
|
+
* FR-1: one JSONL record PER PACKAGE in `packages`, each naming the package, its version, and the
|
|
11193
|
+
* tarball's sha256 (or the explicit `sha256:n/a` before a tarball exists — a dry-run preview). FR-2:
|
|
11194
|
+
* the write is `openSync('a') → writeSync → fsyncSync(fd) → closeSync`, plus an `fsyncSync` of the
|
|
11195
|
+
* `.dz` directory itself the one time this call CREATES it (a file's own fsync durably persists its
|
|
11196
|
+
* bytes; the directory entry that makes the file findable after a crash needs its own fsync — the
|
|
11197
|
+
* same lesson `integration-apply.ts`'s `fsyncDirectory` already encodes).
|
|
11198
|
+
*
|
|
11199
|
+
* Returns `{ logged, reason? }` — never a bare boolean, so a caller can print WHY a write failed,
|
|
11200
|
+
* not just that it did. Most callers are best-effort (a write failure never blocks a verdict already
|
|
11201
|
+
* decided) — the one exception is an `--allow-sibling-drift` OVERRIDE, whose caller MUST check
|
|
11202
|
+
* `logged`: an override is not real without a durable row behind it (AM-6's load-bearing property —
|
|
11203
|
+
* see `auditedOverride` in `cmdPublish`).
|
|
10880
11204
|
*/
|
|
10881
11205
|
function appendPublishGateAudit(
|
|
10882
11206
|
root: string,
|
|
10883
11207
|
rule: 'sibling-drift' | 'packed-install-smoke',
|
|
10884
11208
|
verdict: 'pass' | 'warn' | 'block',
|
|
10885
11209
|
detail: string,
|
|
11210
|
+
packages: readonly PublishGateAuditPackage[],
|
|
10886
11211
|
overrideReason?: string,
|
|
10887
|
-
|
|
11212
|
+
fsLayer: PublishGateAuditFsLayer = REAL_PUBLISH_GATE_AUDIT_FS,
|
|
11213
|
+
): { logged: boolean; reason?: string } {
|
|
11214
|
+
if (packages.length === 0) return { logged: true }; // nothing to record about — not a failure
|
|
11215
|
+
const dzDir = join(root, '.dz');
|
|
11216
|
+
const auditPath = join(dzDir, 'guard-audit.jsonl');
|
|
11217
|
+
const dzDirExisted = fsLayer.existsSync(dzDir);
|
|
11218
|
+
// AM-2: tracked BEFORE the write — this is what decides whether the append call below is about
|
|
11219
|
+
// to CREATE the file (needing a `.dz` directory-entry fsync afterwards) or extend an existing one
|
|
11220
|
+
// (whose directory entry is already durable from a previous append).
|
|
11221
|
+
const auditFileExisted = fsLayer.existsSync(auditPath);
|
|
10888
11222
|
try {
|
|
10889
|
-
|
|
11223
|
+
fsLayer.mkdirSync(dzDir, { recursive: true });
|
|
11224
|
+
} catch (err) {
|
|
11225
|
+
return { logged: false, reason: `could not create ${dzDir}: ${(err as Error).message.split('\n')[0]}` };
|
|
11226
|
+
}
|
|
11227
|
+
const records = packages.map((pkg) => {
|
|
11228
|
+
const sha = pkg.tarballSha256 !== undefined && pkg.tarballSha256 !== '' ? pkg.tarballSha256 : 'n/a';
|
|
11229
|
+
const label = `${rule}: ${pkg.name}@${pkg.version} sha256:${sha} — ${detail}`;
|
|
11230
|
+
return auditRecord(
|
|
10890
11231
|
verdict === 'pass'
|
|
10891
|
-
? { op: 'publish', verdict, violations: [], checked: [rule], notEstablished: [], notes: [
|
|
10892
|
-
: { op: 'publish', verdict, violations: [{ rule, severity: 'hard', detail }], checked: [rule], notEstablished: [] },
|
|
11232
|
+
? { op: 'publish', verdict, violations: [], checked: [rule], notEstablished: [], notes: [label] }
|
|
11233
|
+
: { op: 'publish', verdict, violations: [{ rule, severity: 'hard', detail: label }], checked: [rule], notEstablished: [] },
|
|
10893
11234
|
new Date().toISOString(),
|
|
10894
11235
|
overrideReason !== undefined ? { reason: overrideReason } : undefined,
|
|
10895
11236
|
);
|
|
10896
|
-
|
|
10897
|
-
|
|
10898
|
-
|
|
10899
|
-
|
|
10900
|
-
} catch {
|
|
10901
|
-
return false
|
|
11237
|
+
});
|
|
11238
|
+
let bytes: string;
|
|
11239
|
+
try {
|
|
11240
|
+
bytes = appendChainedLines(records, readLogTail(auditPath));
|
|
11241
|
+
} catch (err) {
|
|
11242
|
+
return { logged: false, reason: `could not build the chained record: ${(err as Error).message.split('\n')[0]}` };
|
|
10902
11243
|
}
|
|
11244
|
+
if (bytes === '') return { logged: true };
|
|
11245
|
+
const buf = Buffer.from(bytes, 'utf-8');
|
|
11246
|
+
let fd: number | undefined;
|
|
11247
|
+
try {
|
|
11248
|
+
fd = fsLayer.openSync(auditPath, fsConstants.O_APPEND | fsConstants.O_CREAT | fsConstants.O_WRONLY);
|
|
11249
|
+
writeAllSync(fsLayer, fd, buf); // AM-2: loops on a short write, throws on no progress
|
|
11250
|
+
fsLayer.fsyncSync(fd);
|
|
11251
|
+
fsLayer.closeSync(fd);
|
|
11252
|
+
fd = undefined;
|
|
11253
|
+
} catch (err) {
|
|
11254
|
+
return { logged: false, reason: (err as Error).message.split('\n')[0] ?? String(err) };
|
|
11255
|
+
} finally {
|
|
11256
|
+
if (fd !== undefined) { try { fsLayer.closeSync(fd); } catch { /* best-effort */ } }
|
|
11257
|
+
}
|
|
11258
|
+
// AM-2 (Codex round-1 review, finding 1, high): a directory-entry fsync is REQUIRED, not
|
|
11259
|
+
// best-effort, whenever THIS call is the reason the entry needed persisting — swallowing its
|
|
11260
|
+
// failure used to let `logged: true` go out about a record whose directory entry can vanish on a
|
|
11261
|
+
// crash before the next fsck. Two DIFFERENT entries can need persisting, tracked independently:
|
|
11262
|
+
if (!auditFileExisted) {
|
|
11263
|
+
// The audit FILE was just created by the open() above — `.dz` (its containing directory) needs
|
|
11264
|
+
// its own fsync so the new directory entry survives a crash; the file's own fsync (above) only
|
|
11265
|
+
// guarantees the file's DATA, not that anything can find it afterwards.
|
|
11266
|
+
let dfd: number | undefined;
|
|
11267
|
+
try {
|
|
11268
|
+
dfd = fsLayer.openSync(dzDir, fsConstants.O_RDONLY);
|
|
11269
|
+
fsLayer.fsyncSync(dfd);
|
|
11270
|
+
} catch (err) {
|
|
11271
|
+
return { logged: false, reason: `could not fsync ${dzDir} after creating ${auditPath}: ${(err as Error).message.split('\n')[0]}` };
|
|
11272
|
+
} finally {
|
|
11273
|
+
if (dfd !== undefined) { try { fsLayer.closeSync(dfd); } catch { /* best-effort, does not affect the verdict already returned */ } }
|
|
11274
|
+
}
|
|
11275
|
+
}
|
|
11276
|
+
if (!dzDirExisted) {
|
|
11277
|
+
// `.dz` ITSELF was just created by `mkdirSync` above — its PARENT (`root`) needs its own fsync
|
|
11278
|
+
// so `.dz`'s OWN directory entry survives a crash (the fsync of `.dz` just above only durably
|
|
11279
|
+
// persists entries INSIDE `.dz`, not the fact that `.dz` exists at all).
|
|
11280
|
+
let pfd: number | undefined;
|
|
11281
|
+
try {
|
|
11282
|
+
pfd = fsLayer.openSync(root, fsConstants.O_RDONLY);
|
|
11283
|
+
fsLayer.fsyncSync(pfd);
|
|
11284
|
+
} catch (err) {
|
|
11285
|
+
return { logged: false, reason: `could not fsync ${root} after creating ${dzDir}: ${(err as Error).message.split('\n')[0]}` };
|
|
11286
|
+
} finally {
|
|
11287
|
+
if (pfd !== undefined) { try { fsLayer.closeSync(pfd); } catch { /* best-effort, does not affect the verdict already returned */ } }
|
|
11288
|
+
}
|
|
11289
|
+
}
|
|
11290
|
+
return { logged: true };
|
|
11291
|
+
}
|
|
11292
|
+
|
|
11293
|
+
/** FR-2: the printed suffix a caller appends to its own verdict line — never silent about logging. */
|
|
11294
|
+
function auditSuffix(wrote: { logged: boolean; reason?: string }): string {
|
|
11295
|
+
return wrote.logged ? ' (logged)' : ` (audit NOT logged: ${wrote.reason ?? 'unknown reason'})`;
|
|
10903
11296
|
}
|
|
10904
11297
|
|
|
10905
11298
|
function renderGuardObservation(observation: GuardObservation): string {
|
|
@@ -20144,7 +20537,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
20144
20537
|
case 'auto-canonicalize':
|
|
20145
20538
|
return await cmdAutoCanonicalize(options, cwd, write);
|
|
20146
20539
|
case 'publish':
|
|
20147
|
-
return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner, io.publishSiblingDriftFetcher, io.publishPackedInstallRunner, io.publishExecRunner);
|
|
20540
|
+
return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner, io.publishSiblingDriftFetcher, io.publishPackedInstallRunner, io.publishExecRunner, io.publishGateAuditFsLayer, io.publishNpmPackRunner);
|
|
20148
20541
|
case 'release':
|
|
20149
20542
|
return cmdRelease(options, flags, cwd, write, io.releaseRunner);
|
|
20150
20543
|
case 'parity':
|