@nextcommerce/campaigns-os 1.53.0 → 1.54.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +1 -0
  3. package/compatibility.json +1 -1
  4. package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
  5. package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
  6. package/contracts/commerce-surface-catalog.json +27 -27
  7. package/contracts/effects.v1.json +96 -37
  8. package/contracts/release-ledger.json +333 -0
  9. package/contracts/supported-surface.json +3 -3
  10. package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
  11. package/docs/build-packet.md +11 -4
  12. package/docs/campaign-standardization-report.md +32 -0
  13. package/docs/design-source-package.md +7 -3
  14. package/docs/effects.md +2 -1
  15. package/docs/local-setup.md +1 -1
  16. package/docs/orientation-contract-reference.md +1 -1
  17. package/docs/progress-snapshots.md +3 -1
  18. package/docs/qa-and-test-orders.md +28 -3
  19. package/docs/runtime-readiness.md +1 -1
  20. package/docs/sdk-storage-compatibility.md +15 -0
  21. package/docs/skills-revision.md +10 -10
  22. package/docs/supported-surface.md +1 -0
  23. package/package.json +1 -1
  24. package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
  25. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  26. package/skills/campaign-readback-classification/SKILL.md +3 -3
  27. package/skills/campaign-run-evidence/SKILL.md +3 -3
  28. package/skills/contribution-intake/SKILL.md +3 -3
  29. package/skills/next-campaigns-build/SKILL.md +3 -3
  30. package/skills/next-campaigns-os/SKILL.md +3 -3
  31. package/skills/next-campaigns-os-setup/SKILL.md +3 -3
  32. package/skills/next-campaigns-polish/SKILL.md +3 -3
  33. package/skills/next-campaigns-qa/SKILL.md +3 -3
  34. package/skills.json +10 -10
  35. package/src/campaign-ecosystem.mjs +153 -31
  36. package/src/cli.mjs +22 -3
  37. package/src/directory-lock.mjs +79 -7
  38. package/src/doctor/checks.mjs +12 -2
  39. package/src/doctor/inspect.mjs +13 -0
  40. package/src/invocation.mjs +3 -1
  41. package/src/progress-node.mjs +3 -2
  42. package/src/qa-browser.mjs +80 -9
  43. package/src/qa-node.mjs +33 -4
  44. package/src/sdk-attribute-index.mjs +111 -23
  45. package/src/sdk-markup.mjs +49 -10
  46. package/src/sdk-repin.mjs +282 -0
  47. package/src/stage-ledger.mjs +1 -1
  48. package/src/stage-record.mjs +37 -6
  49. package/src/standardization-report.mjs +26 -1
  50. package/src/target-lock.mjs +9 -1
