@dzhechkov/harness-cli 0.8.23 → 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 +16 -16
- package/README.md +245 -7
- package/dist/boolean-flags.d.ts.map +1 -1
- package/dist/boolean-flags.js +2 -0
- package/dist/boolean-flags.js.map +1 -1
- package/dist/cli.d.ts +65 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +841 -30
- package/dist/cli.js.map +1 -1
- package/dist/known-flags.d.ts.map +1 -1
- package/dist/known-flags.js +2 -0
- package/dist/known-flags.js.map +1 -1
- package/package.json +27 -27
- package/sbom.json +15 -15
- package/src/boolean-flags.ts +2 -0
- package/src/cli.ts +884 -28
- package/src/known-flags.ts +2 -0
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';
|
|
@@ -92,6 +93,7 @@ import {
|
|
|
92
93
|
generatePlugin,
|
|
93
94
|
publishPackages,
|
|
94
95
|
runSetup,
|
|
96
|
+
memoryBackendSourceLabel,
|
|
95
97
|
runMigrate,
|
|
96
98
|
searchRegistry,
|
|
97
99
|
runSync,
|
|
@@ -318,6 +320,11 @@ import {
|
|
|
318
320
|
verifyManifest,
|
|
319
321
|
hashPackBytes,
|
|
320
322
|
rewriteWorkspaceSpecs,
|
|
323
|
+
detectSiblingDrift,
|
|
324
|
+
planPackedInstallSmoke,
|
|
325
|
+
judgePackedInstallSmoke,
|
|
326
|
+
type FetchPublished,
|
|
327
|
+
type PackedInstallExecution,
|
|
321
328
|
listPackFiles,
|
|
322
329
|
listSignablePackFiles,
|
|
323
330
|
assertKeyOutsideTree,
|
|
@@ -633,7 +640,7 @@ import type { SetupSpec } from '@dzhechkov/harness-core';
|
|
|
633
640
|
import type { LogTail } from '@dzhechkov/harness-core';
|
|
634
641
|
import type { DeadwoodInventoryItem } from '@dzhechkov/harness-core';
|
|
635
642
|
import type { ContractDiagnostic, ContractEvidenceReader } from '@dzhechkov/harness-core';
|
|
636
|
-
import type { ProvenanceMode, PackVerdict, PatternRecord, RecallPatternsOptions, TeachGuardResult, TargetName, IntegrationOutcome, BookKU, HarmonizeReport, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep, SlopFinding, SlopLintConfig, SlopRegistry } from '@dzhechkov/harness-core';
|
|
643
|
+
import type { ProvenanceMode, PackVerdict, PatternRecord, RecallPatternsOptions, TeachGuardResult, TargetName, IntegrationOutcome, BookKU, HarmonizeReport, ClaimFinding, RecallUsagePatternRow, GateExecution, GateStep, SlopFinding, SlopLintConfig, SlopRegistry, PackedTarballArtifact, PackedTransportSmokeVerdict } from '@dzhechkov/harness-core';
|
|
637
644
|
import { getPreset, PRESET_NAMES } from '@dzhechkov/harness-presets';
|
|
638
645
|
import { scanGitHub, analyzeRepo, generateReport, deepAnalyze, scanAllSources, ScoutMemory } from '@dzhechkov/scout';
|
|
639
646
|
|
|
@@ -925,6 +932,44 @@ export interface CliIo {
|
|
|
925
932
|
readonly releaseRunner?: ReleaseExecRunner;
|
|
926
933
|
/** Post-publish mirror command seam; production uses synchronous shell execution. */
|
|
927
934
|
readonly publishMirrorRunner?: PublishMirrorRunner;
|
|
935
|
+
/**
|
|
936
|
+
* Test seam for `dz publish`'s sibling-drift gate (feature publish-sibling-drift-gate):
|
|
937
|
+
* overrides the registry fetch (production leaves it unset → real `npm pack` + extract into a
|
|
938
|
+
* temp dir). Tests inject a local directory instead of hitting the real registry.
|
|
939
|
+
*/
|
|
940
|
+
readonly publishSiblingDriftFetcher?: FetchPublished;
|
|
941
|
+
/**
|
|
942
|
+
* Test seam for `dz publish`'s packed-install smoke: overrides the pack/install/`--version`
|
|
943
|
+
* subprocesses (production leaves it unset → real `execSync`, stdio piped). Mirrors
|
|
944
|
+
* {@link CliIo.releaseRunner}.
|
|
945
|
+
*/
|
|
946
|
+
readonly publishPackedInstallRunner?: ReleaseExecRunner;
|
|
947
|
+
/**
|
|
948
|
+
* AM-1 (feature publish-sibling-drift-gate): overrides EVERY subprocess `publishPackages` would
|
|
949
|
+
* run on a LIVE publish — build, the `npm pack`/`npm publish <tgz>` packedTransport commands, and
|
|
950
|
+
* the `npm view` registry probes (production leaves it unset → real `execSync`, stdio piped).
|
|
951
|
+
* Threaded into `publishPackages`'s `exec` option so a test can drive the FULL live+packedTransport
|
|
952
|
+
* `cmdPublish` path (pack → smoke → publish → registry-confirm) with zero network and zero real
|
|
953
|
+
* `npm publish`.
|
|
954
|
+
*/
|
|
955
|
+
readonly publishExecRunner?: (
|
|
956
|
+
command: string,
|
|
957
|
+
options: { cwd?: string | URL | undefined; stdio?: unknown; encoding?: unknown; timeout?: number | undefined; env?: NodeJS.ProcessEnv | undefined },
|
|
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;
|
|
928
973
|
/**
|
|
929
974
|
* Test seam for `dz install`: overrides the `npm install` subprocess (production leaves
|
|
930
975
|
* it unset → real `execSync`, stdio piped). A stub runner that pre-stages a fixture
|
|
@@ -6200,7 +6245,15 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
6200
6245
|
|
|
6201
6246
|
// Step 3: Run setup (hooks + memory + config)
|
|
6202
6247
|
write(`║ 3. Setting up learning environment... ║`);
|
|
6203
|
-
const
|
|
6248
|
+
const memoryOptRaw = options.get('memory');
|
|
6249
|
+
// FR-1/T3 (feature `setup-backend-from-config`): pass `--memory` through AS-IS — `agentdb`,
|
|
6250
|
+
// `jsonl`, or `undefined` — never collapsed to `undefined` on anything but agentdb. The prior
|
|
6251
|
+
// `memoryOpt === 'agentdb' ? 'agentdb' : undefined` made an explicit `--memory jsonl` INDISTINCT
|
|
6252
|
+
// from "no flag at all", so `runSetup`'s config-aware default (FR-2's downgrade path) could never
|
|
6253
|
+
// fire from the CLI. An unrecognised value (neither `agentdb` nor `jsonl`) still reads as
|
|
6254
|
+
// "no flag" — the same permissive fallback as before.
|
|
6255
|
+
const memoryOpt: 'agentdb' | 'jsonl' | undefined =
|
|
6256
|
+
memoryOptRaw === 'agentdb' ? 'agentdb' : memoryOptRaw === 'jsonl' ? 'jsonl' : undefined;
|
|
6204
6257
|
// ADR-001 Decision 2 (feature setup-installs-apply-leg): bake THIS CLI's own installed
|
|
6205
6258
|
// @dzhechkov/harness-core into the generated apply-leg hooks — the installation actually running
|
|
6206
6259
|
// `dz setup` is the one a consumer's project can always reach, unlike a hard-coded npm prefix
|
|
@@ -6217,13 +6270,16 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
6217
6270
|
projectRoot,
|
|
6218
6271
|
target,
|
|
6219
6272
|
preset,
|
|
6220
|
-
memory: memoryOpt
|
|
6273
|
+
memory: memoryOpt,
|
|
6221
6274
|
noHooks: flags.has('no-hooks'),
|
|
6222
6275
|
noMemory: flags.has('no-memory'),
|
|
6223
6276
|
force: flags.has('force'),
|
|
6224
6277
|
installDriver: flags.has('install-driver'),
|
|
6225
6278
|
coreDistDir,
|
|
6226
6279
|
});
|
|
6280
|
+
// FR-3: name the source of the backend actually used — never left to be inferred from the flag
|
|
6281
|
+
// alone, since the backend may now come from `.dz/config.json` or the jsonl default.
|
|
6282
|
+
write(`dz setup: memory backend: ${setupResult.memoryBackend} (${memoryBackendSourceLabel(setupResult.memoryBackendSource)})`);
|
|
6227
6283
|
|
|
6228
6284
|
for (const step of setupResult.steps) {
|
|
6229
6285
|
const icon = step.status === 'done' ? '✓' : step.status === 'skipped' ? '○' : '✗';
|
|
@@ -6275,7 +6331,11 @@ async function cmdSetup(options: Map<string, string>, flags: Set<string>, cwd: s
|
|
|
6275
6331
|
// not from package presence — a skipped hook/MCP step must not let the summary claim a store
|
|
6276
6332
|
// nothing writes to (audit code#3).
|
|
6277
6333
|
const wiring = setupResult.steps.find((s) => s.name === 'agentdb wiring');
|
|
6278
|
-
|
|
6334
|
+
// Keyed off the RESOLVED backend (setupResult.memoryBackend), not the raw flag: FR-1 means the
|
|
6335
|
+
// flag can be absent while the actual backend is still agentdb (config-sourced) — the old
|
|
6336
|
+
// `memoryOpt === 'agentdb'` check would have mislabeled that run as jsonl right after fixing the
|
|
6337
|
+
// underlying steps to keep it agentdb.
|
|
6338
|
+
const backendLabel = setupResult.memoryBackend === 'agentdb'
|
|
6279
6339
|
? (wiring?.status === 'done' ? 'agentdb (.dz/agentdb.db + .dz/agentdb-mcp.db, separate stores)' : `agentdb INCOMPLETE — see setup steps`)
|
|
6280
6340
|
: 'sessions.jsonl + patterns.jsonl';
|
|
6281
6341
|
write(`║ Learning: ${backendLabel.padEnd(41)}║`);
|
|
@@ -6975,12 +7035,112 @@ function mirrorFailureMessage(error: unknown): string {
|
|
|
6975
7035
|
return String(error);
|
|
6976
7036
|
}
|
|
6977
7037
|
|
|
7038
|
+
/**
|
|
7039
|
+
* Scratch root for the packed-install smoke's pack/install dirs (feature
|
|
7040
|
+
* publish-sibling-drift-gate). MEASURED 2026-09-13: npm resolves a LOCAL tarball path (`npm
|
|
7041
|
+
* install <path-to.tgz>`) relative to `os.tmpdir()` — not to cwd — whenever that path sits
|
|
7042
|
+
* INSIDE `os.tmpdir()`, and does the same for the install dir; put pack and install dirs both
|
|
7043
|
+
* under `tmpdir()` and the recorded `file:` spec loses its `tmpdir()` prefix entirely (reproducer:
|
|
7044
|
+
* a fresh `npm pack <src> --pack-destination "$T/pack"` + `cd "$T/install" && npm install
|
|
7045
|
+
* "$T/pack/x.tgz"` with `$T` under `/tmp` silently installs NOTHING — "changed 1 package", empty
|
|
7046
|
+
* node_modules, `reify moves {}` in `--loglevel silly`; the identical commands under `/var/tmp`
|
|
7047
|
+
* install correctly). A directory outside `os.tmpdir()` sidesteps the quirk entirely.
|
|
7048
|
+
*/
|
|
7049
|
+
function packedInstallScratchRoot(): string {
|
|
7050
|
+
return existsSync('/var/tmp') ? '/var/tmp' : tmpdir();
|
|
7051
|
+
}
|
|
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
|
+
|
|
6978
7124
|
function cmdPublish(
|
|
6979
7125
|
options: Map<string, string>,
|
|
6980
7126
|
flags: Set<string>,
|
|
6981
7127
|
cwd: string,
|
|
6982
7128
|
writeOutput: Write,
|
|
6983
7129
|
mirrorRunner?: PublishMirrorRunner,
|
|
7130
|
+
siblingDriftFetcher?: FetchPublished,
|
|
7131
|
+
packedInstallRunner?: ReleaseExecRunner,
|
|
7132
|
+
publishExecRunner?: (
|
|
7133
|
+
command: string,
|
|
7134
|
+
options: { cwd?: string | URL | undefined; stdio?: unknown; encoding?: unknown; timeout?: number | undefined; env?: NodeJS.ProcessEnv | undefined },
|
|
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,
|
|
6984
7144
|
): number {
|
|
6985
7145
|
const json = flags.has('json');
|
|
6986
7146
|
// Under --json stdout carries exactly one JSON document, so every human line — guard notes, refusals,
|
|
@@ -6989,9 +7149,9 @@ function cmdPublish(
|
|
|
6989
7149
|
const write: Write = json ? (line) => { process.stderr.write(`${line}\n`); } : writeOutput;
|
|
6990
7150
|
// Reject unknown flags/options so a typo (e.g. `--dry-rum`) can NEVER be
|
|
6991
7151
|
// silently swallowed and flip the command into live-publish mode.
|
|
6992
|
-
const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance', 'json', 'no-mirror']);
|
|
7152
|
+
const allowedFlags = new Set(['dry-run', 'no-dry-run', 'yes', 'confirm', 'bump-only', 'help', 'require-signing', 'provenance', 'no-provenance', 'json', 'no-mirror', 'allow-sibling-drift', 'include-drifted']);
|
|
6993
7153
|
const allowedOptions = new Set(['filter', 'claim-check', 'no-guard', 'sign-key', 'mirror-cmd']);
|
|
6994
|
-
const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>, --mirror-cmd <cmd>, --no-mirror, --no-guard "<reason>" (skip the guard pre-flight; logged)';
|
|
7154
|
+
const allowedHelp = ' allowed: --dry-run (default), --yes/--confirm/--no-dry-run (go live), --bump-only, --filter <substr>, --claim-check <off|warn|error>, --mirror-cmd <cmd>, --no-mirror, --no-guard "<reason>" (skip the guard pre-flight; logged), --allow-sibling-drift (override the sibling-drift gate; logged), --include-drifted (auto-extend the batch with a drifted sibling)';
|
|
6995
7155
|
for (const flag of flags) {
|
|
6996
7156
|
if (!allowedFlags.has(flag)) {
|
|
6997
7157
|
write(`dz publish: unknown option --${flag}`);
|
|
@@ -7073,12 +7233,360 @@ function cmdPublish(
|
|
|
7073
7233
|
const claimCheckOpt = (claimCheckRaw as 'off' | 'warn' | 'error' | undefined) ?? 'warn';
|
|
7074
7234
|
|
|
7075
7235
|
const bumpOnly = flags.has('bump-only');
|
|
7076
|
-
|
|
7077
7236
|
// SAFETY: dry-run is the DEFAULT. A real publish requires an EXPLICIT opt-in
|
|
7078
7237
|
// via --yes, --confirm, or --no-dry-run. Without one, we never bump or publish.
|
|
7238
|
+
// Computed HERE (moved up from below the gates, AM-5) so both gates can see it: a dry run keeps
|
|
7239
|
+
// previewing packed-install with the CURRENT pre-bump tarball (nothing to compare a LIVE publish
|
|
7240
|
+
// against yet), while a live run defers the real packed-install-smoke into `publishPackages`'s
|
|
7241
|
+
// `packedTransport` — the one that tests the ACTUAL bytes about to ship (AM-1).
|
|
7079
7242
|
const wantsLive = flags.has('yes') || flags.has('confirm') || flags.has('no-dry-run');
|
|
7080
7243
|
const dryRun = !wantsLive;
|
|
7081
7244
|
|
|
7245
|
+
// ── FR-1..FR-4 — sibling-drift gate, then packed-install smoke (feature
|
|
7246
|
+
// publish-sibling-drift-gate, ADR-001). The sibling-drift gate runs before the signature gate
|
|
7247
|
+
// and the live-publish banner (so a --include-drifted-expanded batch is checked and shown too).
|
|
7248
|
+
// AM-5: on a DRY RUN both gates always print their verdict, even once sibling-drift already
|
|
7249
|
+
// blocks — the whole point of a preview is full information before anything ships. On a LIVE
|
|
7250
|
+
// run, sibling-drift still refuses immediately (packing/installing a doomed batch wastes real
|
|
7251
|
+
// time); its own packed-install smoke is deferred into `publishPackages`'s `packedTransport`
|
|
7252
|
+
// (AM-1) — the one gate that tests the tarball bytes actually handed to `npm publish`.
|
|
7253
|
+
const allowSiblingDrift = flags.has('allow-sibling-drift');
|
|
7254
|
+
const includeDrifted = flags.has('include-drifted');
|
|
7255
|
+
const allPackages = discoverPackages(cwd);
|
|
7256
|
+
const workspaceVersions = new Map(allPackages.map((p) => [p.name, p.version]));
|
|
7257
|
+
const workspaceDirs = new Map(allPackages.map((p) => [p.name, p.dir]));
|
|
7258
|
+
const matchesFilter = (pk: { name: string; dir: string }): boolean =>
|
|
7259
|
+
filter === undefined || filter.length === 0 || filter.some((f) => pk.name.includes(f) || pk.dir.includes(f));
|
|
7260
|
+
let targets = allPackages.filter(matchesFilter);
|
|
7261
|
+
let batchNames = new Set(targets.map((p) => p.name));
|
|
7262
|
+
|
|
7263
|
+
// Production default: `npm pack <name>@<version>` into a temp dir, extracted. Tests inject a
|
|
7264
|
+
// local directory (ADR-001, "fetchPublished … в тестах — локальный каталог").
|
|
7265
|
+
const fetchPublished: FetchPublished =
|
|
7266
|
+
siblingDriftFetcher ??
|
|
7267
|
+
((name, version) => {
|
|
7268
|
+
try {
|
|
7269
|
+
const tmp = mkdtempSync(join(tmpdir(), 'dz-sibling-drift-'));
|
|
7270
|
+
execSync(`npm pack ${name}@${version} --pack-destination ${JSON.stringify(tmp)}`, {
|
|
7271
|
+
stdio: 'pipe',
|
|
7272
|
+
encoding: 'utf-8',
|
|
7273
|
+
timeout: 60_000,
|
|
7274
|
+
});
|
|
7275
|
+
const tarball = readdirSync(tmp).find((f) => f.endsWith('.tgz'));
|
|
7276
|
+
if (tarball === undefined) return null;
|
|
7277
|
+
execSync(`tar -xzf ${JSON.stringify(join(tmp, tarball))} -C ${JSON.stringify(tmp)}`, { stdio: 'pipe', timeout: 60_000 });
|
|
7278
|
+
return { dir: join(tmp, 'package') };
|
|
7279
|
+
} catch {
|
|
7280
|
+
return null;
|
|
7281
|
+
}
|
|
7282
|
+
});
|
|
7283
|
+
|
|
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]}` };
|
|
7315
|
+
}
|
|
7316
|
+
npmPackInventoryCache.set(key, out);
|
|
7317
|
+
return out;
|
|
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>();
|
|
7333
|
+
|
|
7334
|
+
let driftBlocked = 0;
|
|
7335
|
+
const extraBatch = new Set<string>();
|
|
7336
|
+
// AM-2: --include-drifted must reach a FIXED POINT over transitive drifted siblings — a sibling
|
|
7337
|
+
// folded into the batch can itself depend on a drifted sibling outside it, and the round-1 review
|
|
7338
|
+
// (finding 2) showed the single pass never re-checked an EXPANDED batch's own new edges. Capped at
|
|
7339
|
+
// `allPackages.length + 1` rounds (the plan's own "цикл с потолком = число пакетов").
|
|
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 {
|
|
7344
|
+
for (let round = 0; round < maxRounds; round++) {
|
|
7345
|
+
let addedThisRound = false;
|
|
7346
|
+
for (const pk of targets) {
|
|
7347
|
+
let manifestObj: {
|
|
7348
|
+
dependencies?: Record<string, string>;
|
|
7349
|
+
peerDependencies?: Record<string, string>;
|
|
7350
|
+
optionalDependencies?: Record<string, string>;
|
|
7351
|
+
} | undefined;
|
|
7352
|
+
try {
|
|
7353
|
+
manifestObj = JSON.parse(readFileSync(join(pk.dir, 'package.json'), 'utf-8'));
|
|
7354
|
+
} catch (err) {
|
|
7355
|
+
// AM-3: an unreadable/invalid package.json for a BATCH package is an input this HARD gate
|
|
7356
|
+
// cannot build — it must BLOCK, never silently degrade to "no dependencies" (which used to
|
|
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.
|
|
7360
|
+
const reason = `package.json unreadable/invalid (${(err as Error).message.split('\n')[0]})`;
|
|
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
|
+
}
|
|
7376
|
+
}
|
|
7377
|
+
continue;
|
|
7378
|
+
}
|
|
7379
|
+
const deps = manifestObj?.dependencies ?? {};
|
|
7380
|
+
const peerDeps = manifestObj?.peerDependencies ?? {};
|
|
7381
|
+
const optionalDeps = manifestObj?.optionalDependencies ?? {};
|
|
7382
|
+
|
|
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.
|
|
7389
|
+
const anyWorkspaceDep = [...Object.values(deps), ...Object.values(peerDeps), ...Object.values(optionalDeps)]
|
|
7390
|
+
.some((spec) => String(spec).startsWith('workspace:'));
|
|
7391
|
+
|
|
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
|
+
});
|
|
7408
|
+
|
|
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;
|
|
7435
|
+
} else {
|
|
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`);
|
|
7440
|
+
driftBlocked++;
|
|
7441
|
+
}
|
|
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++;
|
|
7462
|
+
}
|
|
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)}`);
|
|
7469
|
+
}
|
|
7470
|
+
}
|
|
7471
|
+
}
|
|
7472
|
+
|
|
7473
|
+
if (driftBlocked > 0) break; // nothing to expand into a run that already refuses
|
|
7474
|
+
if (!includeDrifted || !addedThisRound) break; // no auto-expand requested, or fixed point reached
|
|
7475
|
+
|
|
7476
|
+
// FR-4: --include-drifted folds the drifted sibling(s) into the batch — they bump patch like
|
|
7477
|
+
// any other package in `publishPackages`' own (unchanged) bump logic. Re-loop: the newly
|
|
7478
|
+
// folded-in sibling(s) may themselves depend on a drifted sibling outside the (now bigger) batch.
|
|
7479
|
+
filter = filter === undefined ? [...batchNames, ...extraBatch] : [...filter, ...extraBatch];
|
|
7480
|
+
targets = allPackages.filter(matchesFilter);
|
|
7481
|
+
batchNames = new Set(targets.map((p) => p.name));
|
|
7482
|
+
}
|
|
7483
|
+
} finally {
|
|
7484
|
+
if (packTmpDir !== undefined) rmSync(packTmpDir, { recursive: true, force: true });
|
|
7485
|
+
}
|
|
7486
|
+
|
|
7487
|
+
const siblingDriftFailed = driftBlocked > 0;
|
|
7488
|
+
if (siblingDriftFailed && !dryRun) {
|
|
7489
|
+
write(`dz publish: refusing to publish (${driftBlocked} sibling-drift violation(s))`);
|
|
7490
|
+
return 1;
|
|
7491
|
+
}
|
|
7492
|
+
|
|
7493
|
+
// FR-3 — packed-install smoke: pack the WHOLE (possibly --include-drifted-expanded) batch,
|
|
7494
|
+
// install every tarball together in a CLEAN dir (out-of-batch siblings resolve from the
|
|
7495
|
+
// registry, exactly like a fresh user's install), then boot every bin with --version.
|
|
7496
|
+
// "n/a" (FR-6) when nothing in the batch has a bin. AM-8: a bin is collected here whether or not
|
|
7497
|
+
// its target file exists YET — a manifest that declares one but ships nothing must BLOCK after a
|
|
7498
|
+
// real install, never silently vanish from the plan (which used to read as n/a, or even skip the
|
|
7499
|
+
// whole gate when it was the batch's only bin).
|
|
7500
|
+
const bins: { pkg: string; binName: string; relPath: string }[] = [];
|
|
7501
|
+
for (const pk of targets) {
|
|
7502
|
+
let manifest: { bin?: string | Record<string, string> } = {};
|
|
7503
|
+
try { manifest = JSON.parse(readFileSync(join(pk.dir, 'package.json'), 'utf-8')); } catch { /* no bin info available */ }
|
|
7504
|
+
if (typeof manifest.bin === 'string') {
|
|
7505
|
+
bins.push({ pkg: pk.name, binName: pk.name.split('/').pop() ?? pk.name, relPath: manifest.bin.replace(/^\.\//, '') });
|
|
7506
|
+
} else if (manifest.bin !== undefined && manifest.bin !== null && typeof manifest.bin === 'object') {
|
|
7507
|
+
for (const [name, relRaw] of Object.entries(manifest.bin)) {
|
|
7508
|
+
bins.push({ pkg: pk.name, binName: name, relPath: String(relRaw).replace(/^\.\//, '') });
|
|
7509
|
+
}
|
|
7510
|
+
}
|
|
7511
|
+
}
|
|
7512
|
+
|
|
7513
|
+
// AM-1/AM-5: the packed-install-smoke PREVIEW below runs on a DRY RUN only, against whatever is
|
|
7514
|
+
// CURRENTLY on disk (pre-bump) — it cannot be the "same bytes that ship" gate AM-1 requires,
|
|
7515
|
+
// because a dry run never bumps/builds/packs anything real to compare against. On a LIVE run the
|
|
7516
|
+
// real gate is `packedTransport` (wired at the `publishPackages` call below), which packs ONCE
|
|
7517
|
+
// post-bump and smokes exactly those tarballs — this preview is skipped entirely then, so its
|
|
7518
|
+
// digest is never confused with the one that actually ships.
|
|
7519
|
+
let packedInstallSmokePreviewFailed = false;
|
|
7520
|
+
if (dryRun) {
|
|
7521
|
+
if (bins.length === 0) {
|
|
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)}`);
|
|
7524
|
+
} else {
|
|
7525
|
+
const scratchRoot = packedInstallScratchRoot();
|
|
7526
|
+
const packDir = mkdtempSync(join(scratchRoot, 'dz-publish-pack-'));
|
|
7527
|
+
const installDir = mkdtempSync(join(scratchRoot, 'dz-publish-install-'));
|
|
7528
|
+
const runSmoke: ReleaseExecRunner =
|
|
7529
|
+
packedInstallRunner ??
|
|
7530
|
+
((cmd, o) => {
|
|
7531
|
+
try {
|
|
7532
|
+
const stdout = execSync(cmd, { cwd: o.cwd, stdio: 'pipe', encoding: 'utf-8', timeout: o.timeoutMs });
|
|
7533
|
+
return { exitCode: 0, stdout: stdout == null ? '' : String(stdout), stderr: '' };
|
|
7534
|
+
} catch (err) {
|
|
7535
|
+
const e = err as Error & { status?: number | null; signal?: string | null; killed?: boolean; stdout?: unknown; stderr?: unknown };
|
|
7536
|
+
const timedOut = (e.status === null || e.status === undefined) && (e.signal != null || e.killed === true);
|
|
7537
|
+
return {
|
|
7538
|
+
exitCode: typeof e.status === 'number' ? e.status : 1,
|
|
7539
|
+
stdout: e.stdout == null ? '' : String(e.stdout),
|
|
7540
|
+
stderr: e.stderr == null || String(e.stderr).trim() === '' ? formatPublishError(e) : String(e.stderr),
|
|
7541
|
+
timedOut,
|
|
7542
|
+
};
|
|
7543
|
+
}
|
|
7544
|
+
});
|
|
7545
|
+
const smokePlan = planPackedInstallSmoke({
|
|
7546
|
+
packages: targets.map((p) => ({ name: p.name, dir: p.dir, version: p.version })),
|
|
7547
|
+
bins,
|
|
7548
|
+
packDir,
|
|
7549
|
+
installDir,
|
|
7550
|
+
});
|
|
7551
|
+
const smokeExecutions: PackedInstallExecution[] = [];
|
|
7552
|
+
// Lead edit after the live dry-run (13.09 12:05): the preview packed the WORKING directory with
|
|
7553
|
+
// `workspace:^` specs still inside, so `npm install <tgz>` died with EUNSUPPORTEDPROTOCOL — the
|
|
7554
|
+
// preview must stage package.json exactly as the live packedTransport does (sibling pins via
|
|
7555
|
+
// rewriteWorkspaceSpecs, prepublishOnly dropped) and restore the originals afterwards. Shared
|
|
7556
|
+
// with `dz release` via `withStagedPackageJson` (feature release-smoke-staged-pack).
|
|
7557
|
+
try {
|
|
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) });
|
|
7567
|
+
}
|
|
7568
|
+
const smokeVerdict = judgePackedInstallSmoke(smokePlan, smokeExecutions);
|
|
7569
|
+
try { rmSync(packDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
7570
|
+
try { rmSync(installDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
7571
|
+
|
|
7572
|
+
if (smokeVerdict.ok) {
|
|
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)}`);
|
|
7575
|
+
} else {
|
|
7576
|
+
const detail = smokeVerdict.failureDetail ?? smokeVerdict.bins.find((b) => !b.ok)?.detail ?? '(no 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)}`);
|
|
7579
|
+
for (const b of smokeVerdict.bins.filter((b) => !b.ok)) write(` ✗ ${b.pkg} (${b.binName}): ${b.detail ?? '(no detail)'}`);
|
|
7580
|
+
packedInstallSmokePreviewFailed = true;
|
|
7581
|
+
}
|
|
7582
|
+
}
|
|
7583
|
+
}
|
|
7584
|
+
|
|
7585
|
+
if (siblingDriftFailed || packedInstallSmokePreviewFailed) {
|
|
7586
|
+
write(`dz publish: refusing to publish (${driftBlocked} sibling-drift violation(s)${packedInstallSmokePreviewFailed ? ', packed install smoke failed' : ''})`);
|
|
7587
|
+
return 1;
|
|
7588
|
+
}
|
|
7589
|
+
|
|
7082
7590
|
if (!dryRun) {
|
|
7083
7591
|
// Loud confirmation banner listing exactly what is about to be published.
|
|
7084
7592
|
const targets = discoverPackages(cwd).filter((p) =>
|
|
@@ -7130,22 +7638,35 @@ function cmdPublish(
|
|
|
7130
7638
|
// `pnpm publish` and skipped re-signing (ADR-001, features/publish-gate-verifies-the-tarball).
|
|
7131
7639
|
let cleanupGate: (() => void) | null = null;
|
|
7132
7640
|
try {
|
|
7133
|
-
const
|
|
7134
|
-
|
|
7135
|
-
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
|
|
7142
|
-
|
|
7143
|
-
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`);
|
|
7144
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;
|
|
7145
7669
|
}
|
|
7146
|
-
verifyOk =
|
|
7147
|
-
extracted !== undefined &&
|
|
7148
|
-
verifyManifest(extracted.dir, signed, readFileSync(trustRoot, 'utf8')).ok;
|
|
7149
7670
|
} catch {
|
|
7150
7671
|
verifyOk = false;
|
|
7151
7672
|
} finally {
|
|
@@ -7171,12 +7692,78 @@ function cmdPublish(
|
|
|
7171
7692
|
// longer exist. Default to the same path `dz sign --init` writes, so the ordinary operator needs no
|
|
7172
7693
|
// new flag; `--sign-key` overrides it.
|
|
7173
7694
|
const signKey = (options.get('sign-key') ?? join(homedir(), '.dz', 'keys', 'dz.key')).trim();
|
|
7695
|
+
// AM-1: the packedTransport smoke closure and the actual `npm publish <tgz>` inside
|
|
7696
|
+
// `publishPackages` both read from THIS SAME directory — created once, cleaned up once, after
|
|
7697
|
+
// publishPackages returns (it needs the tarballs on disk through its own publish step).
|
|
7698
|
+
const packedTransportPackDestDir = mkdtempSync(join(packedInstallScratchRoot(), 'dz-publish-packed-'));
|
|
7174
7699
|
const publishReport = publishPackages(cwd, {
|
|
7175
7700
|
provenance,
|
|
7176
7701
|
dryRun,
|
|
7177
7702
|
filter,
|
|
7178
7703
|
bumpOnly,
|
|
7179
7704
|
claimGate: claimCheckOpt,
|
|
7705
|
+
exec: publishExecRunner,
|
|
7706
|
+
packedTransport: {
|
|
7707
|
+
packDestDir: packedTransportPackDestDir,
|
|
7708
|
+
// AM-1: judged ONCE, over every package's packed artifact — nothing in the batch publishes
|
|
7709
|
+
// until this returns ok:true. `bins` (AM-8-fixed: declared bins are collected whether or not
|
|
7710
|
+
// their target file exists yet) was already computed above from the same `targets` this
|
|
7711
|
+
// batch resolves to.
|
|
7712
|
+
smoke: (artifacts): { ok: boolean; reason?: string } => {
|
|
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 }));
|
|
7715
|
+
if (bins.length === 0) {
|
|
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)}`);
|
|
7718
|
+
return { ok: true };
|
|
7719
|
+
}
|
|
7720
|
+
const scratchRoot = packedInstallScratchRoot();
|
|
7721
|
+
const installDir = mkdtempSync(join(scratchRoot, 'dz-publish-install-'));
|
|
7722
|
+
const runSmoke: ReleaseExecRunner =
|
|
7723
|
+
packedInstallRunner ??
|
|
7724
|
+
((cmd, o) => {
|
|
7725
|
+
try {
|
|
7726
|
+
const stdout = execSync(cmd, { cwd: o.cwd, stdio: 'pipe', encoding: 'utf-8', timeout: o.timeoutMs });
|
|
7727
|
+
return { exitCode: 0, stdout: stdout == null ? '' : String(stdout), stderr: '' };
|
|
7728
|
+
} catch (err) {
|
|
7729
|
+
const e = err as Error & { status?: number | null; signal?: string | null; killed?: boolean; stdout?: unknown; stderr?: unknown };
|
|
7730
|
+
const timedOut = (e.status === null || e.status === undefined) && (e.signal != null || e.killed === true);
|
|
7731
|
+
return {
|
|
7732
|
+
exitCode: typeof e.status === 'number' ? e.status : 1,
|
|
7733
|
+
stdout: e.stdout == null ? '' : String(e.stdout),
|
|
7734
|
+
stderr: e.stderr == null || String(e.stderr).trim() === '' ? formatPublishError(e) : String(e.stderr),
|
|
7735
|
+
timedOut,
|
|
7736
|
+
};
|
|
7737
|
+
}
|
|
7738
|
+
});
|
|
7739
|
+
const smokePlan = planPackedInstallSmoke({
|
|
7740
|
+
// skipPack (AM-1): these tarballs are ALREADY packed (by publishPackages, above) — a
|
|
7741
|
+
// second, different pack here would smoke bytes other than the ones about to publish.
|
|
7742
|
+
packages: artifacts.map((a) => ({ name: a.name, dir: '(packed already — see skipPack)', version: a.newVersion })),
|
|
7743
|
+
bins,
|
|
7744
|
+
packDir: packedTransportPackDestDir,
|
|
7745
|
+
installDir,
|
|
7746
|
+
skipPack: true,
|
|
7747
|
+
});
|
|
7748
|
+
const smokeExecutions: PackedInstallExecution[] = [];
|
|
7749
|
+
for (const step of smokePlan.steps) {
|
|
7750
|
+
const r = runSmoke(step.cmd, { cwd: step.cwd, timeoutMs: step.timeoutMs });
|
|
7751
|
+
smokeExecutions.push({ stepId: step.id, exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, ...(r.timedOut !== undefined ? { timedOut: r.timedOut } : {}) });
|
|
7752
|
+
}
|
|
7753
|
+
const verdict = judgePackedInstallSmoke(smokePlan, smokeExecutions);
|
|
7754
|
+
try { rmSync(installDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
7755
|
+
if (verdict.ok) {
|
|
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)}`);
|
|
7758
|
+
return { ok: true };
|
|
7759
|
+
}
|
|
7760
|
+
const detail = verdict.failureDetail ?? verdict.bins.find((b) => !b.ok)?.detail ?? '(no 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)}`);
|
|
7763
|
+
for (const b of verdict.bins.filter((b) => !b.ok)) write(` ✗ ${b.pkg} (${b.binName}): ${b.detail ?? '(no detail)'}`);
|
|
7764
|
+
return { ok: false, reason: detail };
|
|
7765
|
+
},
|
|
7766
|
+
},
|
|
7180
7767
|
signKey: signKey === '' ? undefined : resolve(cwd, signKey),
|
|
7181
7768
|
verifyAfterSign: (packDir: string): { ok: boolean; trustRootPresent: boolean; pack?: string } => {
|
|
7182
7769
|
// Verify the OUTCOME against the trust root a CONSUMER would use — an existing key may be the
|
|
@@ -7250,6 +7837,7 @@ function cmdPublish(
|
|
|
7250
7837
|
}
|
|
7251
7838
|
},
|
|
7252
7839
|
});
|
|
7840
|
+
try { rmSync(packedTransportPackDestDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
7253
7841
|
|
|
7254
7842
|
const configMirror = mirrorCommandFromConfig(cwd);
|
|
7255
7843
|
const configuredCommand = (options.get('mirror-cmd') ?? configMirror.command ?? '').trim();
|
|
@@ -7340,6 +7928,9 @@ function cmdPublish(
|
|
|
7340
7928
|
? ` (confirmed by registry after ${pkg.registryProbes} probes)`
|
|
7341
7929
|
: '';
|
|
7342
7930
|
write(` ${icon} ${pkg.name.padEnd(35)} ${pkg.oldVersion} → ${pkg.newVersion} ${pkg.status}${receipt}${detail}`);
|
|
7931
|
+
// AM-1: the digest of the EXACT tarball bytes that were smoke-tested AND published — present
|
|
7932
|
+
// only for a packedTransport publish, so "the smoke tested what shipped" is checkable here too.
|
|
7933
|
+
if (pkg.status === 'published' && pkg.sha256 !== undefined) write(` sha256:${pkg.sha256}`);
|
|
7343
7934
|
if (pkg.status === 'error' && pkg.error) {
|
|
7344
7935
|
for (const line of pkg.error.split('\n')) write(` ${line}`);
|
|
7345
7936
|
}
|
|
@@ -7676,14 +8267,31 @@ function cmdRelease(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
7676
8267
|
factsList = selected;
|
|
7677
8268
|
}
|
|
7678
8269
|
|
|
8270
|
+
// FR-6 (feature publish-sibling-drift-gate): real tmp dirs for the packed-install smoke — only
|
|
8271
|
+
// when something in the set actually has a bin to boot (packing bin-less siblings proves
|
|
8272
|
+
// nothing this gate exists to catch). Planning stays pure (planReleaseGates never mkdtemps
|
|
8273
|
+
// itself); these are cleaned up on every exit path below, dry-run included.
|
|
8274
|
+
const packedInstallEligible = factsList.some((f) => f.bins.some((b) => b.exists));
|
|
8275
|
+
const releaseScratchRoot = packedInstallScratchRoot();
|
|
8276
|
+
const packedInstallDirs = packedInstallEligible
|
|
8277
|
+
? { packDir: mkdtempSync(join(releaseScratchRoot, 'dz-release-pack-')), installDir: mkdtempSync(join(releaseScratchRoot, 'dz-release-install-')) }
|
|
8278
|
+
: undefined;
|
|
8279
|
+
const cleanupPackedInstallDirs = (): void => {
|
|
8280
|
+
if (packedInstallDirs === undefined) return;
|
|
8281
|
+
try { rmSync(packedInstallDirs.packDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
8282
|
+
try { rmSync(packedInstallDirs.installDir, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
8283
|
+
};
|
|
8284
|
+
|
|
7679
8285
|
const plan = planReleaseGates(factsList, {
|
|
7680
8286
|
monorepoRoot: cwd,
|
|
7681
8287
|
pnpmLockPresent: existsSync(join(cwd, 'pnpm-lock.yaml')),
|
|
7682
8288
|
includeDevDeps: flags.has('audit-dev'),
|
|
8289
|
+
packedInstall: packedInstallDirs,
|
|
7683
8290
|
});
|
|
7684
8291
|
|
|
7685
8292
|
// --dry-run: print the full plan, execute NOTHING (deterministic, byte-testable preview).
|
|
7686
8293
|
if (flags.has('dry-run')) {
|
|
8294
|
+
cleanupPackedInstallDirs();
|
|
7687
8295
|
if (json) {
|
|
7688
8296
|
write(JSON.stringify({ dryRun: true, packages: plan.packages, steps: plan.steps, skips: plan.skips, warnings }, null, 2));
|
|
7689
8297
|
return 0;
|
|
@@ -7707,7 +8315,24 @@ function cmdRelease(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
7707
8315
|
let smokeTmp: string | undefined;
|
|
7708
8316
|
const execSteps: GateStep[] = plan.steps.filter((s) => s.kind !== 'synthetic-fail');
|
|
7709
8317
|
say(`\ndz release — executing ${execSteps.length} gate step(s) across ${plan.packages.length} package(s)…`);
|
|
7710
|
-
|
|
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 => {
|
|
7711
8336
|
let stepCwd = step.cwd;
|
|
7712
8337
|
if (step.tempCwd === true) {
|
|
7713
8338
|
// AM-4: boot bins in a throwaway cwd so an installer-style bin cannot mutate the workspace.
|
|
@@ -7724,10 +8349,39 @@ function cmdRelease(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
7724
8349
|
durationMs: Date.now() - started,
|
|
7725
8350
|
timedOut: r.timedOut,
|
|
7726
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
|
+
}
|
|
7727
8379
|
}
|
|
8380
|
+
|
|
7728
8381
|
if (smokeTmp !== undefined) {
|
|
7729
8382
|
try { rmSync(smokeTmp, { recursive: true, force: true }); } catch { /* best-effort cleanup */ }
|
|
7730
8383
|
}
|
|
8384
|
+
cleanupPackedInstallDirs();
|
|
7731
8385
|
|
|
7732
8386
|
const verdict = classifyGateExecutions(plan, executions);
|
|
7733
8387
|
|
|
@@ -7736,7 +8390,22 @@ function cmdRelease(options: Map<string, string>, flags: Set<string>, cwd: strin
|
|
|
7736
8390
|
for (const g of verdict.gates) {
|
|
7737
8391
|
const icon = g.status === 'pass' ? '✓' : g.status === 'fail' ? '✗' : '○';
|
|
7738
8392
|
say(` ${icon} ${g.gate.padEnd(7)} ${g.status.toUpperCase().padEnd(4)} ${g.passed} passed, ${g.failures.length} failed, ${g.skips.length} skipped`);
|
|
7739
|
-
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
|
+
}
|
|
7740
8409
|
for (const sk of g.skips) say(` [${sk.class}] ${sk.pkg}: ${sk.reason}`);
|
|
7741
8410
|
}
|
|
7742
8411
|
say(` verdict at ${verdict.timestamp}: ${verdict.publishAction === 'proceed' ? '✓ all gates green' : '✗ RELEASE BLOCKED'}`);
|
|
@@ -10455,6 +11124,177 @@ function runGuardEvaluation(root: string, op: string, text: string | undefined,
|
|
|
10455
11124
|
return result;
|
|
10456
11125
|
}
|
|
10457
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
|
+
|
|
11157
|
+
/**
|
|
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
|
|
11186
|
+
* gates write to the SAME append-only, hash-chained `.dz/guard-audit.jsonl` the declarative
|
|
11187
|
+
* `dz guard` rules use — visibility for `dz guard promote`/`dz compounding` never depends on
|
|
11188
|
+
* which mechanism produced the finding. `pass` records go through as an informational `note`
|
|
11189
|
+
* (never a violation, so they can never flip the row's own verdict) so a clean check is ALSO on
|
|
11190
|
+
* the record, not just a block or an override (AM-6: "аудит без записи = не аудит").
|
|
11191
|
+
*
|
|
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`).
|
|
11204
|
+
*/
|
|
11205
|
+
function appendPublishGateAudit(
|
|
11206
|
+
root: string,
|
|
11207
|
+
rule: 'sibling-drift' | 'packed-install-smoke',
|
|
11208
|
+
verdict: 'pass' | 'warn' | 'block',
|
|
11209
|
+
detail: string,
|
|
11210
|
+
packages: readonly PublishGateAuditPackage[],
|
|
11211
|
+
overrideReason?: string,
|
|
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);
|
|
11222
|
+
try {
|
|
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(
|
|
11231
|
+
verdict === 'pass'
|
|
11232
|
+
? { op: 'publish', verdict, violations: [], checked: [rule], notEstablished: [], notes: [label] }
|
|
11233
|
+
: { op: 'publish', verdict, violations: [{ rule, severity: 'hard', detail: label }], checked: [rule], notEstablished: [] },
|
|
11234
|
+
new Date().toISOString(),
|
|
11235
|
+
overrideReason !== undefined ? { reason: overrideReason } : undefined,
|
|
11236
|
+
);
|
|
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]}` };
|
|
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'})`;
|
|
11296
|
+
}
|
|
11297
|
+
|
|
10458
11298
|
function renderGuardObservation(observation: GuardObservation): string {
|
|
10459
11299
|
const tag = observation.status === 'unknown' ? 'note' : 'observe';
|
|
10460
11300
|
return ` [${tag}] ${observation.rule} ${observation.scope}: ${observation.detail} [${observation.status}]`;
|
|
@@ -16903,7 +17743,17 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
|
|
|
16903
17743
|
);
|
|
16904
17744
|
}
|
|
16905
17745
|
|
|
16906
|
-
|
|
17746
|
+
// writeSequence (qe-bridge-signoff-order): diagnostic sequencing metadata — a self-reported
|
|
17747
|
+
// process-local trace with monotonic stamps taken at each named event (start of the first record
|
|
17748
|
+
// write; after the report landed; just before the atomic update). It replaces a wall-clock
|
|
17749
|
+
// mtime comparison that was a race (the record is rewritten AFTER the report by design). It does
|
|
17750
|
+
// NOT prove write order or crash safety: those are proven by the failpoint test (R4-1) and the
|
|
17751
|
+
// report-failure test. Lead edit after Codex review 2026-09-13: honest step names.
|
|
17752
|
+
const seq: Array<{ step: 'signoff-write-started' | 'report-written' | 'record-update-prepared'; monotonicNs: string }> = [
|
|
17753
|
+
{ step: 'signoff-write-started', monotonicNs: String(process.hrtime.bigint()) },
|
|
17754
|
+
];
|
|
17755
|
+
|
|
17756
|
+
const recordText = (reportWritten: boolean, writeSequence: typeof seq): string => `${JSON.stringify(buildBridgeSignoffRecord(signoff, {
|
|
16907
17757
|
runId,
|
|
16908
17758
|
claudeBin: resolvedBin,
|
|
16909
17759
|
binOverride,
|
|
@@ -16912,12 +17762,13 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
|
|
|
16912
17762
|
rawStdoutFile,
|
|
16913
17763
|
promptSha256,
|
|
16914
17764
|
...(parsed.channels === undefined ? {} : { channels: parsed.channels }),
|
|
17765
|
+
writeSequence,
|
|
16915
17766
|
}), null, 2)}\n`;
|
|
16916
17767
|
|
|
16917
17768
|
let signoffPath: string;
|
|
16918
17769
|
try {
|
|
16919
17770
|
signoffPath = uniquePath(join(stateDir, `signoff-${runId}`), '.json');
|
|
16920
|
-
writeNewFileOrThrow(signoffPath, recordText(false));
|
|
17771
|
+
writeNewFileOrThrow(signoffPath, recordText(false, seq));
|
|
16921
17772
|
} catch (error) {
|
|
16922
17773
|
return failRun(
|
|
16923
17774
|
'audit-write-failed',
|
|
@@ -16935,6 +17786,10 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
|
|
|
16935
17786
|
}
|
|
16936
17787
|
|
|
16937
17788
|
if (reportError === null) {
|
|
17789
|
+
// the report is on disk: the sequence gains a step BEFORE the `reportWritten:true` record
|
|
17790
|
+
// write, not after — an observer reading the eventual writeSequence must see the report step
|
|
17791
|
+
// land before the record-update step that persists it.
|
|
17792
|
+
seq.push({ step: 'report-written', monotonicNs: String(process.hrtime.bigint()) });
|
|
16938
17793
|
// the ONLY moment `reportWritten:true` may appear: after the report is on disk
|
|
16939
17794
|
try {
|
|
16940
17795
|
// ATOMIC (R4-1): write a sibling temp file, then rename() over the original. On the same
|
|
@@ -16943,7 +17798,8 @@ async function cmdQeBridge(options: Map<string, string>, flags: Set<string>, cwd
|
|
|
16943
17798
|
// which made the "a crash leaves a record that is true or pessimistic" claim untrue in the
|
|
16944
17799
|
// one case it was about.
|
|
16945
17800
|
const tmpPath = `${signoffPath}.tmp.${process.pid}`;
|
|
16946
|
-
|
|
17801
|
+
seq.push({ step: 'record-update-prepared', monotonicNs: String(process.hrtime.bigint()) });
|
|
17802
|
+
writeNewFileOrThrow(tmpPath, recordText(true, seq));
|
|
16947
17803
|
if (process.env[QE_BRIDGE_FAILPOINT_ENV] === 'hang-before-rename') {
|
|
16948
17804
|
// test-only: stop dead INSIDE the window, so a SIGKILL can prove the property
|
|
16949
17805
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 600_000);
|
|
@@ -19681,7 +20537,7 @@ export async function runCli(argv: string[], io: CliIo = {}): Promise<number> {
|
|
|
19681
20537
|
case 'auto-canonicalize':
|
|
19682
20538
|
return await cmdAutoCanonicalize(options, cwd, write);
|
|
19683
20539
|
case 'publish':
|
|
19684
|
-
return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner);
|
|
20540
|
+
return cmdPublish(options, flags, cwd, write, io.publishMirrorRunner, io.publishSiblingDriftFetcher, io.publishPackedInstallRunner, io.publishExecRunner, io.publishGateAuditFsLayer, io.publishNpmPackRunner);
|
|
19685
20541
|
case 'release':
|
|
19686
20542
|
return cmdRelease(options, flags, cwd, write, io.releaseRunner);
|
|
19687
20543
|
case 'parity':
|