@dzhechkov/harness-core 0.8.32 → 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.
- package/.dz-manifest.json +97 -37
- package/README.md +30 -0
- package/dist/apply-leg.d.ts +13 -1
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +123 -10
- package/dist/apply-leg.js.map +1 -1
- package/dist/embed-socket-path.d.ts +65 -0
- package/dist/embed-socket-path.d.ts.map +1 -0
- package/dist/embed-socket-path.js +100 -0
- package/dist/embed-socket-path.js.map +1 -0
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/operations.d.ts.map +1 -1
- package/dist/operations.js +66 -3
- package/dist/operations.js.map +1 -1
- package/dist/packed-install-smoke.d.ts +108 -0
- package/dist/packed-install-smoke.d.ts.map +1 -0
- package/dist/packed-install-smoke.js +172 -0
- package/dist/packed-install-smoke.js.map +1 -0
- package/dist/publish-sibling-drift.d.ts +67 -0
- package/dist/publish-sibling-drift.d.ts.map +1 -0
- package/dist/publish-sibling-drift.js +262 -0
- package/dist/publish-sibling-drift.js.map +1 -0
- package/dist/publish.d.ts +44 -0
- package/dist/publish.d.ts.map +1 -1
- package/dist/publish.js +243 -24
- package/dist/publish.js.map +1 -1
- package/dist/qe-bridge.d.ts +16 -0
- package/dist/qe-bridge.d.ts.map +1 -1
- package/dist/qe-bridge.js +1 -0
- package/dist/qe-bridge.js.map +1 -1
- package/dist/release.d.ts +19 -0
- package/dist/release.d.ts.map +1 -1
- package/dist/release.js +81 -2
- package/dist/release.js.map +1 -1
- package/dist/setup.d.ts +36 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +96 -2
- package/dist/setup.js.map +1 -1
- package/package.json +23 -23
- package/sbom.json +186 -36
- package/src/apply-leg.ts +123 -10
- package/src/embed-socket-path.ts +113 -0
- package/src/index.ts +35 -2
- package/src/operations.ts +67 -3
- package/src/packed-install-smoke.ts +273 -0
- package/src/publish-sibling-drift.ts +293 -0
- package/src/publish.ts +280 -25
- package/src/qe-bridge.ts +14 -0
- package/src/release.ts +103 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
});
|