@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.
- package/CHANGELOG.md +61 -0
- package/README.md +1 -0
- package/compatibility.json +1 -1
- package/contracts/campaign-cart-checkout-field-contract.v0.json +10 -0
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +27 -27
- package/contracts/effects.v1.json +96 -37
- package/contracts/release-ledger.json +333 -0
- package/contracts/supported-surface.json +3 -3
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/build-packet.md +11 -4
- package/docs/campaign-standardization-report.md +32 -0
- package/docs/design-source-package.md +7 -3
- package/docs/effects.md +2 -1
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/progress-snapshots.md +3 -1
- package/docs/qa-and-test-orders.md +28 -3
- package/docs/runtime-readiness.md +1 -1
- package/docs/sdk-storage-compatibility.md +15 -0
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -0
- package/package.json +1 -1
- package/schemas/campaign-runtime-assembly-report.v0.schema.json +5 -1
- package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
- package/skills/campaign-readback-classification/SKILL.md +3 -3
- package/skills/campaign-run-evidence/SKILL.md +3 -3
- package/skills/contribution-intake/SKILL.md +3 -3
- package/skills/next-campaigns-build/SKILL.md +3 -3
- package/skills/next-campaigns-os/SKILL.md +3 -3
- package/skills/next-campaigns-os-setup/SKILL.md +3 -3
- package/skills/next-campaigns-polish/SKILL.md +3 -3
- package/skills/next-campaigns-qa/SKILL.md +3 -3
- package/skills.json +10 -10
- package/src/campaign-ecosystem.mjs +153 -31
- package/src/cli.mjs +22 -3
- package/src/directory-lock.mjs +79 -7
- package/src/doctor/checks.mjs +12 -2
- package/src/doctor/inspect.mjs +13 -0
- package/src/invocation.mjs +3 -1
- package/src/progress-node.mjs +3 -2
- package/src/qa-browser.mjs +80 -9
- package/src/qa-node.mjs +33 -4
- package/src/sdk-attribute-index.mjs +111 -23
- package/src/sdk-markup.mjs +49 -10
- package/src/sdk-repin.mjs +282 -0
- package/src/stage-ledger.mjs +1 -1
- package/src/stage-record.mjs +37 -6
- package/src/standardization-report.mjs +26 -1
- 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
|
+
}
|
package/src/stage-ledger.mjs
CHANGED
|
@@ -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");
|
package/src/stage-record.mjs
CHANGED
|
@@ -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)
|
|
608
|
-
|
|
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)) {
|
package/src/target-lock.mjs
CHANGED
|
@@ -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
|
}
|