@dzhechkov/harness-core 0.8.31 → 0.8.33

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.
Files changed (53) hide show
  1. package/.dz-manifest.json +97 -37
  2. package/README.md +30 -0
  3. package/dist/apply-leg.d.ts +13 -1
  4. package/dist/apply-leg.d.ts.map +1 -1
  5. package/dist/apply-leg.js +123 -10
  6. package/dist/apply-leg.js.map +1 -1
  7. package/dist/embed-socket-path.d.ts +65 -0
  8. package/dist/embed-socket-path.d.ts.map +1 -0
  9. package/dist/embed-socket-path.js +100 -0
  10. package/dist/embed-socket-path.js.map +1 -0
  11. package/dist/index.d.ts +9 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +7 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/operations.d.ts.map +1 -1
  16. package/dist/operations.js +66 -3
  17. package/dist/operations.js.map +1 -1
  18. package/dist/packed-install-smoke.d.ts +108 -0
  19. package/dist/packed-install-smoke.d.ts.map +1 -0
  20. package/dist/packed-install-smoke.js +172 -0
  21. package/dist/packed-install-smoke.js.map +1 -0
  22. package/dist/publish-sibling-drift.d.ts +67 -0
  23. package/dist/publish-sibling-drift.d.ts.map +1 -0
  24. package/dist/publish-sibling-drift.js +262 -0
  25. package/dist/publish-sibling-drift.js.map +1 -0
  26. package/dist/publish.d.ts +44 -0
  27. package/dist/publish.d.ts.map +1 -1
  28. package/dist/publish.js +243 -24
  29. package/dist/publish.js.map +1 -1
  30. package/dist/qe-bridge.d.ts +16 -0
  31. package/dist/qe-bridge.d.ts.map +1 -1
  32. package/dist/qe-bridge.js +1 -0
  33. package/dist/qe-bridge.js.map +1 -1
  34. package/dist/release.d.ts +19 -0
  35. package/dist/release.d.ts.map +1 -1
  36. package/dist/release.js +81 -2
  37. package/dist/release.js.map +1 -1
  38. package/dist/setup.d.ts +36 -0
  39. package/dist/setup.d.ts.map +1 -1
  40. package/dist/setup.js +96 -2
  41. package/dist/setup.js.map +1 -1
  42. package/package.json +23 -23
  43. package/sbom.json +186 -36
  44. package/src/apply-leg.ts +123 -10
  45. package/src/embed-socket-path.ts +113 -0
  46. package/src/index.ts +35 -2
  47. package/src/operations.ts +67 -3
  48. package/src/packed-install-smoke.ts +273 -0
  49. package/src/publish-sibling-drift.ts +293 -0
  50. package/src/publish.ts +280 -25
  51. package/src/qe-bridge.ts +14 -0
  52. package/src/release.ts +103 -2
  53. package/src/setup.ts +108 -2
package/src/publish.ts CHANGED
@@ -14,9 +14,15 @@ import { execSync } from 'node:child_process';
14
14
  // Structural twin of node's ExecSyncOptionsWithStringEncoding — a type-only import of node:child_process
15
15
  // still counts as an IO import for the core-boundary ratchet (measured 66 → 67), so the shape is spelled here.
16
16
  type ExecSyncOptionsWithStringEncoding = NonNullable<Parameters<typeof execSync>[1]> & { encoding: 'utf-8' };
17
+ // node:crypto is NOT in the core-boundary ratchet's tracked module list (fs/child_process/https
18
+ // only) — a read-only digest of bytes already produced by THIS process is not the kind of external
19
+ // I/O the ratchet polices, so this import is free with respect to it (verified against
20
+ // `core-boundary.ts`'s `countIoImports` module list).
21
+ import { createHash } from 'node:crypto';
17
22
 
18
23
  import { claimCheck } from './claim-check.js';
19
24
  import { rewriteReleaseLine } from './release-line.js';
25
+ import { packedTarballName } from './packed-install-smoke.js';
20
26
 