@@ -0,0 +1,282 @@
1
+ // `sdk repin`: rewrite the hardcoded Campaign Cart SDK pins of a static
2
+ // campaign repo (one with no page-kit `_data/campaigns.json`).
3
+ //
4
+ // The references are the ones the campaign-ecosystem scan already finds: every
5
+ // http(s) Campaign Cart URL that points at a loader or dist artifact
6
+ // (loader.js, campaign-cart.css), in HTML and script files, HTML comments
7
+ // masked. Only a semver pin (`@vX.Y.Z` or `@X.Y.Z`) below the target version is
8
+ // rewritten, and only its version segment changes; the leading `v` is kept as
9
+ // written. `@latest`, `@main`, commit refs and prerelease refs are reported and
10
+ // left alone, as is a pin already at or above the target (never a downgrade).
11
+ //
12
+ // Preview is the default and writes nothing. `--apply` writes the files and a
13
+ // change record at .campaign-runtime/sdk-repin.json (schema
14
+ // campaigns-os-sdk-repin/v0) that a Run Record can cite by path and sha256.
15
+ // A repo with `_data/campaigns.json` is refused: page-kit owns that pin.
16
+
17
+ import { createHash } from "node:crypto";
18
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
19
+ import { dirname, join, resolve } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ import { isReleasedSdkVersion } from "../campaign-spec/dist/index.js";
23
+ import {
24
+ campaignCartArtifactReferences,
25
+ isCampaignCartHtmlFile,
26
+ listCampaignCartScanFiles,
27
+ listCampaignCartSourceFiles,
28
+ loadSdkSupportPolicy,
29
+ } from "./campaign-ecosystem.mjs";
30
+ import { refused } from "./lifecycle.mjs";
31
+ import { compareVersions, relPath, unique } from "./repo-scan.mjs";
32
+
33
+ export const SDK_REPIN_SCHEMA = "campaigns-os-sdk-repin/v0";
34
+ export const SDK_REPIN_RECORD_REL_PATH = ".campaign-runtime/sdk-repin.json";
35
+ const PAGE_KIT_CAMPAIGNS_SUFFIX = "_data/campaigns.json";
36
+
37
+ const PACKAGE_VERSION = (() => {
38
+ try {
39
+ return JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version || null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ })();
44
+
45
+ /** A --target-sdk value repin accepts: a released x.y.z, with or without a leading v. */
46
+ export const isRepinTargetVersion = (value) => isReleasedSdkVersion(String(value ?? "").trim().replace(/^v/, ""));
47
+
48
+ /**
49
+ * Plan the rewrite without touching the target. `targetSdk` is an x.y.z (a
50
+ * leading v is accepted); when absent the policy's preferred_minimum is the
51
+ * target, the same policy the ecosystem scan evaluates pins against. A target
52
+ * that is not a directory, or a target version that is not a released SDK
53
+ * version, is refused (a tagged refusal: one line, nothing journaled).
54
+ */
55
+ export function planSdkRepin(options = {}) {
56
+ return planWithSpans(options).plan;
57
+ }
58
+
59
+ // The plan plus the internal rewrite spans (file offsets) runSdkRepin needs;
60
+ // the spans never leave this module.
61
+ function planWithSpans({ targetRepo, targetSdk = null, sdkSupportPolicy = null } = {}) {
62
+ const target = resolve(targetRepo);
63
+ if (!existsSync(target)) throw refused(`sdk repin: --target ${targetRepo} does not exist.`);
64
+ if (!statSync(target).isDirectory()) throw refused(`sdk repin: --target ${targetRepo} is not a directory.`);
65
+ const policy = loadSdkSupportPolicy(sdkSupportPolicy);
66
+ const explicit = targetSdk !== null && targetSdk !== undefined;
67
+ const toVersion = explicit ? String(targetSdk).trim().replace(/^v/, "") : policy.preferred_minimum;
68
+ if (!isReleasedSdkVersion(toVersion)) {
69
+ throw refused(explicit
70
+ ? `sdk repin: --target-sdk ${targetSdk} is not a released SDK version (x.y.z).`
71
+ : `sdk repin: the SDK support policy (${policy.source}) names no usable preferred_minimum; pass --target-sdk <x.y.z>.`);
72
+ }
73
+ const base = {
74
+ schema_version: SDK_REPIN_SCHEMA,
75
+ target_repo: target,
76
+ to_version: toVersion,
77
+ to_version_source: explicit ? "argument" : "policy.preferred_minimum",
78
+ policy,
79
+ };
80
+
81
+ const pageKitData = findPageKitCampaignData(target);
82
+ if (pageKitData.length) {
83
+ return { spans: [], plan: {
84
+ ...base,
85
+ ok: false,
86
+ status: "refused_page_kit",
87
+ message: `${pageKitData.join(", ")} exists: this is a page-kit repo, whose Campaign Cart pin lives in _data/campaigns.json and is owned by page-kit. Use \`campaigns-os page-kit sync --packet <campaign-runtime.build.json>\` to set it from the CampaignSpec; sdk repin rewrites hardcoded pins in static repos only. Nothing was changed.`,
88
+ page_kit_data: pageKitData,
89
+ references: [],
90
+ rewrites: [],
91
+ left_alone: { not_semver: [], at_target: [], newer_than_target: [] },
92
+ files: [],
93
+ } };
94
+ }
95
+
96
+ const references = [];
97
+ for (const file of listCampaignCartSourceFiles(target)) {
98
+ const raw = readText(file);
99
+ if (raw === null) continue;
100
+ for (const entry of campaignCartArtifactReferences(raw, { isHtml: isCampaignCartHtmlFile(file) })) {
101
+ // Masking keeps offsets, so the span is the ref in the raw text; a
102
+ // mismatch would mean the two disagree, and nothing is rewritten then.
103
+ if (raw.slice(entry.ref_start, entry.ref_end) !== entry.ref) continue;
104
+ const artifact = /\.css(?:[?#]|$)/i.test(entry.url) ? "css" : "loader";
105
+ const action = !entry.version
106
+ ? "not_semver"
107
+ : compareVersions(entry.version, toVersion) < 0
108
+ ? "rewrite"
109
+ : compareVersions(entry.version, toVersion) === 0 ? "at_target" : "newer_than_target";
110
+ const newRef = action === "rewrite" ? `${entry.ref.startsWith("v") ? "v" : ""}${toVersion}` : null;
111
+ const urlRefStart = entry.ref_start - entry.url_start;
112
+ references.push({
113
+ path: relPath(target, file),
114
+ line: entry.line,
115
+ artifact,
116
+ url: entry.url,
117
+ ref: entry.ref,
118
+ version: entry.version,
119
+ action,
120
+ new_ref: newRef,
121
+ new_url: newRef ? `${entry.url.slice(0, urlRefStart)}${newRef}${entry.url.slice(urlRefStart + entry.ref.length)}` : null,
122
+ ref_start: entry.ref_start,
123
+ ref_end: entry.ref_end,
124
+ });
125
+ }
126
+ }
127
+
128
+ const rewrites = references.filter((entry) => entry.action === "rewrite");
129
+ const status = !references.length
130
+ ? "no_references"
131
+ : rewrites.length ? "changes" : "nothing_to_change";
132
+ const spans = rewrites.map(({ path, ref_start, ref_end, ref, new_ref }) => ({ path, ref_start, ref_end, ref, new_ref }));
133
+ return { spans, plan: {
134
+ ...base,
135
+ ok: true,
136
+ status,
137
+ message: statusMessage(status, toVersion),
138
+ references: references.map(publicReference),
139
+ rewrites: rewrites.map(publicReference),
140
+ left_alone: {
141
+ not_semver: references.filter((entry) => entry.action === "not_semver").map(publicReference),
142
+ at_target: references.filter((entry) => entry.action === "at_target").map(publicReference),
143
+ newer_than_target: references.filter((entry) => entry.action === "newer_than_target").map(publicReference),
144
+ },
145
+ files: unique(rewrites.map((entry) => entry.path)),
146
+ } };
147
+ }
148
+
149
+ /**
150
+ * Plan, and with `apply` write. Returns the plan plus `mode`, `applied`,
151
+ * `change_record` and (after a write) `verified`, the re-scan confirming no
152
+ * reference below the target is left.
153
+ */
154
+ export function runSdkRepin({ targetRepo, targetSdk = null, apply = false, sdkSupportPolicy = null, now = () => new Date() } = {}) {
155
+ const { plan, spans } = planWithSpans({ targetRepo, targetSdk, sdkSupportPolicy });
156
+ const result = { ...plan };
157
+ result.mode = apply ? "apply" : "preview";
158
+ result.applied = false;
159
+ result.change_record = { path: SDK_REPIN_RECORD_REL_PATH, written: false, sha256: null, record: null };
160
+ if (plan.status !== "changes") return result;
161
+
162
+ const record = buildChangeRecord(plan, now());
163
+ result.change_record.record = record;
164
+ if (!apply) return result;
165
+
166
+ const byFile = new Map();
167
+ for (const span of spans) {
168
+ if (!byFile.has(span.path)) byFile.set(span.path, []);
169
+ byFile.get(span.path).push(span);
170
+ }
171
+ // Every file is re-read and every span checked BEFORE any file is written,
172
+ // so a file that changed since the plan aborts the run with nothing written.
173
+ const updates = [];
174
+ for (const [path, fileSpans] of byFile) {
175
+ const file = join(plan.target_repo, path);
176
+ let text = readFileSync(file, "utf8");
177
+ // Right to left, so earlier offsets stay valid.
178
+ for (const span of [...fileSpans].sort((a, b) => b.ref_start - a.ref_start)) {
179
+ if (text.slice(span.ref_start, span.ref_end) !== span.ref) {
180
+ throw refused(`sdk repin: ${path} changed between the scan and the write (a pinned reference is no longer where the scan found it); no file was written and no change record was made. Re-run sdk repin to plan against the current files.`);
181
+ }
182
+ text = `${text.slice(0, span.ref_start)}${span.new_ref}${text.slice(span.ref_end)}`;
183
+ }
184
+ updates.push({ file, text });
185
+ }
186
+ for (const { file, text } of updates) writeFileSync(file, text);
187
+ const recordPath = join(plan.target_repo, SDK_REPIN_RECORD_REL_PATH);
188
+ mkdirSync(dirname(recordPath), { recursive: true });
189
+ const body = `${JSON.stringify(record, null, 2)}\n`;
190
+ writeFileSync(recordPath, body);
191
+ result.applied = true;
192
+ result.change_record.written = true;
193
+ result.change_record.sha256 = createHash("sha256").update(body).digest("hex");
194
+
195
+ const after = planSdkRepin({ targetRepo: plan.target_repo, targetSdk: plan.to_version, sdkSupportPolicy });
196
+ result.verified = after.ok && after.rewrites.length === 0;
197
+ result.message = `Rewrote ${plan.rewrites.length} reference(s) in ${plan.files.length} file(s) to ${plan.to_version}.`;
198
+ return result;
199
+ }
200
+
201
+ // The change record: what moved, with repo-relative paths only, so it can be
202
+ // committed and cited (path + sha256) by a Run Record.
203
+ function buildChangeRecord(plan, generatedAt) {
204
+ return {
205
+ schema_version: SDK_REPIN_SCHEMA,
206
+ generated_at: generatedAt.toISOString(),
207
+ campaigns_os_version: PACKAGE_VERSION,
208
+ command: "sdk repin",
209
+ to_version: plan.to_version,
210
+ to_version_source: plan.to_version_source,
211
+ from_versions: unique(plan.rewrites.map((entry) => entry.version)).sort(compareVersions),
212
+ files_touched: plan.files,
213
+ reference_count: plan.rewrites.length,
214
+ rewrites: plan.rewrites.map(({ path, line, artifact, ref, new_ref }) => ({ path, line, artifact, from: ref, to: new_ref })),
215
+ left_alone: {
216
+ not_semver: plan.left_alone.not_semver.map(({ path, line, ref }) => ({ path, line, ref })),
217
+ at_target: plan.left_alone.at_target.length,
218
+ newer_than_target: plan.left_alone.newer_than_target.map(({ path, line, ref }) => ({ path, line, ref })),
219
+ },
220
+ };
221
+ }
222
+
223
+ export function formatSdkRepinReport(result) {
224
+ const lines = [];
225
+ const header = result.mode === "apply" ? "sdk repin: apply" : "sdk repin: preview (nothing written; re-run with --apply to write)";
226
+ lines.push(header);
227
+ lines.push(`Target: ${result.target_repo}`);
228
+ lines.push(`Target SDK: ${result.to_version} (${result.to_version_source === "argument" ? "--target-sdk" : `policy preferred_minimum, ${result.policy.source}`})`);
229
+ if (result.status === "refused_page_kit") {
230
+ lines.push(`Refused: ${result.message}`);
231
+ return lines.join("\n");
232
+ }
233
+ if (result.rewrites.length) {
234
+ lines.push(`${result.applied ? "Rewrote" : "Would rewrite"} ${result.rewrites.length} reference(s) in ${result.files.length} file(s):`);
235
+ for (const entry of result.rewrites) {
236
+ lines.push(` ${entry.path}:${entry.line} ${entry.url}`);
237
+ lines.push(` -> ${entry.new_url}`);
238
+ }
239
+ }
240
+ if (result.left_alone.not_semver.length) {
241
+ lines.push(`Left alone, not semver-pinned (${result.left_alone.not_semver.length}):`);
242
+ for (const entry of result.left_alone.not_semver) lines.push(` ${entry.path}:${entry.line} @${entry.ref} ${entry.url}`);
243
+ }
244
+ if (result.left_alone.newer_than_target.length) {
245
+ lines.push(`Left alone, newer than ${result.to_version} (${result.left_alone.newer_than_target.length}):`);
246
+ for (const entry of result.left_alone.newer_than_target) lines.push(` ${entry.path}:${entry.line} @${entry.ref}`);
247
+ }
248
+ if (result.left_alone.at_target.length) lines.push(`Already at ${result.to_version}: ${result.left_alone.at_target.length} reference(s).`);
249
+ if (result.status !== "changes") lines.push(result.message);
250
+ if (result.change_record.written) {
251
+ lines.push(`Change record: ${result.change_record.path} (sha256 ${result.change_record.sha256})`);
252
+ lines.push(`Re-scan: ${result.verified ? "no pinned reference below the target remains" : "references below the target remain; inspect the files"}`);
253
+ }
254
+ return lines.join("\n");
255
+ }
256
+
257
+ function statusMessage(status, toVersion) {
258
+ if (status === "no_references") return "No Campaign Cart loader or stylesheet reference was found; nothing to change.";
259
+ if (status === "nothing_to_change") return `No semver-pinned reference is below ${toVersion}; nothing to change.`;
260
+ return `Semver-pinned references below ${toVersion} can be rewritten.`;
261
+ }
262
+
263
+ function publicReference({ path, line, artifact, url, ref, version, action, new_ref, new_url }) {
264
+ return { path, line, artifact, url, ref, version, action, new_ref, new_url };
265
+ }
266
+
267
+ // `_data/campaigns.json` at the target or under any directory the scan walks
268
+ // (a page-kit project nested in a larger repo is still page-kit's pin).
269
+ function findPageKitCampaignData(target) {
270
+ return listCampaignCartScanFiles(target)
271
+ .map((file) => relPath(target, file))
272
+ .filter((path) => path === PAGE_KIT_CAMPAIGNS_SUFFIX || path.endsWith(`/${PAGE_KIT_CAMPAIGNS_SUFFIX}`))
273
+ .sort();
274
+ }
275
+
276
+ function readText(path) {
277
+ try {
278
+ return readFileSync(path, "utf8");
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
@@ -115,7 +115,7 @@ const HISTORY_IDENTITY_FIELDS = Object.freeze([
115
115
  "source_brief_material", "source_spec_material_hash", "verdict_run_id", "purchase_proof", "outputs", "evidence",
116
116
  ]);
117
117
  export const HISTORY_ARCHIVED_BY = Object.freeze(["record brief", "record spec", "record build", "record polish", "qa run", "prepare-build --force"]);
118
- export const HISTORY_REASON_CODES = Object.freeze(["brief_presentation_changed", "brief_qa_policy_changed", "spec_material_changed", "rerecorded", "force_reset"]);
118
+ export const HISTORY_REASON_CODES = Object.freeze(["brief_presentation_changed", "brief_qa_policy_changed", "spec_material_changed", "rerecorded", "force_reset", "build_output_changed"]);
119
119
  const ARCHIVED_STAGE_KEYS = Object.freeze(["assembly", "polish", "qa"]);
120
120
 
121
121
  const isCompletedStatus = (status) => typeof status === "string" && status.startsWith("completed");
@@ -48,7 +48,7 @@ import {
48
48
  import { evaluateRecordedHiddenEagerMediaCheckpoint } from "./polish-node.mjs";
49
49
  import { effectiveStageStatus, effectiveStatusIsTerminal, inputStamps, stageWriteInputs } from "./input-currency.mjs";
50
50
  import { demoteStages, recordBrief, recordSpec } from "./input-refresh.mjs";
51
- import { applyDerivedAssemblyReportSummary, archiveStageRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor } from "./stage-ledger.mjs";
51
+ import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor } from "./stage-ledger.mjs";
52
52
  import { withTargetLockSync } from "./target-lock.mjs";
53
53
  import { commerceScopeFromScope } from "./theme-gate.mjs";
54
54
 
@@ -331,9 +331,13 @@ function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment =
331
331
  // Otherwise Polish evidence bound to this exact output stays and anything
332
332
  // else is owed again; the evidence object is kept so `polish capture` has
333
333
  // somewhere to attach page_load, and its stale identity fields are removed.
334
+ // A deploy that stamped the build it probed is owed again once the output
335
+ // differs; one with no stamp (recorded before deploy stamped it) is kept.
336
+ const deployOwed = deployAfterBuild(stageObject(report, "deploy"), fingerprint, now);
337
+ const deploy = deployOwed ? { deploy: deployOwed } : {};
334
338
  if (detected) {
335
339
  const cause = detected === "spec_material_changed" ? "spec" : "presentation";
336
- const demoted = demoteStages({ ...report, stages: { ...report.stages, assembly } }, { causes: [cause], now, by: "record build", only: ["polish", "qa"] });
340
+ const demoted = demoteStages({ ...report, stages: { ...report.stages, assembly, ...deploy } }, { causes: [cause], now, by: "record build", only: ["polish", "qa"] });
337
341
  return { report: demoted.report, context: null };
338
342
  }
339
343
  const previousPolish = stageObject(report, "polish");
@@ -348,7 +352,29 @@ function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment =
348
352
  required_by: "build",
349
353
  required_for: ["qa"],
350
354
  };
351
- return { report: { ...report, stages: { ...report.stages, assembly, polish } }, context: null };
355
+ return { report: { ...report, stages: { ...report.stages, assembly, polish, ...deploy } }, context: null };
356
+ }
357
+
358
+ // stages.deploy after a build of `fingerprint`, or null when it is kept: a
359
+ // completed deploy whose source_build_fingerprint names other output goes
360
+ // whole into history (archived_by "record build", reason_code
361
+ // "build_output_changed") and reads required (required_by "build",
362
+ // required_for ["qa"]) until record deploy probes this build. As a demotion
363
+ // does, the live record loses what was written by the completion (completed_at,
364
+ // recorded_by, performed_by) and what describes the old output's probe
365
+ // (source_build_fingerprint, evidence, outputs); blockers, warnings, inputs
366
+ // and commands are kept.
367
+ function deployAfterBuild(previousDeploy, fingerprint, now) {
368
+ const stamped = optionalString(previousDeploy.source_build_fingerprint);
369
+ if (!stamped || stamped === fingerprint || !REPLACED_COMPLETED_STATUSES.includes(previousDeploy.status)) return null;
370
+ const demoted = {
371
+ ...withoutKeys(previousDeploy, ["source_build_fingerprint", "evidence", "outputs", "completed_at", "recorded_by", "performed_by"]),
372
+ stage: "deploy",
373
+ status: "required",
374
+ required_by: "build",
375
+ required_for: ["qa"],
376
+ };
377
+ return archiveSupersededRecord(demoted, previousDeploy, { by: "record build", reason: "build_output_changed", at: now });
352
378
  }
353
379
 
354
380
  function composePolish(report, { now, recordedBy, fingerprint, input, inputs = {} }) {
@@ -604,12 +630,16 @@ export async function probeLocalPreview({ packetPath, baseUrl, fetchImpl = globa
604
630
  }
605
631
 
606
632
  // A recorded local preview: the packet's deploy.preview_url, and stages.deploy
607
- // completed with the URL in outputs (what next reads) and the probe as evidence.
608
- function composeDeploy(report, packet, { now, recordedBy, probe }) {
633
+ // completed with the URL in outputs (what next reads), the probe as evidence,
634
+ // and the build it probed as source_build_fingerprint (doctorFacts has checked
635
+ // it equals stages.assembly.build_fingerprint), so a later record build of
636
+ // different output makes deploy required again, as it does Polish.
637
+ function composeDeploy(report, packet, { now, recordedBy, probe, fingerprint }) {
609
638
  const deploy = {
610
639
  ...stageObject(report, "deploy"),
611
640
  stage: "deploy",
612
641
  status: "completed",
642
+ source_build_fingerprint: fingerprint,
613
643
  outputs: [probe.url],
614
644
  evidence: probe.routes.map((route) => `${route.url} answered HTTP ${route.status}`),
615
645
  completed_at: now,
@@ -862,6 +892,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
862
892
  ...(facts.fingerprint ? [`build output fingerprint ${facts.fingerprint} (doctor derived.build_output_fingerprint.value)`] : []),
863
893
  ...(stage === "build" && buildEnvironment ? [`stages.assembly.evidence.build_environment = ${buildEnvironment}`] : []),
864
894
  ...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
895
+ ...(stage === "build" && composed.report.stages.deploy?.required_by === "build" ? [`stages.deploy.status = ${composed.report.stages.deploy.status} (the recorded deploy probed other output; run ${cmd("record")} deploy again)`] : []),
865
896
  ...(composed.context ? ["Build Context scaffold.required = false"] : []),
866
897
  ];
867
898
  return {
@@ -938,7 +969,7 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
938
969
  : stage === "theme"
939
970
  ? composeTheme(report, { now: timestamp, recordedBy, layer })
940
971
  : stage === "deploy"
941
- ? composeDeploy(report, packet, { now: timestamp, recordedBy, probe: input })
972
+ ? composeDeploy(report, packet, { now: timestamp, recordedBy, probe: input, fingerprint: facts.fingerprint })
942
973
  : composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input, inputs: recordInputs(doctor) });
943
974
  applyDerivedAssemblyReportSummary(next.report, recordInputs(doctor));
944
975
  // The packet's one new value, deploy.preview_url, is checked by
@@ -24,6 +24,7 @@ import {
24
24
  loadSdkSupportPolicy,
25
25
  scanCampaignCartAppRoot,
26
26
  } from "./campaign-ecosystem.mjs";
27
+ import { isReleasedSdkVersion } from "../campaign-spec/dist/index.js";
27
28
  import { resolveCommerceCatalog } from "./private-template-source.mjs";
28
29
  import {
29
30
  assessTemplateFreshness,
@@ -375,7 +376,9 @@ function scanPageKitRoot({
375
376
  const bindingFiles = sourceScan.binding_files;
376
377
  let checkoutFields = null;
377
378
  if (bindingFiles.length) {
378
- checkoutFields = inspectCheckoutFields(rootPath, bindingFiles, contract, contractFindings);
379
+ checkoutFields = inspectCheckoutFields(rootPath, bindingFiles, contract, contractFindings, {
380
+ sdkVersionForFile: campaignsSdkVersionResolver(campaigns.slugs),
381
+ });
379
382
  capabilities.push("checkout_field_contract");
380
383
  }
381
384
  const root = {
@@ -504,6 +507,28 @@ function isPageKitRoot(dir) {
504
507
  return Boolean(findPageKitDependency(pkg.value));
505
508
  }
506
509
 
510
+ // The SDK version a Page Kit source file's checkout bindings run against: its
511
+ // campaign's campaigns.json sdk_version (src/<slug>/...), else, for a file no
512
+ // one campaign owns, the lowest pin across campaigns. Unknown (null) when that
513
+ // pin is missing or not an exact release (isReleasedSdkVersion, as doctor
514
+ // reads the same pin), or when any campaign lacks one; the field contract
515
+ // judges unknown as an earlier SDK.
516
+ function campaignsSdkVersionResolver(slugs) {
517
+ const exact = new Map(slugs.map((entry) => [entry.slug, isReleasedSdkVersion(entry.sdk_version) ? entry.sdk_version : null]));
518
+ const pins = [...exact.values()];
519
+ const lowest = pins.length && pins.every(Boolean)
520
+ ? { version: [...pins].sort(compareVersions)[0], source: "campaigns_json" }
521
+ : null;
522
+ return (path) => {
523
+ const [top, slug] = String(path).split("/");
524
+ if (top === "src" && exact.has(slug)) {
525
+ const version = exact.get(slug);
526
+ return version ? { version, source: "campaigns_json" } : null;
527
+ }
528
+ return lowest;
529
+ };
530
+ }
531
+
507
532
  function readCampaigns(rootPath) {
508
533
  const campaignsPath = join(rootPath, "_data", "campaigns.json");
509
534
  if (!existsSync(campaignsPath)) {
@@ -21,9 +21,17 @@ export function targetLockPath(targetRepo) {
21
21
  // `command` names the waiting command. The lock does not record which command
22
22
  // holds it, only a pid, so the holder is described generically. A lock with
23
23
  // no owner record is called out on its own: it is never taken over, and the
24
- // operator needs to know it will not clear by waiting.
24
+ // operator needs to know it will not clear by waiting. So is anything at the
25
+ // lock path that is not a lock at all, named by what it is (#514).
25
26
  function unavailable(targetRepo, lockPath, command) {
26
27
  return (error) => {
28
+ if (error?.code === "ENOTLOCK") {
29
+ return Object.assign(new Error(
30
+ `${command}: the target lock path ${lockPath} is occupied by ${error.obstruction}. `
31
+ + "It is never removed automatically. Move it out of the way, then retry.",
32
+ { cause: error },
33
+ ), { code: error.code, obstruction: error.obstruction });
34
+ }
27
35
  if (error?.code !== "EEXIST") {
28
36
  return new Error(`${command} could not take the target lock at ${lockPath}${error?.code ? ` (${error.code})` : ""}: ${error?.message}`, { cause: error });
29
37
  }