prismatica 0.2.0 → 0.4.0
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/README.md +20 -8
- package/dist/assurance/mainpush.js +383 -8
- package/dist/assurance/mainpush.js.map +1 -1
- package/dist/assurance/proofinputs.js +62 -34
- package/dist/assurance/proofinputs.js.map +1 -1
- package/dist/atlas/labels.js +2 -2
- package/dist/atlas/labels.js.map +1 -1
- package/dist/atlas/next.js +120 -18
- package/dist/atlas/next.js.map +1 -1
- package/dist/board/html.js +37 -9
- package/dist/board/html.js.map +1 -1
- package/dist/board/model.js +58 -14
- package/dist/board/model.js.map +1 -1
- package/dist/cli.js +70 -6
- package/dist/cli.js.map +1 -1
- package/dist/commands/delta.js +84 -1
- package/dist/commands/delta.js.map +1 -1
- package/dist/commands/doctor.js +55 -11
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/frame.js +13 -1
- package/dist/commands/frame.js.map +1 -1
- package/dist/commands/guide.js +17 -5
- package/dist/commands/guide.js.map +1 -1
- package/dist/commands/next.js +196 -26
- package/dist/commands/next.js.map +1 -1
- package/dist/commands/release.js +950 -0
- package/dist/commands/release.js.map +1 -0
- package/dist/commands/ship.js +150 -15
- package/dist/commands/ship.js.map +1 -1
- package/dist/commands/start.js +5 -2
- package/dist/commands/start.js.map +1 -1
- package/dist/commands/update.js +435 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/detect.js +40 -10
- package/dist/detect.js.map +1 -1
- package/dist/executor.js +15 -0
- package/dist/executor.js.map +1 -1
- package/dist/git.js +27 -0
- package/dist/git.js.map +1 -1
- package/dist/guide.js +254 -33
- package/dist/guide.js.map +1 -1
- package/dist/paths.js +7 -0
- package/dist/paths.js.map +1 -1
- package/dist/planning/pack.js +12 -7
- package/dist/planning/pack.js.map +1 -1
- package/dist/planning/schema.js +17 -1
- package/dist/planning/schema.js.map +1 -1
- package/dist/prepush.js +52 -0
- package/dist/prepush.js.map +1 -1
- package/dist/records/store.js +17 -1
- package/dist/records/store.js.map +1 -1
- package/dist/records/types.js +122 -7
- package/dist/records/types.js.map +1 -1
- package/dist/releases.js +287 -0
- package/dist/releases.js.map +1 -0
- package/dist/render.js +23 -0
- package/dist/render.js.map +1 -1
- package/dist/title.js +69 -0
- package/dist/title.js.map +1 -0
- package/package.json +5 -1
- package/dist/agent-runner.js +0 -656
- package/dist/agent-runner.js.map +0 -1
- package/dist/atlas/packs.js +0 -169
- package/dist/atlas/packs.js.map +0 -1
|
@@ -0,0 +1,950 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `release prepare` / `release publish` — the owner-only, deterministic npm
|
|
3
|
+
* release lifecycle. Both commands re-derive their next action from durable
|
|
4
|
+
* state (Git, the npm registry, records) on every invocation — never a
|
|
5
|
+
* stored step counter — by orchestrating real git/npm/gh calls around the
|
|
6
|
+
* pure decision tables in `releases.ts`.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import os from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { execa } from 'execa';
|
|
13
|
+
import { checkStillBinds, isCheckEvidenceFile, laneRelative, nonExemptDirtyPaths } from '../assurance/proofinputs.js';
|
|
14
|
+
import { assessImpact, reportGaps } from '../atlas/impact.js';
|
|
15
|
+
import { listFlows } from '../atlas/store.js';
|
|
16
|
+
import { listOpenChanges } from '../change.js';
|
|
17
|
+
import { loadConfig } from '../config.js';
|
|
18
|
+
import { UserError } from '../errors.js';
|
|
19
|
+
import * as git from '../git.js';
|
|
20
|
+
import { readLedger } from '../ledger.js';
|
|
21
|
+
import { ledgerPath } from '../paths.js';
|
|
22
|
+
import { contractPath, listContractIds, readContract, readFlowReport, releaseExists, releasePath, reportExists, writeContract, writeRelease, } from '../records/store.js';
|
|
23
|
+
import { ReleaseSchema } from '../records/types.js';
|
|
24
|
+
import { renderContractBody, renderReleaseBody } from '../render.js';
|
|
25
|
+
import { classifyPreflight, classifySupersede, classifyVersionBumpDiff, deriveReleaseNextAction, resolveTargetVersion, } from '../releases.js';
|
|
26
|
+
import { prismaticaVersion } from '../runtime.js';
|
|
27
|
+
import * as ui from '../ui.js';
|
|
28
|
+
import { runCheck } from './check.js';
|
|
29
|
+
import { runFlowReport } from './flow.js';
|
|
30
|
+
import { runFrame } from './frame.js';
|
|
31
|
+
import { runShip } from './ship.js';
|
|
32
|
+
function readPackageJson(dir) {
|
|
33
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
34
|
+
if (typeof parsed.name !== 'string' || typeof parsed.version !== 'string') {
|
|
35
|
+
throw new UserError(`${path.join(dir, 'package.json')} has no readable name/version.`);
|
|
36
|
+
}
|
|
37
|
+
return { name: parsed.name, version: parsed.version };
|
|
38
|
+
}
|
|
39
|
+
// ── Lane bootstrap: make a fresh release lane's own tools resolvable ────────
|
|
40
|
+
//
|
|
41
|
+
// `git worktree add` never carries `node_modules` — it is gitignored, so a
|
|
42
|
+
// fresh release lane has none, and Check's configured commands (`npm run
|
|
43
|
+
// typecheck`, `npm run build`, …) resolve nothing until dependencies exist.
|
|
44
|
+
// Deterministic, once: an existing `node_modules` is trusted as-is (never
|
|
45
|
+
// reinstalled); an absent one is installed from the committed lockfile via
|
|
46
|
+
// `npm ci` — the only reproducible install — and no lockfile is a fail-closed
|
|
47
|
+
// refusal, never a silent `npm install` fallback that would let a release
|
|
48
|
+
// resolve fresh, unpinned dependency versions.
|
|
49
|
+
/** Exported so `releasebootstrap.test.ts` can prove this against a real, un-bootstrapped checkout. */
|
|
50
|
+
export async function ensureLaneDependencies(home) {
|
|
51
|
+
if (fs.existsSync(path.join(home, 'node_modules'))) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!fs.existsSync(path.join(home, 'package-lock.json'))) {
|
|
55
|
+
throw new UserError(`${home} has no node_modules and no package-lock.json — deterministic release bootstrap requires a committed lockfile.`, {
|
|
56
|
+
command: 'npm install',
|
|
57
|
+
why: '`npm ci` is the only reproducible install; run `npm install` once to generate package-lock.json, commit it, and rerun `release prepare` — never a silent `npm install` fallback here',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const result = await execa('npm', ['ci'], { cwd: home, reject: false, all: true });
|
|
61
|
+
if (result.failed) {
|
|
62
|
+
throw new UserError(`\`npm ci\` failed to bootstrap ${home}: ${(result.all || result.stderr || result.stdout).trim()}`, {
|
|
63
|
+
command: 'npm ci',
|
|
64
|
+
why: 'the lane must be installable before Check can run the repo’s real typecheck/lint/unit/build commands',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// ── The npm registry seam ────────────────────────────────────────────────
|
|
69
|
+
//
|
|
70
|
+
// Minimal, real npm CLI queries. Deliberately not imported by
|
|
71
|
+
// `assurance/mainpush.ts` (nor the reverse) — see that module's own comment
|
|
72
|
+
// on why the two literal copies exist.
|
|
73
|
+
/** npm's own genuine-404 wording — anything else (offline, rate-limited, malformed) is uncertain. */
|
|
74
|
+
const NPM_NOT_FOUND = /E404|404 Not Found|is not in (?:this|the) registry/i;
|
|
75
|
+
async function npmPublicIntegrity(pkgName, version) {
|
|
76
|
+
const result = await execa('npm', ['view', `${pkgName}@${version}`, 'dist.integrity', '--json'], {
|
|
77
|
+
reject: false,
|
|
78
|
+
});
|
|
79
|
+
if (result.failed) {
|
|
80
|
+
return NPM_NOT_FOUND.test(`${result.stderr}${result.stdout}`) ? { state: 'absent' } : { state: 'uncertain' };
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(result.stdout);
|
|
84
|
+
return typeof parsed === 'string' && parsed.length > 0 ? { state: 'present', integrity: parsed } : { state: 'uncertain' };
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return { state: 'uncertain' };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function parseVersionsJson(stdout) {
|
|
91
|
+
try {
|
|
92
|
+
const parsed = JSON.parse(stdout);
|
|
93
|
+
if (typeof parsed === 'string') {
|
|
94
|
+
return [parsed]; // npm collapses a single published version to a bare string
|
|
95
|
+
}
|
|
96
|
+
return Array.isArray(parsed) && parsed.every((v) => typeof v === 'string') ? parsed : null;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function npmDistTags(pkgName) {
|
|
103
|
+
const result = await execa('npm', ['view', pkgName, 'dist-tags', '--json'], { reject: false });
|
|
104
|
+
if (result.failed) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(result.stdout);
|
|
109
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
const entries = Object.entries(parsed);
|
|
113
|
+
return entries.every(([, v]) => typeof v === 'string') ? parsed : null;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** `null` = could not be answered. Fails closed on any entry it cannot read id/version from. */
|
|
120
|
+
async function npmStagedCandidates(pkgName) {
|
|
121
|
+
const result = await execa('npm', ['stage', 'list', pkgName, '--json'], { reject: false });
|
|
122
|
+
if (result.failed) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
let parsed;
|
|
126
|
+
try {
|
|
127
|
+
parsed = JSON.parse(result.stdout);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
if (!Array.isArray(parsed)) {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const candidates = [];
|
|
136
|
+
for (const entry of parsed) {
|
|
137
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const e = entry;
|
|
141
|
+
if (typeof e.id !== 'string' || e.id.length === 0 || typeof e.version !== 'string' || e.version.length === 0) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
candidates.push({ id: e.id, version: e.version, tag: typeof e.tag === 'string' ? e.tag : undefined });
|
|
145
|
+
}
|
|
146
|
+
return candidates;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The one registry probe both `prepare` and `publish` build their facts from.
|
|
150
|
+
* The FIRST query (public versions) is the reachability probe: an outright
|
|
151
|
+
* failure (network/DNS/registry down) means unreachable; a successful call
|
|
152
|
+
* whose shape cannot be trusted, or a later query that separately fails,
|
|
153
|
+
* surfaces as `null` in its own field — "uncertain", never "unreachable".
|
|
154
|
+
*/
|
|
155
|
+
async function queryRegistry(pkgName) {
|
|
156
|
+
const versionsResult = await execa('npm', ['view', pkgName, 'versions', '--json'], { reject: false });
|
|
157
|
+
if (versionsResult.failed) {
|
|
158
|
+
return { reachable: false, publicVersions: null, distTags: null, stagedCandidates: null };
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
reachable: true,
|
|
162
|
+
publicVersions: parseVersionsJson(versionsResult.stdout),
|
|
163
|
+
distTags: await npmDistTags(pkgName),
|
|
164
|
+
stagedCandidates: await npmStagedCandidates(pkgName),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** ~25s bounded wait: enough for ordinary registry propagation lag, never unbounded. */
|
|
168
|
+
const DEFAULT_POLL = { attempts: 6, delayMs: 5000 };
|
|
169
|
+
async function sleep(ms) {
|
|
170
|
+
if (ms > 0) {
|
|
171
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// ── The release workflow's own run, identified deterministically ──────────
|
|
175
|
+
//
|
|
176
|
+
// `prismatica-release.yml` declares `run-name: "Release ${{
|
|
177
|
+
// github.event.inputs.version }}"`, so `gh run list`'s `displayTitle` names
|
|
178
|
+
// exactly the version it was dispatched for — a rerun can tell "a run THIS
|
|
179
|
+
// release already triggered" apart from any other run of this workflow,
|
|
180
|
+
// never guessing from "whatever ran most recently".
|
|
181
|
+
/** Exported so a test can prove this matches `prismatica-release.yml`'s own `run-name:` — two independent copies of the same format would silently drift apart. */
|
|
182
|
+
export function releaseRunTitle(version) {
|
|
183
|
+
return `Release ${version}`;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Runs of `prismatica-release.yml` whose title matches `version`, newest
|
|
187
|
+
* first. `null` = the query could not be answered — fails closed, never
|
|
188
|
+
* "assume no run exists".
|
|
189
|
+
*/
|
|
190
|
+
async function findReleaseWorkflowRuns(mainRoot, version) {
|
|
191
|
+
const result = await execa('gh', ['run', 'list', '--workflow', 'prismatica-release.yml', '--json', 'status,conclusion,displayTitle,event,url', '-L', '30'], { cwd: mainRoot, reject: false });
|
|
192
|
+
if (result.failed) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
let parsed;
|
|
196
|
+
try {
|
|
197
|
+
parsed = JSON.parse(result.stdout);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
if (!Array.isArray(parsed)) {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
const runs = [];
|
|
206
|
+
for (const entry of parsed) {
|
|
207
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
const e = entry;
|
|
211
|
+
if (e.event !== 'workflow_dispatch' || e.displayTitle !== releaseRunTitle(version)) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (typeof e.status !== 'string') {
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
runs.push({
|
|
218
|
+
status: e.status,
|
|
219
|
+
conclusion: typeof e.conclusion === 'string' && e.conclusion.length > 0 ? e.conclusion : null,
|
|
220
|
+
url: typeof e.url === 'string' ? e.url : '',
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return runs;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Whether `dispatch-workflow` may safely fire another dispatch — re-derived
|
|
227
|
+
* from the matching run's OWN state every time, never a stored step counter.
|
|
228
|
+
* `'dispatch'`: no run this release ever triggered, or the one it did fail
|
|
229
|
+
* closed/cancelled — the documented recovery. `'proceed'`: the workflow's
|
|
230
|
+
* work (or a later approval) is already visible in the staged or public
|
|
231
|
+
* namespace — the caller re-derives its next action from fresh facts rather
|
|
232
|
+
* than this function acting on stale state itself. A queued/in-progress run,
|
|
233
|
+
* or one that succeeded but has not yet produced a visible staged candidate,
|
|
234
|
+
* is bounded-observed (never busy-looped, never waited on forever); GitHub
|
|
235
|
+
* query uncertainty is retried within the same bound, never treated as proof
|
|
236
|
+
* no run exists.
|
|
237
|
+
*/
|
|
238
|
+
async function resolveDispatchDecision(mainRoot, pkgName, version, poll) {
|
|
239
|
+
let lastRun; // undefined = never successfully queried
|
|
240
|
+
for (let attempt = 0; attempt < poll.attempts; attempt += 1) {
|
|
241
|
+
const runs = await findReleaseWorkflowRuns(mainRoot, version);
|
|
242
|
+
if (runs !== null) {
|
|
243
|
+
lastRun = runs[0] ?? null;
|
|
244
|
+
if (lastRun === null) {
|
|
245
|
+
return 'dispatch';
|
|
246
|
+
}
|
|
247
|
+
const registry = await queryRegistry(pkgName);
|
|
248
|
+
const visible = (registry.stagedCandidates?.some((candidate) => candidate.version === version) ?? false) ||
|
|
249
|
+
(registry.publicVersions?.includes(version) ?? false);
|
|
250
|
+
if (visible) {
|
|
251
|
+
return 'proceed';
|
|
252
|
+
}
|
|
253
|
+
if (lastRun.status === 'completed' && lastRun.conclusion !== 'success') {
|
|
254
|
+
return 'dispatch';
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (attempt < poll.attempts - 1) {
|
|
258
|
+
await sleep(poll.delayMs);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (!lastRun) {
|
|
262
|
+
// `lastRun === null` is unreachable (that branch returns 'dispatch'
|
|
263
|
+
// immediately above) — this guard exists for TypeScript's narrowing.
|
|
264
|
+
throw new UserError(`Could not confirm whether ${version}'s release workflow has already run — fails closed.`, {
|
|
265
|
+
command: `gh run list --workflow prismatica-release.yml`,
|
|
266
|
+
why: 'GitHub Actions state must be readable before deciding whether another dispatch is safe',
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
throw new UserError(`${version}'s release workflow (${lastRun.status}${lastRun.conclusion ? `, ${lastRun.conclusion}` : ''}) has not produced a staged candidate after a bounded wait — fails closed.`, {
|
|
270
|
+
command: lastRun.url ? `open ${lastRun.url}` : `gh run list --workflow prismatica-release.yml`,
|
|
271
|
+
why: 'a rerun must not dispatch another run while an existing one may still be working',
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
async function dispatchReleaseWorkflow(mainRoot, mainBranch, version) {
|
|
275
|
+
const result = await execa('gh', ['workflow', 'run', 'prismatica-release.yml', '--ref', mainBranch, '-f', `version=${version}`], { cwd: mainRoot, reject: false });
|
|
276
|
+
if (result.failed) {
|
|
277
|
+
throw new UserError(`Could not dispatch the release workflow: ${(result.stderr || result.stdout).trim()}`, {
|
|
278
|
+
command: `gh workflow run prismatica-release.yml --ref ${mainBranch} -f version=${version}`,
|
|
279
|
+
why: 'the workflow definition always runs from main and builds/stages the tagged commit',
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* `os.tmpdir()` honours `TMPDIR`, which is not guaranteed to point outside
|
|
285
|
+
* the repository — if it does, `mkdtemp` would create the download directory
|
|
286
|
+
* inside the working tree. Compares canonical (symlink-resolved) paths, not
|
|
287
|
+
* strings: a lexically different path that resolves back inside the repo is
|
|
288
|
+
* still inside it, and containment (not mere inequality) is what the
|
|
289
|
+
* download must never violate.
|
|
290
|
+
*/
|
|
291
|
+
function assertOutsideRepo(dir, repoRoot) {
|
|
292
|
+
const repoReal = fs.realpathSync(repoRoot);
|
|
293
|
+
const dirReal = fs.realpathSync(dir);
|
|
294
|
+
const rel = path.relative(repoReal, dirReal);
|
|
295
|
+
const isInside = rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
296
|
+
if (isInside) {
|
|
297
|
+
fs.rmSync(dirReal, { recursive: true, force: true });
|
|
298
|
+
throw new UserError(`The system temporary directory (${dirReal}) resolves inside the repository at ${repoReal} — refusing to download the staged tarball there.`, {
|
|
299
|
+
command: 'echo $TMPDIR',
|
|
300
|
+
why: 'the staged artifact must never be downloaded into the user’s repository, even when TMPDIR points inside it',
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* npm 11's `stage download` supports only `[--json] [--registry <registry>]`
|
|
306
|
+
* — no `--output` — and always writes `<pkg>-<version>-<stage-id>.tgz` into
|
|
307
|
+
* its OWN process working directory, silently ignoring any unknown flag.
|
|
308
|
+
* Running it with `cwd` set to a fresh, private, empty temp directory and
|
|
309
|
+
* reading that directory back is therefore the only honest way to capture
|
|
310
|
+
* exactly what npm wrote — never into the owner's repository, and never
|
|
311
|
+
* trusting a specific filename npm did not document. `assertOutsideRepo`
|
|
312
|
+
* proves that directory is genuinely outside the repo, by canonical path,
|
|
313
|
+
* BEFORE npm ever runs.
|
|
314
|
+
*
|
|
315
|
+
* Exported so `releasedownload.test.ts` can prove this against a real
|
|
316
|
+
* process boundary (a fake `npm` on PATH), not a mock that assumes
|
|
317
|
+
* `--output` works.
|
|
318
|
+
*/
|
|
319
|
+
export async function downloadStagedTarball(repoRoot, stagedId) {
|
|
320
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prismatica-release-'));
|
|
321
|
+
assertOutsideRepo(dir, repoRoot);
|
|
322
|
+
const result = await execa('npm', ['stage', 'download', stagedId], { cwd: dir, reject: false });
|
|
323
|
+
const candidates = fs.readdirSync(dir).filter((name) => name.endsWith('.tgz'));
|
|
324
|
+
if (result.failed || candidates.length !== 1) {
|
|
325
|
+
const found = candidates.length === 0 ? 'no .tgz file was written' : `${candidates.length} candidates: ${candidates.join(', ')}`;
|
|
326
|
+
throw new UserError(`Could not download the staged tarball for ${stagedId}: ${result.failed ? (result.stderr || result.stdout).trim() : found}`, {
|
|
327
|
+
command: `npm stage download ${stagedId}`,
|
|
328
|
+
why: 'the staged artifact must be re-proven offline before it is safe to approve, and exactly one candidate tarball must be unambiguous',
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
return path.join(dir, candidates[0]);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Downloads package@version's PUBLIC tarball from the registry — used only by
|
|
335
|
+
* the approved-unrecorded recovery path, which has no staged digest left to
|
|
336
|
+
* trust and must re-prove the now-public artifact itself. `npm pack` on a
|
|
337
|
+
* registry spec fetches and saves the exact published bytes (verified against
|
|
338
|
+
* a real published version: its sha512 SRI equals what `npm view
|
|
339
|
+
* dist.integrity` reports) — never a local repack.
|
|
340
|
+
*/
|
|
341
|
+
async function downloadPublicTarball(pkgName, version) {
|
|
342
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prismatica-release-public-'));
|
|
343
|
+
const result = await execa('npm', ['pack', `${pkgName}@${version}`, '--pack-destination', dir, '--json'], { reject: false });
|
|
344
|
+
if (result.failed) {
|
|
345
|
+
throw new UserError(`Could not download the public tarball for ${pkgName}@${version}: ${(result.stderr || result.stdout).trim()}`, {
|
|
346
|
+
command: `npm pack ${pkgName}@${version} --pack-destination ${dir} --json`,
|
|
347
|
+
why: 'recovery without a staged artifact must re-prove the now-public tarball, never trust the registry’s own report of its digest',
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
let filename;
|
|
351
|
+
try {
|
|
352
|
+
const parsed = JSON.parse(result.stdout);
|
|
353
|
+
const first = Array.isArray(parsed) ? parsed[0] : undefined;
|
|
354
|
+
filename = typeof first?.filename === 'string' ? first.filename : null;
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
filename = null;
|
|
358
|
+
}
|
|
359
|
+
const tarballPath = filename ? path.join(dir, filename) : null;
|
|
360
|
+
if (!tarballPath || !fs.existsSync(tarballPath)) {
|
|
361
|
+
throw new UserError(`\`npm pack ${pkgName}@${version}\` did not produce a readable tarball.`, {
|
|
362
|
+
command: `npm pack ${pkgName}@${version} --pack-destination ${dir} --json`,
|
|
363
|
+
why: 'recovery cannot write a record for an artifact it could not download',
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return tarballPath;
|
|
367
|
+
}
|
|
368
|
+
function sha512Sri(filePath) {
|
|
369
|
+
const digest = createHash('sha512').update(fs.readFileSync(filePath)).digest('base64');
|
|
370
|
+
return `sha512-${digest}`;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Every mismatch between an extracted tarball's `package/` directory and the
|
|
374
|
+
* production dependency tree `lockPath` promises — empty means the tarball is
|
|
375
|
+
* exactly what the lock file says. A repo with no lock file has nothing to
|
|
376
|
+
* verify this way (`[]`), rather than an error.
|
|
377
|
+
*
|
|
378
|
+
* Exported so `release.test.ts`'s own packaged-artifact proof and this
|
|
379
|
+
* module's staged-tarball re-proof apply exactly the same check — never two
|
|
380
|
+
* independent approximations of it.
|
|
381
|
+
*/
|
|
382
|
+
export function verifyTarballAgainstLock(extractedPackageDir, lockPath) {
|
|
383
|
+
if (!fs.existsSync(lockPath)) {
|
|
384
|
+
return [];
|
|
385
|
+
}
|
|
386
|
+
let lock;
|
|
387
|
+
try {
|
|
388
|
+
lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
return [`${lockPath} is not readable JSON`];
|
|
392
|
+
}
|
|
393
|
+
const problems = [];
|
|
394
|
+
for (const [installPath, entry] of Object.entries(lock.packages ?? {})) {
|
|
395
|
+
if (installPath === '' || entry.dev || entry.devOptional || entry.optional || !entry.version) {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const bundled = path.join(extractedPackageDir, installPath, 'package.json');
|
|
399
|
+
if (!fs.existsSync(bundled)) {
|
|
400
|
+
problems.push(`${installPath}: not bundled at all`);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
let actual;
|
|
404
|
+
try {
|
|
405
|
+
actual = JSON.parse(fs.readFileSync(bundled, 'utf8')).version;
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
problems.push(`${installPath}: bundled package.json is not readable JSON`);
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (actual !== entry.version) {
|
|
412
|
+
problems.push(`${installPath}: bundled ${actual}, lock says ${entry.version}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return problems;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Extracts `tarballPath` and re-proves its contents against
|
|
419
|
+
* `package-lock.json` — offline. Used both for the staged artifact before
|
|
420
|
+
* 2FA approval and, during approved-unrecorded recovery, for the public
|
|
421
|
+
* tarball downloaded in its place — one comparison, never two independent
|
|
422
|
+
* approximations of it. `context` names which for error messages only
|
|
423
|
+
* (`'staged'` / `'public'`).
|
|
424
|
+
*/
|
|
425
|
+
async function reproveTarball(mainRoot, tarballPath, context) {
|
|
426
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'prismatica-release-reprove-'));
|
|
427
|
+
const extracted = path.join(dir, 'extracted');
|
|
428
|
+
fs.mkdirSync(extracted, { recursive: true });
|
|
429
|
+
const untarred = await execa('tar', ['-xzf', tarballPath, '-C', extracted], { reject: false });
|
|
430
|
+
if (untarred.failed) {
|
|
431
|
+
throw new UserError(`Could not extract the ${context} tarball: ${(untarred.stderr || untarred.stdout).trim()}`, {
|
|
432
|
+
command: `tar -xzf ${tarballPath} -C ${extracted}`,
|
|
433
|
+
why: `the ${context} artifact must be re-proven offline before it is safe to trust`,
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
const problems = verifyTarballAgainstLock(path.join(extracted, 'package'), path.join(mainRoot, 'package-lock.json'));
|
|
437
|
+
if (problems.length > 0) {
|
|
438
|
+
throw new UserError(`The ${context} tarball does not match package-lock.json: ${problems.join('; ')}`, {
|
|
439
|
+
command: `tar -tzf ${tarballPath}`,
|
|
440
|
+
why: 'an artifact whose bundled dependencies disagree with the lock file is never safe to trust',
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/** `npm stage approve` — the one consequential step, gated by npm's own interactive 2FA prompt. */
|
|
445
|
+
async function npmStageApprove(stagedId) {
|
|
446
|
+
const result = await execa('npm', ['stage', 'approve', stagedId], { reject: false, stdio: 'inherit' });
|
|
447
|
+
if (result.failed) {
|
|
448
|
+
throw new UserError(`\`npm stage approve ${stagedId}\` did not succeed.`, {
|
|
449
|
+
command: `npm stage approve ${stagedId}`,
|
|
450
|
+
why: 'the owner completes this interactively, behind npm’s own 2FA prompt',
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* The one release Prismatica knows about right now, wherever it is in its
|
|
456
|
+
* lifecycle — an open lane not yet merged, or a merged contract with no
|
|
457
|
+
* release record yet. "One release in flight at a time" (the plan's own
|
|
458
|
+
* assumption) is what makes this unambiguous.
|
|
459
|
+
*/
|
|
460
|
+
async function findReleaseInFlight(mainRoot) {
|
|
461
|
+
for (const change of await listOpenChanges(mainRoot)) {
|
|
462
|
+
const { data: contract } = readContract(change.home, change.id);
|
|
463
|
+
if (contract.releaseVersion) {
|
|
464
|
+
return { contractId: change.id, version: contract.releaseVersion, home: change.home, kind: 'lane' };
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
for (const id of listContractIds(mainRoot)) {
|
|
468
|
+
const { data: contract } = readContract(mainRoot, id);
|
|
469
|
+
if (contract.releaseVersion && !releaseExists(mainRoot, contract.releaseVersion)) {
|
|
470
|
+
return { contractId: id, version: contract.releaseVersion, home: mainRoot, kind: 'merged' };
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* The most recently merged release contract, regardless of whether it has
|
|
477
|
+
* already been recorded — what `release publish` targets. Unlike
|
|
478
|
+
* `findReleaseInFlight` (which stops caring about a release the instant it is
|
|
479
|
+
* recorded, so `release prepare`'s preflight correctly allows a fresh one to
|
|
480
|
+
* start), `publish` must still recognise a JUST-finished release long enough
|
|
481
|
+
* to answer "already live and recorded" instead of the less honest "nothing
|
|
482
|
+
* to publish". `listContractIds` sorts ascending by id, and ids are
|
|
483
|
+
* date-prefixed, so the last match is the newest.
|
|
484
|
+
*/
|
|
485
|
+
function latestMergedReleaseContract(mainRoot) {
|
|
486
|
+
const ids = listContractIds(mainRoot);
|
|
487
|
+
for (let i = ids.length - 1; i >= 0; i -= 1) {
|
|
488
|
+
const { data: contract } = readContract(mainRoot, ids[i]);
|
|
489
|
+
if (contract.releaseVersion) {
|
|
490
|
+
return { contractId: ids[i], version: contract.releaseVersion };
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
async function mainIsCurrent(mainRoot, mainBranch) {
|
|
496
|
+
const upstream = `origin/${mainBranch}`;
|
|
497
|
+
if (!(await git.refExists(mainRoot, upstream))) {
|
|
498
|
+
return true; // no remote to be behind
|
|
499
|
+
}
|
|
500
|
+
return (await git.behindCount(mainRoot, mainBranch, upstream)) === 0;
|
|
501
|
+
}
|
|
502
|
+
/** Same filter `frame.ts` applies: Prismatica's own records are expected dirty between commands. */
|
|
503
|
+
async function mainIsClean(mainRoot) {
|
|
504
|
+
const dirtyCode = (await git.dirtyPaths(mainRoot)).filter((file) => !file.startsWith('.prismatica/'));
|
|
505
|
+
return dirtyCode.length === 0;
|
|
506
|
+
}
|
|
507
|
+
async function gatherPreflightFacts(mainRoot, mainBranch, pkgName, targetVersion, mainVersion, unfinishedReleaseInFlight) {
|
|
508
|
+
const mainClean = await mainIsClean(mainRoot);
|
|
509
|
+
const mainCurrent = await mainIsCurrent(mainRoot, mainBranch);
|
|
510
|
+
const targetTagExists = await git.tagExists(mainRoot, `v${targetVersion}`);
|
|
511
|
+
const registry = await queryRegistry(pkgName);
|
|
512
|
+
return {
|
|
513
|
+
targetVersion,
|
|
514
|
+
mainVersion,
|
|
515
|
+
mainClean,
|
|
516
|
+
mainCurrent,
|
|
517
|
+
registryReachable: registry.reachable,
|
|
518
|
+
publicVersions: registry.publicVersions,
|
|
519
|
+
distTags: registry.distTags,
|
|
520
|
+
stagedCandidates: registry.stagedCandidates,
|
|
521
|
+
targetTagExists,
|
|
522
|
+
unfinishedReleaseInFlight,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
function preflightRefusal(verdict) {
|
|
526
|
+
if (verdict.reason === 'target-staged') {
|
|
527
|
+
return new UserError(verdict.detail, {
|
|
528
|
+
command: `npm stage reject ${verdict.stagedCandidate.id}`,
|
|
529
|
+
why: 'continue that release with `prismatica release publish`, or reject it (owner-interactive, 2FA) and rerun `release prepare`',
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
if (verdict.reason === 'unfinished-release') {
|
|
533
|
+
return new UserError(verdict.detail, {
|
|
534
|
+
command: 'prismatica next',
|
|
535
|
+
why: 'finish the release in flight with `release publish`, close a merged-but-unpublished one with `release prepare --supersede`, or `abandon` an unmerged lane that targeted the wrong version',
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
return new UserError(verdict.detail);
|
|
539
|
+
}
|
|
540
|
+
function supersedeRefusal(verdict) {
|
|
541
|
+
if (verdict.reason === 'target-staged') {
|
|
542
|
+
return new UserError(verdict.detail, {
|
|
543
|
+
command: `npm stage reject ${verdict.stagedCandidate.id}`,
|
|
544
|
+
why: 'reject the stage (owner-interactive, 2FA), then rerun `release prepare --supersede`',
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
if (verdict.reason === 'target-public') {
|
|
548
|
+
return new UserError(verdict.detail, {
|
|
549
|
+
command: 'prismatica release publish',
|
|
550
|
+
why: 'the version is already public — record it, never supersede it',
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
return new UserError(verdict.detail);
|
|
554
|
+
}
|
|
555
|
+
// ── Flow Report: only when impact demands it, and only what is derivable ───
|
|
556
|
+
async function ensureFlowReport(home, contract) {
|
|
557
|
+
const changedFiles = await git.changedFilesSince(home, contract.baseline.commit, git.EXCLUDE_RECORDS);
|
|
558
|
+
const { flows } = listFlows(home);
|
|
559
|
+
const impact = assessImpact(changedFiles, flows);
|
|
560
|
+
const existing = reportExists(home, contract.id) ? readFlowReport(home, contract.id).data : null;
|
|
561
|
+
if (reportGaps(impact, existing).length === 0) {
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
await runFlowReport({ cwd: home, contractId: contract.id, auto: true });
|
|
565
|
+
const after = reportExists(home, contract.id) ? readFlowReport(home, contract.id).data : null;
|
|
566
|
+
const gaps = reportGaps(impact, after);
|
|
567
|
+
if (gaps.length > 0) {
|
|
568
|
+
throw new UserError(`This release's diff needs a person's answer before it can proceed: ${gaps.join('; ')}`, {
|
|
569
|
+
command: 'prismatica flow report --flow <id> --status unchanged --reason "..."',
|
|
570
|
+
why: 'no file diff can establish whether behaviour changed — `--auto` already recorded everything it honestly could',
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
// ── Check: skip only when a passing check still binds the current HEAD ─────
|
|
575
|
+
async function checkAlreadyBinds(home, contract) {
|
|
576
|
+
const mine = readLedger(home).entries.filter((entry) => entry.contractId === contract.id && entry.event === 'check_run');
|
|
577
|
+
const latest = mine.at(-1);
|
|
578
|
+
if (!latest || !latest.pass || !latest.checkedHead) {
|
|
579
|
+
return false;
|
|
580
|
+
}
|
|
581
|
+
return checkStillBinds(home, contract, latest.checkedHead);
|
|
582
|
+
}
|
|
583
|
+
export async function runReleasePrepare(options) {
|
|
584
|
+
const mainRoot = await git.mainRepoRoot(options.cwd);
|
|
585
|
+
const config = loadConfig(mainRoot);
|
|
586
|
+
const pkgName = readPackageJson(mainRoot).name;
|
|
587
|
+
if (options.supersede) {
|
|
588
|
+
return runSupersede(mainRoot, pkgName);
|
|
589
|
+
}
|
|
590
|
+
if (!options.target) {
|
|
591
|
+
throw new UserError('Tell release prepare what to bump: `patch`, `minor`, `major`, or `--version X.Y.Z`.', {
|
|
592
|
+
command: 'prismatica release prepare minor',
|
|
593
|
+
why: 'there is no default bump kind',
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
const mainVersion = readPackageJson(mainRoot).version;
|
|
597
|
+
let targetVersion;
|
|
598
|
+
try {
|
|
599
|
+
targetVersion = resolveTargetVersion(mainVersion, options.target);
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
throw new UserError(error instanceof Error ? error.message : String(error));
|
|
603
|
+
}
|
|
604
|
+
const inFlight = await findReleaseInFlight(mainRoot);
|
|
605
|
+
const resuming = inFlight?.kind === 'lane' && inFlight.version === targetVersion;
|
|
606
|
+
let home;
|
|
607
|
+
let contract;
|
|
608
|
+
if (resuming) {
|
|
609
|
+
home = inFlight.home;
|
|
610
|
+
({ data: contract } = readContract(home, inFlight.contractId));
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
const facts = await gatherPreflightFacts(mainRoot, config.mainBranch, pkgName, targetVersion, mainVersion, inFlight !== null);
|
|
614
|
+
const verdict = classifyPreflight(facts);
|
|
615
|
+
if (!verdict.ok) {
|
|
616
|
+
throw preflightRefusal(verdict);
|
|
617
|
+
}
|
|
618
|
+
const framed = await runFrame({
|
|
619
|
+
cwd: mainRoot,
|
|
620
|
+
title: `Release ${targetVersion}`,
|
|
621
|
+
issue: `release/${targetVersion}`,
|
|
622
|
+
allowedPaths: ['package.json', 'package-lock.json'],
|
|
623
|
+
acceptanceChecks: [
|
|
624
|
+
{ id: 'ac-1', description: `npm publishes ${targetVersion}`, test: 'manual:OWNER' },
|
|
625
|
+
],
|
|
626
|
+
answers: { touchesAuth: false, touchesPayments: false, touchesSavedData: false, copyOnly: false },
|
|
627
|
+
tierOverride: 'light',
|
|
628
|
+
installHook: false,
|
|
629
|
+
now: options.now,
|
|
630
|
+
});
|
|
631
|
+
home = framed.worktree;
|
|
632
|
+
contract = { ...framed.contract, releaseVersion: targetVersion };
|
|
633
|
+
writeContract(home, contract, renderContractBody(contract));
|
|
634
|
+
}
|
|
635
|
+
// `npm version` — idempotent: skip once the working tree already reads the target.
|
|
636
|
+
if (readPackageJson(home).version !== targetVersion) {
|
|
637
|
+
const bumped = await execa('npm', ['version', targetVersion, '--no-git-tag-version', '--allow-same-version'], { cwd: home, reject: false });
|
|
638
|
+
if (bumped.failed) {
|
|
639
|
+
throw new UserError(`\`npm version ${targetVersion}\` failed: ${(bumped.stderr || bumped.stdout).trim()}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
await ensureFlowReport(home, contract);
|
|
643
|
+
// Exact-path checkpoint commit — every Check input this release-prepare
|
|
644
|
+
// transaction produced (package.json, package-lock.json if npm touched it,
|
|
645
|
+
// the framed contract, the Flow report if one was written).
|
|
646
|
+
const checkpoint = await nonExemptDirtyPaths(home, contract);
|
|
647
|
+
if (checkpoint.blocked) {
|
|
648
|
+
throw new UserError('A rename or unmerged conflict sits among this release’s own uncommitted files.', { command: `git -C ${home} status`, why: 'a checkpoint commit cannot safely resolve either automatically' });
|
|
649
|
+
}
|
|
650
|
+
if (checkpoint.paths.length > 0) {
|
|
651
|
+
await git.commitPaths(home, checkpoint.paths, `release: checkpoint ${targetVersion}`);
|
|
652
|
+
}
|
|
653
|
+
if (!(await checkAlreadyBinds(home, contract))) {
|
|
654
|
+
await ensureLaneDependencies(home);
|
|
655
|
+
const checked = await runCheck({ cwd: home, contractId: contract.id });
|
|
656
|
+
if (!checked.result.pass) {
|
|
657
|
+
throw new UserError('Check failed — fix what failed, then rerun `release prepare`.', {
|
|
658
|
+
command: `prismatica check ${contract.id}`,
|
|
659
|
+
why: 'a release only ever ships proven work',
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
// Exact-path record commit — ONLY Check's own durable outputs (the
|
|
664
|
+
// evidence file, the appended ledger line, the contract's stage bump to
|
|
665
|
+
// `prove`), never a general "whatever is dirty" sweep: a stray file in the
|
|
666
|
+
// lane must never ride into this commit. `dirtyEntries`, never
|
|
667
|
+
// `dirtyPaths`: a brand-new evidence directory (this lane's FIRST check)
|
|
668
|
+
// collapses to one porcelain line for the directory itself under plain
|
|
669
|
+
// `dirtyPaths`, hiding the individual evidence file `isCheckEvidenceFile`
|
|
670
|
+
// needs to see.
|
|
671
|
+
const contractRel = laneRelative(home, contractPath(home, contract.id));
|
|
672
|
+
const ledgerRel = laneRelative(home, ledgerPath(home));
|
|
673
|
+
const recordPaths = (await git.dirtyEntries(home))
|
|
674
|
+
.map((entry) => entry.path)
|
|
675
|
+
.filter((p) => isCheckEvidenceFile(home, contract.id, p) || p === ledgerRel || p === contractRel);
|
|
676
|
+
if (recordPaths.length > 0) {
|
|
677
|
+
await git.commitPaths(home, recordPaths, `release: check evidence for ${targetVersion}`);
|
|
678
|
+
}
|
|
679
|
+
const shipReport = await runShip({ cwd: home, contractId: contract.id });
|
|
680
|
+
if (!shipReport.shipped) {
|
|
681
|
+
throw new UserError('The gate is not satisfied yet — see the requirements above.', {
|
|
682
|
+
command: `prismatica gate ${contract.id}`,
|
|
683
|
+
why: 'release prepare ships through the same gate every change does',
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
return { action: 'shipped', contractId: contract.id, version: targetVersion, prUrl: shipReport.prUrl };
|
|
687
|
+
}
|
|
688
|
+
async function runSupersede(mainRoot, pkgName) {
|
|
689
|
+
const inFlight = await findReleaseInFlight(mainRoot);
|
|
690
|
+
if (!inFlight || inFlight.kind !== 'merged') {
|
|
691
|
+
throw new UserError('There is no merged, unfinished release to supersede.', {
|
|
692
|
+
command: 'prismatica next',
|
|
693
|
+
why: 'supersede closes a release that has already merged but never published — an unmerged lane is closed with `abandon` instead',
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
if (releaseExists(mainRoot, inFlight.version)) {
|
|
697
|
+
throw new UserError(`A release record for ${inFlight.version} already exists — releases are write-once.`);
|
|
698
|
+
}
|
|
699
|
+
const registry = await queryRegistry(pkgName);
|
|
700
|
+
const facts = {
|
|
701
|
+
targetVersion: inFlight.version,
|
|
702
|
+
publicVersions: registry.publicVersions,
|
|
703
|
+
stagedCandidates: registry.stagedCandidates,
|
|
704
|
+
};
|
|
705
|
+
const verdict = classifySupersede(facts);
|
|
706
|
+
if (!verdict.ok) {
|
|
707
|
+
throw supersedeRefusal(verdict);
|
|
708
|
+
}
|
|
709
|
+
const release = ReleaseSchema.parse({
|
|
710
|
+
version: inFlight.version,
|
|
711
|
+
contractId: inFlight.contractId,
|
|
712
|
+
outcome: 'superseded',
|
|
713
|
+
at: new Date().toISOString(),
|
|
714
|
+
recoveryRoutes: [],
|
|
715
|
+
prismaticaVersion: prismaticaVersion(),
|
|
716
|
+
});
|
|
717
|
+
writeRelease(mainRoot, release, renderReleaseBody(release));
|
|
718
|
+
return { action: 'superseded', contractId: inFlight.contractId, version: inFlight.version };
|
|
719
|
+
}
|
|
720
|
+
export function printReleasePrepareReport(report) {
|
|
721
|
+
if (report.action === 'superseded') {
|
|
722
|
+
console.log(ui.ok(`✓ ${report.version} superseded`));
|
|
723
|
+
console.log(` ${ui.bold('contract')} ${report.contractId}`);
|
|
724
|
+
console.log('');
|
|
725
|
+
console.log(ui.dim(` commit the release record: git add .prismatica/releases/${report.version}.md`));
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
console.log(ui.ok(`✓ release ${report.version} shipped`));
|
|
729
|
+
console.log(` ${ui.bold('contract')} ${report.contractId}`);
|
|
730
|
+
if (report.prUrl) {
|
|
731
|
+
console.log(` ${ui.bold('PR')} ${report.prUrl}`);
|
|
732
|
+
}
|
|
733
|
+
console.log('');
|
|
734
|
+
console.log(ui.dim(' gate → merge → close, then `prismatica release publish` once it is on main.'));
|
|
735
|
+
}
|
|
736
|
+
async function verifyPureVersionBump(mainRoot, contract, expectedVersion) {
|
|
737
|
+
const changed = await git.diffNames(mainRoot, contract.baseline.commit, 'HEAD', git.EXCLUDE_RECORDS);
|
|
738
|
+
const oldPkgText = await git.showFile(mainRoot, contract.baseline.commit, 'package.json');
|
|
739
|
+
const newPkgText = await git.showFile(mainRoot, 'HEAD', 'package.json');
|
|
740
|
+
const touchesLock = changed.includes('package-lock.json');
|
|
741
|
+
const oldLockText = touchesLock ? await git.showFile(mainRoot, contract.baseline.commit, 'package-lock.json') : null;
|
|
742
|
+
const newLockText = touchesLock ? await git.showFile(mainRoot, 'HEAD', 'package-lock.json') : null;
|
|
743
|
+
const verdict = classifyVersionBumpDiff({
|
|
744
|
+
changedNonRecordFiles: changed,
|
|
745
|
+
oldPackageJson: oldPkgText ? JSON.parse(oldPkgText) : null,
|
|
746
|
+
newPackageJson: newPkgText ? JSON.parse(newPkgText) : null,
|
|
747
|
+
oldLock: oldLockText ? JSON.parse(oldLockText) : null,
|
|
748
|
+
newLock: newLockText ? JSON.parse(newLockText) : null,
|
|
749
|
+
expectedVersion,
|
|
750
|
+
});
|
|
751
|
+
if (!verdict.pure) {
|
|
752
|
+
throw new UserError(`The merged diff for "${contract.id}" is not a pure version bump: ${verdict.reason}`, {
|
|
753
|
+
command: `git diff ${contract.baseline.commit}..HEAD -- . ':(exclude).prismatica'`,
|
|
754
|
+
why: 'a release only ever tags a commit whose non-record diff is exactly the version transformation',
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
async function reconstructRecord(mainRoot, contract, version, pkgName, evidence) {
|
|
759
|
+
const query = await npmPublicIntegrity(pkgName, version);
|
|
760
|
+
if (query.state !== 'present') {
|
|
761
|
+
throw new UserError(`${pkgName}@${version} is not confirmed public yet — fails closed.`, {
|
|
762
|
+
command: `npm view ${pkgName}@${version} dist.integrity`,
|
|
763
|
+
why: 'the terminal record is only written once the registry itself confirms the artifact',
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
if (evidence.tier === 'staged-approval') {
|
|
767
|
+
if (query.integrity !== evidence.expectedIntegrity) {
|
|
768
|
+
throw new UserError(`${pkgName}@${version}'s public dist.integrity does not match the staged artifact re-proved before approval — fails closed.`, {
|
|
769
|
+
command: `npm view ${pkgName}@${version} dist.integrity`,
|
|
770
|
+
why: 'the exact staged artifact re-proved before owner 2FA approval must be the exact artifact npm confirms public afterward',
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
else {
|
|
775
|
+
// No staged digest survives past `stage approve` — the staged candidate
|
|
776
|
+
// disappears the instant it succeeds. The only honest proof left is
|
|
777
|
+
// downloading the now-public tarball and re-hashing it ourselves; trusting
|
|
778
|
+
// the registry's own report of its digest would be exactly the unproven
|
|
779
|
+
// claim this recovery path exists to avoid.
|
|
780
|
+
const tarballPath = await downloadPublicTarball(pkgName, version);
|
|
781
|
+
await reproveTarball(mainRoot, tarballPath, 'public');
|
|
782
|
+
const computed = sha512Sri(tarballPath);
|
|
783
|
+
if (computed !== query.integrity) {
|
|
784
|
+
throw new UserError(`${pkgName}@${version}'s downloaded tarball hashes to ${computed}, which does not match the registry's reported dist.integrity (${query.integrity}) — fails closed.`, {
|
|
785
|
+
command: `npm pack ${pkgName}@${version} --pack-destination <dir> --json`,
|
|
786
|
+
why: 'the approved-unrecorded recovery path only writes a record for an artifact it has independently re-proved, never one it merely read a report of',
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
const distTags = await npmDistTags(pkgName);
|
|
791
|
+
if (!distTags || distTags.latest !== version) {
|
|
792
|
+
throw new UserError(`${pkgName}'s "latest" dist-tag does not confirm ${version} — fails closed.`, {
|
|
793
|
+
command: `npm view ${pkgName} dist-tags`,
|
|
794
|
+
why: 'the record only claims what the registry itself has already made true',
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
const tag = `v${version}`;
|
|
798
|
+
if (!(await git.tagExists(mainRoot, tag))) {
|
|
799
|
+
throw new UserError(`${tag} does not exist — fails closed.`, {
|
|
800
|
+
command: `git tag -l ${tag}`,
|
|
801
|
+
why: 'a published release record must bind to the immutable Git tag/commit that produced it, never to npm publicity alone',
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
const commit = await git.revParse(mainRoot, tag);
|
|
805
|
+
const manifestText = await git.showFile(mainRoot, commit, 'package.json');
|
|
806
|
+
const manifestVersion = manifestText ? JSON.parse(manifestText).version : null;
|
|
807
|
+
if (manifestVersion !== version) {
|
|
808
|
+
throw new UserError(`${tag} resolves to a commit whose package.json version ("${String(manifestVersion)}") does not match ${version} — fails closed.`, {
|
|
809
|
+
command: `git show ${tag}:package.json`,
|
|
810
|
+
why: 'the tag, its commit and the published version must all agree before a published record is written',
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
const release = ReleaseSchema.parse({
|
|
814
|
+
version,
|
|
815
|
+
contractId: contract.id,
|
|
816
|
+
outcome: 'published',
|
|
817
|
+
commit,
|
|
818
|
+
tag,
|
|
819
|
+
integrity: query.integrity,
|
|
820
|
+
distTag: 'latest',
|
|
821
|
+
at: new Date().toISOString(),
|
|
822
|
+
recoveryRoutes: [],
|
|
823
|
+
prismaticaVersion: prismaticaVersion(),
|
|
824
|
+
});
|
|
825
|
+
writeRelease(mainRoot, release, renderReleaseBody(release));
|
|
826
|
+
return {
|
|
827
|
+
action: 'reconstruct-record-from-public',
|
|
828
|
+
version,
|
|
829
|
+
detail: `${pkgName}@${version} is public — release record written`,
|
|
830
|
+
release,
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
async function awaitApproval(mainRoot, contract, version, staged, pkgName, poll) {
|
|
834
|
+
const tarballPath = await downloadStagedTarball(mainRoot, staged.id);
|
|
835
|
+
await reproveTarball(mainRoot, tarballPath, 'staged');
|
|
836
|
+
const digest = sha512Sri(tarballPath);
|
|
837
|
+
console.log(ui.bold('Staged release evidence'));
|
|
838
|
+
console.log(` ${ui.bold('version')} ${version}`);
|
|
839
|
+
console.log(` ${ui.bold('stage')} ${staged.id} (tagged ${staged.tag ?? '(unreadable)'})`);
|
|
840
|
+
console.log(` ${ui.bold('digest')} ${digest}`);
|
|
841
|
+
console.log('');
|
|
842
|
+
await npmStageApprove(staged.id);
|
|
843
|
+
// Bounded wait for public visibility WHILE `digest` — the staged artifact's
|
|
844
|
+
// own re-proved integrity — stays in scope. Approval already succeeded:
|
|
845
|
+
// returning normally here would silently discard the one fact that makes
|
|
846
|
+
// the eventual record honest, and a LATER rerun could never recompute it
|
|
847
|
+
// (the staged candidate disappears the instant approval succeeds). A
|
|
848
|
+
// mismatch found mid-poll refuses immediately, via `reconstructRecord`,
|
|
849
|
+
// rather than being retried away.
|
|
850
|
+
for (let attempt = 0; attempt < poll.attempts; attempt += 1) {
|
|
851
|
+
const registry = await queryRegistry(pkgName);
|
|
852
|
+
if (registry.publicVersions?.includes(version)) {
|
|
853
|
+
return reconstructRecord(mainRoot, contract, version, pkgName, {
|
|
854
|
+
tier: 'staged-approval',
|
|
855
|
+
expectedIntegrity: digest,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
if (attempt < poll.attempts - 1) {
|
|
859
|
+
await sleep(poll.delayMs);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
throw new UserError(`${pkgName}@${version} was approved but has not shown public after a bounded wait — fails closed.`, {
|
|
863
|
+
command: `npm view ${pkgName}@${version} dist.integrity`,
|
|
864
|
+
why: 'confirm the version is public before rerunning `prismatica release publish` — a rerun while it is still propagating may re-dispatch the release workflow rather than recover; once the registry shows it public, rerunning independently re-proves the artifact rather than trusting a digest this process can no longer hold',
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
export async function runReleasePublish(options) {
|
|
868
|
+
const mainRoot = await git.mainRepoRoot(options.cwd);
|
|
869
|
+
const config = loadConfig(mainRoot);
|
|
870
|
+
const pkgName = readPackageJson(mainRoot).name;
|
|
871
|
+
const poll = options.poll ?? DEFAULT_POLL;
|
|
872
|
+
const latest = latestMergedReleaseContract(mainRoot);
|
|
873
|
+
if (!latest) {
|
|
874
|
+
throw new UserError('No merged release is waiting to be published.', {
|
|
875
|
+
command: 'prismatica release prepare <patch|minor|major>',
|
|
876
|
+
why: 'release publish only ever continues a release that has already merged',
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
const { data: contract } = readContract(mainRoot, latest.contractId);
|
|
880
|
+
const version = latest.version;
|
|
881
|
+
const tag = `v${version}`;
|
|
882
|
+
const registry = await queryRegistry(pkgName);
|
|
883
|
+
const facts = {
|
|
884
|
+
version,
|
|
885
|
+
intendedTag: 'latest',
|
|
886
|
+
tagExists: await git.tagExists(mainRoot, tag),
|
|
887
|
+
publicVersions: registry.publicVersions,
|
|
888
|
+
distTags: registry.distTags,
|
|
889
|
+
stagedCandidates: registry.stagedCandidates,
|
|
890
|
+
recordExists: releaseExists(mainRoot, version),
|
|
891
|
+
};
|
|
892
|
+
const next = deriveReleaseNextAction(facts);
|
|
893
|
+
switch (next.action) {
|
|
894
|
+
case 'already-recorded':
|
|
895
|
+
throw new UserError(`${version} is already live and recorded.`, {
|
|
896
|
+
command: `cat ${releasePath(mainRoot, version)}`,
|
|
897
|
+
why: 'releases are write-once',
|
|
898
|
+
});
|
|
899
|
+
case 'query-uncertain':
|
|
900
|
+
throw new UserError(next.detail);
|
|
901
|
+
case 'create-tag-and-dispatch': {
|
|
902
|
+
await verifyPureVersionBump(mainRoot, contract, version);
|
|
903
|
+
const headSha = await git.revParse(mainRoot, 'HEAD');
|
|
904
|
+
await git.createTag(mainRoot, tag, headSha);
|
|
905
|
+
const pushed = await git.pushTag(mainRoot, 'origin', tag);
|
|
906
|
+
if (!pushed.ok) {
|
|
907
|
+
throw new UserError(`Could not push tag ${tag}: ${pushed.detail}`);
|
|
908
|
+
}
|
|
909
|
+
await dispatchReleaseWorkflow(mainRoot, config.mainBranch, version);
|
|
910
|
+
return { action: 'create-tag-and-dispatch', version, detail: `tagged and dispatched ${tag}` };
|
|
911
|
+
}
|
|
912
|
+
case 'dispatch-workflow': {
|
|
913
|
+
// No staged candidate is visible. That alone is not proof nothing is
|
|
914
|
+
// happening: a run this release already dispatched may still be
|
|
915
|
+
// queued/running, may have just finished and not yet be visible as
|
|
916
|
+
// staged, or may already have been approved (consumed) in an earlier,
|
|
917
|
+
// interrupted invocation. `resolveDispatchDecision` re-derives which,
|
|
918
|
+
// from the matching run's own state — a rerun must never fire another
|
|
919
|
+
// dispatch while one it already triggered may still be working.
|
|
920
|
+
const decision = await resolveDispatchDecision(mainRoot, pkgName, version, poll);
|
|
921
|
+
if (decision === 'proceed') {
|
|
922
|
+
return runReleasePublish(options); // fresh facts, never act on what's now stale
|
|
923
|
+
}
|
|
924
|
+
await dispatchReleaseWorkflow(mainRoot, config.mainBranch, version);
|
|
925
|
+
return {
|
|
926
|
+
action: 'dispatch-workflow',
|
|
927
|
+
version,
|
|
928
|
+
detail: `re-dispatched the release workflow for ${version}`,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
case 'refuse-wrong-stage':
|
|
932
|
+
throw new UserError(next.detail, {
|
|
933
|
+
command: `npm stage reject ${next.stagedCandidate.id}`,
|
|
934
|
+
why: 'reject the wrong-tagged stage (owner-interactive, 2FA), then rerun to re-dispatch',
|
|
935
|
+
});
|
|
936
|
+
case 'await-approval':
|
|
937
|
+
return awaitApproval(mainRoot, contract, version, next.stagedCandidate, pkgName, poll);
|
|
938
|
+
case 'reconstruct-record-from-public':
|
|
939
|
+
return reconstructRecord(mainRoot, contract, version, pkgName, { tier: 'recovery-without-staged-artifact' });
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
export function printReleasePublishReport(report) {
|
|
943
|
+
const done = report.action === 'reconstruct-record-from-public';
|
|
944
|
+
console.log(done ? ui.ok(`✓ ${report.version} published and recorded`) : ui.warn(`… ${report.version}: ${report.action}`));
|
|
945
|
+
console.log(` ${report.detail}`);
|
|
946
|
+
if (report.release) {
|
|
947
|
+
console.log(` ${ui.bold('integrity')} ${report.release.integrity ?? '(none)'}`);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
//# sourceMappingURL=release.js.map
|