21
27
  export type ProbeOutcome = {
22
28
  readonly attempt: number;
@@ -59,6 +65,32 @@ export interface PublishResult {
59
65
  * and the CLI prints it. Absent on a real publish, where every gate actually ran.
60
66
  */
61
67
  readonly notVerified?: readonly string[] | undefined;
68
+ /**
69
+ * AM-1 (feature publish-sibling-drift-gate): sha256 of the EXACT tarball bytes that were
70
+ * packed-install-smoked AND handed to `npm publish` — present only when `opts.packedTransport`
71
+ * was used for a live (non-dry-run) publish. Printed and audited so "the smoke tested the same
72
+ * bytes that shipped" is a checkable claim, not an architectural assertion.
73
+ */
74
+ readonly sha256?: string | undefined;
75
+ }
76
+
77
+ /**
78
+ * One package's packed, smoke-eligible artifact — the unit `opts.packedTransport.smoke` receives
79
+ * (AM-1). Produced ONCE per package, after its OWN bump/build/sign steps, from a package.json
80
+ * whose `workspace:` specs are already resolved to each sibling's PINNED version (the same
81
+ * transformation `rewriteWorkspaceSpecs` performs) — so the tarball is exactly what `npm publish
82
+ * <tgzPath>` will later ship, byte for byte.
83
+ */
84
+ export interface PackedTarballArtifact {
85
+ readonly name: string;
86
+ readonly newVersion: string;
87
+ readonly tgzPath: string;
88
+ readonly sha256: string;
89
+ }
90
+
91
+ export interface PackedTransportSmokeVerdict {
92
+ readonly ok: boolean;
93
+ readonly reason?: string | undefined;
62
94
  }
63
95
 
64
96
  /** Positive read-back evidence returned by the configured public-mirror command. */
@@ -635,6 +667,26 @@ export function publishPackages(
635
667
  probe?: ((name: string, version: string) => boolean | Omit<ProbeOutcome, 'attempt'>) | undefined;
636
668
  /** Pause injection between receipt probes. The default blocks for the requested milliseconds. */
637
669
  sleep?: ((milliseconds: number) => void) | undefined;
670
+ /**
671
+ * AM-1 (feature publish-sibling-drift-gate). OPT-IN — omitted (the default), this function is
672
+ * byte-identical to its pre-amendment self: every package still publishes via `pnpm publish`
673
+ * FROM ITS DIRECTORY (`publishArgv`), exactly as every existing test of this function expects.
674
+ *
675
+ * When provided, the transport for THIS call's batch changes: each package is packed ONCE
676
+ * (after its own bump/build/sign, workspace: specs resolved to each sibling's PINNED version —
677
+ * a landed batch sibling's NEW version, an out-of-batch sibling's disk version, mirroring what
678
+ * `rewriteWorkspaceSpecs`/pnpm itself would resolve), collected, and `smoke` is called with
679
+ * EVERY package's packed artifact before ANY of them is published. Only on `smoke.ok === true`
680
+ * does each artifact get `npm publish <tgzPath>` — the exact bytes that were smoked (a sha256
681
+ * recheck immediately before that call refuses on any mismatch, defending the "same bytes"
682
+ * claim against anything that could touch the tarball in between).
683
+ */
684
+ packedTransport?: {
685
+ /** Absolute, pre-created directory to write tarballs into (this function never mkdirs it). */
686
+ readonly packDestDir: string;
687
+ /** Judge the WHOLE batch's packed artifacts together — nothing publishes until this passes. */
688
+ readonly smoke: (artifacts: readonly PackedTarballArtifact[]) => PackedTransportSmokeVerdict;
689
+ } | undefined;
638
690
  } = {},
639
691
  ): PublishReport {
640
692
  // Decide ONCE, before the batch: `--provenance` in an incapable environment must fail here, not on
@@ -665,6 +717,64 @@ export function publishPackages(
665
717
  const armFloorPreflight = opts.bumpOnly !== true && (opts.dryRun !== true || opts.probeFloor !== undefined);
666
718
  const probeFloor = opts.probeFloor ?? ((name: string, version: string) => probeVersion(name, version).ok);
667
719
 
720
+ // AM-1 (packedTransport): sibling pins for the packing-only rewrite, updated to each package's
721
+ // NEW version the moment its OWN bump lands — a dependent packed LATER in this same batch must
722
+ // pin to what its dependency will actually ship, not the stale value captured before the loop
723
+ // (mirrors what a real `pnpm pack` reads: the dependency's on-disk package.json, already bumped).
724
+ const pinVersions = new Map(workspaceVersions);
725
+ interface PendingPacked {
726
+ readonly name: string;
727
+ readonly dir: string;
728
+ readonly oldVersion: string;
729
+ readonly newVersion: string;
730
+ readonly tgzPath: string;
731
+ readonly sha256: string;
732
+ readonly pkgJsonPath: string;
733
+ readonly originalPkgJson: string;
734
+ readonly readmePath: string;
735
+ readonly originalReadme: string | undefined;
736
+ readonly claimCheckSummary: { findings: number; high: number } | undefined;
737
+ }
738
+ const pendingPacked: PendingPacked[] = [];
739
+
740
+ /**
741
+ * The registry receipt-probe loop, factored out so the packedTransport pass (below) reuses it
742
+ * identically to the existing per-package publish path — including the "no receipt is not
743
+ * success" throw itself, so there is exactly ONE place in this file that decides that.
744
+ */
745
+ function confirmPublished(name: string, version: string, probeLog: ProbeOutcome[]): { registryProbes: number } {
746
+ let registryProbes = 0;
747
+ let confirmed = false;
748
+ while (registryProbes < REGISTRY_PROBE_BUDGET) {
749
+ registryProbes++;
750
+ const probed = probe(name, version);
751
+ const outcome: Omit<ProbeOutcome, 'attempt'> = typeof probed === 'boolean'
752
+ ? { ok: probed, stdout: '', stderr: '', code: null, ms: 0 }
753
+ : probed;
754
+ probeLog.push({ attempt: registryProbes, ...outcome });
755
+ if (outcome.ok) {
756
+ confirmed = true;
757
+ break;
758
+ }
759
+ if (registryProbes < REGISTRY_PROBE_BUDGET) sleep(REGISTRY_PROBE_INTERVAL_MS);
760
+ }
761
+ if (!confirmed) {
762
+ const last = probeLog[probeLog.length - 1]!;
763
+ const output = last.stderr || last.stdout;
764
+ const firstLine = output.split(/\r?\n/, 1)[0]?.trim() || '(empty)';
765
+ throw new Error(
766
+ `registry did not confirm ${name}@${version} after ${registryProbes} probes ` +
767
+ `(${Math.round(registryProbes * REGISTRY_PROBE_INTERVAL_MS / 60_000)} min); ` +
768
+ `last probe: code ${String(last.code)}, ${last.ms}ms, ${firstLine}`,
769
+ );
770
+ }
771
+ return { registryProbes };
772
+ }
773
+
774
+ function sha256File(path: string): string {
775
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
776
+ }
777
+
668
778
  for (const pkg of ordered) {
669
779
  const oldVersion = pkg.version;
670
780
  const pkgJsonPath = join(pkg.dir, 'package.json');
@@ -886,6 +996,60 @@ export function publishPackages(
886
996
  }
887
997
  }
888
998
 
999
+ if (opts.packedTransport !== undefined) {
1000
+ // AM-1: pack ONCE, from a package.json whose workspace: specs are already resolved to each
1001
+ // sibling's PINNED version — the SAME transformation `rewriteWorkspaceSpecs` performs — so
1002
+ // the tarball about to be smoked is exactly what `npm publish <tgzPath>` ships. Lifecycle
1003
+ // scripts already ran during the `build` step above; `prepublishOnly` is dropped here to
1004
+ // mirror what pnpm itself strips at pack time (the manifest-freshness guard, cli.ts, does
1005
+ // the identical transformation for verification — this is that same convention, now used
1006
+ // to actually PRODUCE the artifact rather than merely check one).
1007
+ const bumpedText = readFileSync(pkgJsonPath, 'utf-8'); // already carries newVersion
1008
+ const rewritten = JSON.parse(rewriteWorkspaceSpecs(bumpedText, pinVersions)) as Record<string, unknown>;
1009
+ const scripts = rewritten['scripts'];
1010
+ if (scripts !== null && typeof scripts === 'object' && !Array.isArray(scripts)) {
1011
+ delete (scripts as Record<string, unknown>)['prepublishOnly'];
1012
+ }
1013
+ const stagedText = JSON.stringify(rewritten, null, 2) + '\n';
1014
+ let tgzPath: string;
1015
+ let digest: string;
1016
+ writeFileSync(pkgJsonPath, stagedText);
1017
+ try {
1018
+ const packOptions = { cwd: pkg.dir, stdio: 'pipe' as const, encoding: 'utf-8' as const };
1019
+ if (opts.exec) opts.exec(`npm pack . --pack-destination ${JSON.stringify(opts.packedTransport.packDestDir)}`, packOptions);
1020
+ else execSync(`npm pack . --pack-destination ${JSON.stringify(opts.packedTransport.packDestDir)}`, packOptions);
1021
+ tgzPath = join(opts.packedTransport.packDestDir, packedTarballName(pkg.name, newVersion));
1022
+ digest = sha256File(tgzPath);
1023
+ } finally {
1024
+ // The COMMITTED tree keeps `workspace:` specs (only the version bump is meant to stick) —
1025
+ // the rewrite above is packing-only and is undone here regardless of pack's outcome.
1026
+ writeFileSync(pkgJsonPath, bumpedText);
1027
+ }
1028
+ pendingPacked.push({
1029
+ name: pkg.name,
1030
+ dir: pkg.dir,
1031
+ oldVersion,
1032
+ newVersion,
1033
+ tgzPath,
1034
+ sha256: digest,
1035
+ pkgJsonPath,
1036
+ originalPkgJson,
1037
+ readmePath: pathJoin(pkg.dir, 'README.md'),
1038
+ originalReadme,
1039
+ claimCheckSummary,
1040
+ });
1041
+ pinVersions.set(pkg.name, newVersion);
1042
+ // Optimistic, mirroring the dry-run branch above: this package WILL land once the
1043
+ // batch-wide smoke (after the loop) passes — a dependent packed later in this same batch
1044
+ // must not re-probe its floor on the registry for an artifact that simply hasn't
1045
+ // published YET (a staged disk version is not a shipped one — but a PACKED one, pending a
1046
+ // batch-wide smoke that the dependent itself is also waiting on, is not "unpublished" in
1047
+ // the sense this preflight polices). Corrected back to `failedInBatch` after the loop if
1048
+ // the smoke actually rejects the batch.
1049
+ landedInBatch.add(pkg.name);
1050
+ continue;
1051
+ }
1052
+
889
1053
  // Publish
890
1054
  const publishOptions = {
891
1055
  cwd: pkg.dir,
@@ -896,31 +1060,7 @@ export function publishPackages(
896
1060
  if (opts.exec) opts.exec(publishCmd, publishOptions);
897
1061
  else execSync(publishCmd, publishOptions);
898
1062
 
899
- let registryProbes = 0;
900
- let confirmed = false;
901
- while (registryProbes < REGISTRY_PROBE_BUDGET) {
902
- registryProbes++;
903
- const probed = probe(pkg.name, newVersion);
904
- const outcome: Omit<ProbeOutcome, 'attempt'> = typeof probed === 'boolean'
905
- ? { ok: probed, stdout: '', stderr: '', code: null, ms: 0 }
906
- : probed;
907
- probeLog.push({ attempt: registryProbes, ...outcome });
908
- if (outcome.ok) {
909
- confirmed = true;
910
- break;
911
- }
912
- if (registryProbes < REGISTRY_PROBE_BUDGET) sleep(REGISTRY_PROBE_INTERVAL_MS);
913
- }
914
- if (!confirmed) {
915
- const last = probeLog[probeLog.length - 1]!;
916
- const output = last.stderr || last.stdout;
917
- const firstLine = output.split(/\r?\n/, 1)[0]?.trim() || '(empty)';
918
- throw new Error(
919
- `registry did not confirm ${pkg.name}@${newVersion} after ${registryProbes} probes ` +
920
- `(${Math.round(registryProbes * REGISTRY_PROBE_INTERVAL_MS / 60_000)} min); ` +
921
- `last probe: code ${String(last.code)}, ${last.ms}ms, ${firstLine}`,
922
- );
923
- }
1063
+ const { registryProbes } = confirmPublished(pkg.name, newVersion, probeLog);
924
1064
 
925
1065
  results.push({ name: pkg.name, oldVersion, newVersion, status: 'published', registryProbes, probeLog, claimCheck: claimCheckSummary });
926
1066
  landedInBatch.add(pkg.name); // only an ACTUAL publish covers dependents (Codex P1)
@@ -946,6 +1086,121 @@ export function publishPackages(
946
1086
  }
947
1087
  }
948
1088
 
1089
+ // AM-1 (packedTransport, pass 2): every packable package in the batch has now been bumped,
1090
+ // built, signed and packed into a real tarball — nothing has been published yet. Judge the
1091
+ // WHOLE batch together (the packed-install smoke needs every batch tarball installed at once,
1092
+ // exactly like a fresh user would receive them) BEFORE any of them ships.
1093
+ if (opts.packedTransport !== undefined && pendingPacked.length > 0) {
1094
+ // Lead edit after Codex re-review (finding 1): the transport is ONE transaction — a batch that
1095
+ // lost any package before packing (an earlier error) is never smoked or published partially, and
1096
+ // a smoke that THROWS rolls every pending package back exactly like a failed verdict.
1097
+ const rollbackAll = (reason: string): void => {
1098
+ for (const p of pendingPacked) {
1099
+ try { writeFileSync(p.pkgJsonPath, p.originalPkgJson); } catch { /* best-effort restore */ }
1100
+ if (p.originalReadme !== undefined) {
1101
+ try { writeFileSync(p.readmePath, p.originalReadme); } catch { /* best-effort restore */ }
1102
+ }
1103
+ results.push({
1104
+ name: p.name, oldVersion: p.oldVersion, newVersion: p.newVersion, status: 'error',
1105
+ error: reason, claimCheck: p.claimCheckSummary,
1106
+ });
1107
+ failedInBatch.add(p.name);
1108
+ landedInBatch.delete(p.name);
1109
+ }
1110
+ pendingPacked.length = 0;
1111
+ };
1112
+ if (failedInBatch.size > 0) {
1113
+ rollbackAll(`batch incomplete before smoke (${[...failedInBatch].join(', ')} failed earlier) — nothing published`);
1114
+ }
1115
+ let smokeVerdict: { ok: boolean; reason?: string | undefined } = { ok: false, reason: 'smoke did not run' };
1116
+ if (pendingPacked.length > 0) {
1117
+ try {
1118
+ smokeVerdict = opts.packedTransport.smoke(
1119
+ pendingPacked.map((p) => ({ name: p.name, newVersion: p.newVersion, tgzPath: p.tgzPath, sha256: p.sha256 })),
1120
+ );
1121
+ } catch (err) {
1122
+ smokeVerdict = { ok: false, reason: `smoke threw: ${formatPublishError(err)}` };
1123
+ }
1124
+ }
1125
+ if (pendingPacked.length === 0) {
1126
+ /* already rolled back above */
1127
+ } else if (!smokeVerdict.ok) {
1128
+ for (const p of pendingPacked) {
1129
+ try { writeFileSync(p.pkgJsonPath, p.originalPkgJson); } catch { /* best-effort restore */ }
1130
+ if (p.originalReadme !== undefined) {
1131
+ try { writeFileSync(p.readmePath, p.originalReadme); } catch { /* best-effort restore */ }
1132
+ }
1133
+ results.push({
1134
+ name: p.name,
1135
+ oldVersion: p.oldVersion,
1136
+ newVersion: p.newVersion,
1137
+ status: 'error',
1138
+ error: `packed install smoke failed: ${smokeVerdict.reason ?? '(no detail)'}`,
1139
+ claimCheck: p.claimCheckSummary,
1140
+ });
1141
+ failedInBatch.add(p.name);
1142
+ landedInBatch.delete(p.name); // correct the pass-1 optimistic assumption
1143
+ }
1144
+ } else {
1145
+ let transportFailed = false;
1146
+ for (const p of pendingPacked) {
1147
+ const probeLog: ProbeOutcome[] = [];
1148
+ // Lead edit after Codex re-review (finding 2): after the first transport/receipt failure the
1149
+ // REST of the batch is not published — a dependant must never land on top of a failed sibling.
1150
+ if (transportFailed) {
1151
+ try { writeFileSync(p.pkgJsonPath, p.originalPkgJson); } catch { /* best-effort restore */ }
1152
+ if (p.originalReadme !== undefined) {
1153
+ try { writeFileSync(p.readmePath, p.originalReadme); } catch { /* best-effort restore */ }
1154
+ }
1155
+ results.push({
1156
+ name: p.name, oldVersion: p.oldVersion, newVersion: p.newVersion, status: 'error',
1157
+ error: 'skipped: an earlier package in this batch failed to publish — dependants are not published on top of a failed sibling',
1158
+ claimCheck: p.claimCheckSummary,
1159
+ });
1160
+ failedInBatch.add(p.name);
1161
+ landedInBatch.delete(p.name);
1162
+ continue;
1163
+ }
1164
+ try {
1165
+ // Defends the "same bytes" claim against anything that could touch the tarball between
1166
+ // the pack step and this publish call (AM-1: "a digest mismatch between smoke and
1167
+ // publish is BLOCKED", made a real, checked code path rather than an architectural
1168
+ // argument that the two steps happen to read the same file).
1169
+ const currentDigest = sha256File(p.tgzPath);
1170
+ if (currentDigest !== p.sha256) {
1171
+ throw new Error(
1172
+ `tarball digest changed between smoke and publish for ${p.name}@${p.newVersion} ` +
1173
+ `(smoked ${p.sha256}, about to publish ${currentDigest}) — refusing`,
1174
+ );
1175
+ }
1176
+ const publishOptions = { cwd: p.dir, stdio: 'pipe' as const, encoding: 'utf-8' as const, env: { ...process.env } };
1177
+ const npmPublishCmd = `npm publish ${JSON.stringify(p.tgzPath)} --access public${publishCmd.includes('--provenance') ? ' --provenance' : ''}`;
1178
+ if (opts.exec) opts.exec(npmPublishCmd, publishOptions);
1179
+ else execSync(npmPublishCmd, publishOptions);
1180
+
1181
+ const { registryProbes } = confirmPublished(p.name, p.newVersion, probeLog);
1182
+ results.push({
1183
+ name: p.name, oldVersion: p.oldVersion, newVersion: p.newVersion, status: 'published',
1184
+ registryProbes, probeLog, claimCheck: p.claimCheckSummary, sha256: p.sha256,
1185
+ });
1186
+ // landedInBatch already carries p.name from pass 1 (optimistic) — now confirmed for real.
1187
+ } catch (err) {
1188
+ try { writeFileSync(p.pkgJsonPath, p.originalPkgJson); } catch { /* best-effort restore */ }
1189
+ if (p.originalReadme !== undefined) {
1190
+ try { writeFileSync(p.readmePath, p.originalReadme); } catch { /* best-effort restore */ }
1191
+ }
1192
+ results.push({
1193
+ name: p.name, oldVersion: p.oldVersion, newVersion: p.newVersion, status: 'error',
1194
+ error: formatPublishError(err), ...(probeLog.length > 0 ? { probeLog } : {}), claimCheck: p.claimCheckSummary,
1195
+ });
1196
+ failedInBatch.add(p.name);
1197
+ landedInBatch.delete(p.name);
1198
+ transportFailed = true;
1199
+ }
1200
+ }
1201
+ }
1202
+ }
1203
+
949
1204
  const releaseLineSynced: string[] = [];
950
1205
  const warnings: string[] = [];
951
1206
  const releasePackageNames = new Set(['@dzhechkov/harness-core', '@dzhechkov/harness-cli']);
package/src/qe-bridge.ts CHANGED
@@ -642,6 +642,19 @@ export interface BridgeAudit {
642
642
  /** sha256 of the exact prompt sent, so the digest in the signoff can be recomputed. */
643
643
  promptSha256: string | null;
644
644
  channels?: BridgeChannels;
645
+ /**
646
+ * Diagnostic sequencing metadata (qe-bridge-signoff-order): a self-reported, process-local trace of
647
+ * the write steps with monotonic `process.hrtime.bigint()` stamps, taken at each named event — the
648
+ * first and third are START/PREPARATION stamps (a record cannot carry the completion time of its
649
+ * own write), the second is taken AFTER the report landed; the names say so:
650
+ * `signoff-write-started` (first record write), `report-written` (after the report is on disk),
651
+ * `record-update-prepared` (right before the atomic tmp+rename that persists `reportWritten:true`;
652
+ * a leftover `.tmp` carrying it is not a record). Order and crash guarantees are NOT proven by this
653
+ * field — the first leg (record before report) is proven by the report-failure test, the second
654
+ * (`reportWritten:true` only after the report) by the failpoint test (hang-before-rename, R4-1);
655
+ * this field replaces a wall-clock mtime comparison that was a race (teach:296e5fcd).
656
+ */
657
+ writeSequence?: ReadonlyArray<{ step: 'signoff-write-started' | 'report-written' | 'record-update-prepared'; monotonicNs: string }>;
645
658
  }
646
659
 
647
660
  /**
@@ -683,6 +696,7 @@ export function buildBridgeSignoffRecord(signoff: BridgeSignoff, audit: BridgeAu
683
696
  reportWritten: audit.reportWritten,
684
697
  rawStdoutFile: audit.rawStdoutFile,
685
698
  ...(audit.channels === undefined ? {} : { channels: audit.channels }),
699
+ ...(audit.writeSequence === undefined ? {} : { writeSequence: audit.writeSequence }),
686
700
  };
687
701
  }
688
702
 
package/src/release.ts CHANGED
@@ -23,9 +23,11 @@
23
23
 
24
24
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
25
25
  import type { Dirent } from 'node:fs';
26
- import { join } from 'node:path';
26
+ import { join, relative } from 'node:path';
27
27
 
28
28
  import { discoverPackages, orderByDependencies } from './publish.js';
29
+ import { planPackedInstallSmoke, judgePackedInstallSmoke } from './packed-install-smoke.js';
30
+ import type { PackedInstallPlan, PackedInstallExecution } from './packed-install-smoke.js';
29
31
 
30
32
  /** The four HARD verify gates, in execution order. */
31
33
  export type ReleaseGateId = 'tests' | 'audit' | 'syntax' | 'smoke';
@@ -114,6 +116,15 @@ export interface GatePlan {
114
116
  readonly skips: readonly GateSkip[];
115
117
  /** Package names in the release set (dependency order). */
116
118
  readonly packages: readonly string[];
119
+ /**
120
+ * AM-7 (feature publish-sibling-drift-gate): the packed-install sub-plan, carried through so
121
+ * {@link classifyGateExecutions} can re-judge its `bin-version`/`bin-exists` steps through the
122
+ * SAME judge `dz publish` uses (`judgePackedInstallSmoke`: exit 0 AND non-empty stdout, AND the
123
+ * declared bin must exist post-install) instead of the generic exit-code-only check every other
124
+ * step gets. Without this, a silently no-op bin could pass `dz release` while `dz publish`
125
+ * refuses it — the two doors would not be equal, contradicting FR-6's own claim.
126
+ */
127
+ readonly packedInstallPlan?: PackedInstallPlan | undefined;
117
128
  }
118
129
 
119
130
  /** The CLI's record of running one exec step. */
@@ -303,6 +314,12 @@ export interface PlanReleaseGatesOptions {
303
314
  readonly auditTimeoutMs?: number | undefined;
304
315
  readonly syntaxTimeoutMs?: number | undefined;
305
316
  readonly smokeTimeoutMs?: number | undefined;
317
+ /**
318
+ * FR-6 (feature publish-sibling-drift-gate): real tmp dirs for the packed-install smoke,
319
+ * supplied by the CLI (planning stays pure — it never calls mkdtemp itself). Omitted ⇒ no
320
+ * packed-install steps are planned, byte-identical to the pre-feature behavior.
321
+ */
322
+ readonly packedInstall?: { readonly packDir: string; readonly installDir: string } | undefined;
306
323
  }
307
324
 
308
325
  /**
@@ -481,7 +498,50 @@ export function planReleaseGates(facts: readonly ReleasePackageFacts[], opts: Pl
481
498
  }
482
499
  }
483
500
 
484
- return { steps, skips, packages: facts.map((f) => f.name) };
501
+ // FR-6 (feature publish-sibling-drift-gate): the packed-install smoke joins the SAME smoke
502
+ // gate `dz publish` runs (planPackedInstallSmoke, ADR-001 Decision 2) — so both doors apply
503
+ // the identical rule. Opt-in via `opts.packedInstall` (real tmp dirs, supplied by the CLI):
504
+ // omitted, this is byte-identical to the pre-feature plan, which every existing planner test
505
+ // relies on. Skipped entirely when nothing in the batch has a bin — packing siblings nobody
506
+ // will boot proves nothing a fresh `npm install` doesn't already cover elsewhere.
507
+ let packedInstallPlan: PackedInstallPlan | undefined;
508
+ if (opts.packedInstall !== undefined) {
509
+ const bins = facts.flatMap((f) =>
510
+ f.bins.map((b) => ({ pkg: f.name, binName: b.name, relPath: relative(f.dir, b.path) })),
511
+ );
512
+ if (bins.length > 0) {
513
+ const packages = facts.map((f) => ({ name: f.name, dir: f.dir, version: f.version }));
514
+ const smokePlan = planPackedInstallSmoke({
515
+ packages,
516
+ bins,
517
+ packDir: opts.packedInstall.packDir,
518
+ installDir: opts.packedInstall.installDir,
519
+ });
520
+ packedInstallPlan = smokePlan; // AM-7: kept for classifyGateExecutions's re-judge pass
521
+ for (const s of smokePlan.steps) {
522
+ const reason =
523
+ s.kind === 'pack'
524
+ ? `pack ${s.pkg} for the packed-install smoke — the tarball a consumer would actually receive`
525
+ : s.kind === 'install'
526
+ ? 'install every packed tarball together in a clean dir — out-of-batch siblings resolve from the registry, exactly like a fresh user'
527
+ : s.kind === 'bin-exists'
528
+ ? `bin "${s.binName}" must EXIST after the packed install (AM-8)`
529
+ : `bin "${s.binName}" must boot from the PACKED install (--version, exit 0, non-empty stdout)`;
530
+ steps.push({
531
+ id: `smoke:packed-install:${s.id}`,
532
+ gate: 'smoke',
533
+ pkg: s.pkg,
534
+ cmd: s.cmd,
535
+ cwd: s.cwd,
536
+ timeoutMs: s.timeoutMs,
537
+ reason,
538
+ kind: 'exec',
539
+ });
540
+ }
541
+ }
542
+ }
543
+
544
+ return { steps, skips, packages: facts.map((f) => f.name), ...(packedInstallPlan !== undefined ? { packedInstallPlan } : {}) };
485
545
  }
486
546
 
487
547
  /* ------------------------------------------------------------------ */
@@ -559,6 +619,30 @@ export function classifyGateExecutions(
559
619
  if (e != null && typeof e.stepId === 'string') byId.set(e.stepId, e);
560
620
  }
561
621
 
622
+ // AM-7: `bin-exists`/`bin-version` packed-install steps are judged through
623
+ // `judgePackedInstallSmoke` — the SAME rule `dz publish` applies (exit 0 AND non-empty stdout,
624
+ // AND the declared bin must exist post-install) — instead of the generic exit-code-only check
625
+ // every other step gets. The generic loop below SKIPS these step ids; the judged verdict is
626
+ // folded into the 'smoke' gate's failures/passed count after the loop.
627
+ const packedInstallPlan = plan?.packedInstallPlan;
628
+ const packedInstallBinStepIds = new Set(
629
+ (packedInstallPlan?.steps ?? []).filter((s) => s.kind === 'bin-exists' || s.kind === 'bin-version').map((s) => `smoke:packed-install:${s.id}`),
630
+ );
631
+ let packedInstallVerdict: ReturnType<typeof judgePackedInstallSmoke> | undefined;
632
+ if (packedInstallPlan !== undefined) {
633
+ const prefix = 'smoke:packed-install:';
634
+ const translated: PackedInstallExecution[] = (executions ?? [])
635
+ .filter((e): e is GateExecution => e != null && typeof e.stepId === 'string' && e.stepId.startsWith(prefix))
636
+ .map((e) => ({
637
+ stepId: e.stepId.slice(prefix.length),
638
+ exitCode: e.exitCode,
639
+ stdout: e.stdout ?? '',
640
+ stderr: e.stderr ?? '',
641
+ ...(e.timedOut !== undefined ? { timedOut: e.timedOut } : {}),
642
+ }));
643
+ packedInstallVerdict = judgePackedInstallSmoke(packedInstallPlan, translated);
644
+ }
645
+
562
646
  const gates: GateResult[] = RELEASE_GATE_ORDER.map((gate) => {
563
647
  const gateSteps = (plan?.steps ?? []).filter((s) => s?.gate === gate);
564
648
  const gateSkips = (plan?.skips ?? []).filter((s) => s?.gate === gate);
@@ -567,6 +651,7 @@ export function classifyGateExecutions(
567
651
 
568
652
  for (const step of gateSteps) {
569
653
  try {
654
+ if (packedInstallBinStepIds.has(step.id)) continue; // judged separately below (AM-7)
570
655
  if (step.kind === 'synthetic-fail') {
571
656
  failures.push({ pkg: step.pkg, reason: step.reason, class: step.failClass ?? 'EXIT_NONZERO' });
572
657
  continue;
@@ -609,6 +694,22 @@ export function classifyGateExecutions(
609
694
  }
610
695
  }
611
696
 
697
+ // AM-7: fold the packed-install bin verdicts (judged via judgePackedInstallSmoke, above) into
698
+ // the 'smoke' gate — the ONLY gate that ever plans packed-install steps.
699
+ if (gate === 'smoke' && packedInstallVerdict !== undefined) {
700
+ for (const bin of packedInstallVerdict.bins) {
701
+ if (bin.ok) {
702
+ passed += 1;
703
+ } else {
704
+ failures.push({
705
+ pkg: bin.pkg,
706
+ reason: `packed-install bin "${bin.binName}" ${bin.detail ?? 'failed'}`,
707
+ class: 'EXIT_NONZERO',
708
+ });
709
+ }
710
+ }
711
+ }
712
+
612
713
  const status: GateResult['status'] = failures.length > 0 ? 'fail' : passed > 0 ? 'pass' : 'skip';
613
714
  return { gate, status, passed, failures, skips: gateSkips };
614
715
  });