@nextcommerce/campaigns-os 1.61.0 → 1.62.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/AGENTS.md +2 -1
- package/CHANGELOG.md +118 -0
- package/README.md +86 -438
- package/agents/claude/CLAUDE.md +9 -4
- package/agents/codex/AGENTS.md +8 -4
- package/agents/copilot/copilot-instructions.md +8 -4
- package/agents/cursor/campaigns-os.mdc +10 -4
- package/campaign-spec/README.md +7 -0
- package/campaign-spec/dist/analytics-vendors.d.ts +40 -0
- package/campaign-spec/dist/analytics-vendors.js +44 -0
- package/campaign-spec/dist/checkout-flow.d.ts +18 -0
- package/campaign-spec/dist/checkout-flow.js +28 -0
- package/campaign-spec/dist/index.d.ts +4 -2
- package/campaign-spec/dist/index.js +5 -1
- package/campaign-spec/dist/rules/analytics-contract-shape.d.ts +8 -0
- package/campaign-spec/dist/rules/analytics-contract-shape.js +39 -0
- package/campaign-spec/dist/rules/campaign-currencies.d.ts +29 -0
- package/campaign-spec/dist/rules/campaign-currencies.js +130 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.d.ts +31 -0
- package/campaign-spec/dist/rules/checkout-path-has-packages.js +79 -0
- package/campaign-spec/dist/rules/index.d.ts +2 -0
- package/campaign-spec/dist/rules/index.js +6 -0
- package/campaign-spec/dist/types.d.ts +43 -9
- package/compatibility.json +1 -1
- package/contracts/commerce-surface-catalog.json +59 -53
- package/contracts/effects.v1.json +549 -165
- package/contracts/fixtures/runtime-recipe/accept/current.json +3 -0
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +3 -0
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +3 -0
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +3 -0
- package/contracts/release-ledger.json +999 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +3 -0
- package/contracts/supported-surface.json +8 -8
- package/contracts/template-brand-contract.shared-commerce.v0.json +23 -1
- package/docs/build-packet.md +430 -37
- package/docs/campaign-standardization-report.md +4 -4
- package/docs/campaigns-os-build-flow.md +54 -3
- package/docs/campaignspec-authoring-examples.md +29 -4
- package/docs/design-source-package.md +18 -6
- package/docs/effects.md +17 -9
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/polish-evidence.md +2 -0
- package/docs/progress-snapshots.md +7 -4
- package/docs/qa-and-test-orders.md +147 -1
- package/docs/readback.md +40 -5
- package/docs/runtime-readiness.md +6 -3
- package/docs/sdk-storage-compatibility.md +1 -1
- package/docs/skills-revision.md +10 -10
- package/package.json +1 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +9 -0
- package/schemas/campaign-runtime-build-packet.v0.schema.json +9 -0
- package/schemas/campaign-spec.v4.schema.json +19 -3
- package/schemas/campaign-spec.v5.schema.json +19 -3
- package/schemas/campaigns-os-readback.v2.schema.json +62 -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 +4 -3
- package/skills/next-campaigns-os/SKILL.md +6 -4
- package/skills/next-campaigns-os-setup/SKILL.md +5 -3
- package/skills/next-campaigns-polish/SKILL.md +5 -4
- package/skills/next-campaigns-qa/SKILL.md +7 -4
- package/skills/next-campaigns-sdk-bump/SKILL.md +42 -9
- package/skills.json +11 -11
- package/src/brand-theme.mjs +2 -1
- package/src/build-brief.mjs +4 -1
- package/src/campaign-workspace.mjs +288 -15
- package/src/change-scope.mjs +284 -0
- package/src/cli.mjs +308 -86
- package/src/commercial-journey.mjs +16 -3
- package/src/commercial-parity.mjs +15 -7
- package/src/content-residue.mjs +4 -2
- package/src/design-source-package.mjs +36 -0
- package/src/design-source-publication.mjs +9 -6
- package/src/directory-lock.mjs +73 -19
- package/src/doctor/checks.mjs +79 -7
- package/src/doctor/ci.mjs +75 -10
- package/src/doctor/inspect.mjs +23 -6
- package/src/doctor/next-step.mjs +7 -0
- package/src/doctor-sidecar.mjs +3 -2
- package/src/evidence-debt.mjs +47 -7
- package/src/finding-cause.mjs +12 -11
- package/src/input-refresh.mjs +339 -32
- package/src/invocation.mjs +7 -1
- package/src/live-campaign-refs.mjs +7 -2
- package/src/local-preview-policy.mjs +35 -2
- package/src/page-kit-analytics-vendors.mjs +132 -0
- package/src/polish-browser.mjs +3 -1
- package/src/polish-readability.mjs +221 -9
- package/src/polish-visual.mjs +9 -2
- package/src/progress-node.mjs +3 -2
- package/src/qa-analytics-parity.mjs +17 -1
- package/src/qa-browser.mjs +40 -4
- package/src/qa-commercial-parity.mjs +50 -3
- package/src/qa-node.mjs +196 -16
- package/src/qa-publish.mjs +9 -8
- package/src/qa-sidecar.mjs +16 -4
- package/src/qa-vendor-beacons.mjs +232 -0
- package/src/qa-vendor-endpoints.mjs +306 -0
- package/src/qc-accept.mjs +29 -1
- package/src/qc-check-registry.mjs +5 -1
- package/src/qc-results.mjs +73 -26
- package/src/readback.mjs +58 -10
- package/src/route-identity.mjs +10 -0
- package/src/run-record.mjs +18 -10
- package/src/runtime-state-ignore.mjs +6 -2
- package/src/sdk-storage-compatibility.mjs +21 -2
- package/src/source-html-intake.mjs +15 -1
- package/src/spec-derive-campaign.mjs +216 -0
- package/src/spec-derive.mjs +473 -53
- package/src/spec-identity.mjs +14 -0
- package/src/stage-ledger.mjs +40 -1
- package/src/stage-record.mjs +58 -13
- package/src/target-lock.mjs +11 -1
package/src/spec-identity.mjs
CHANGED
|
@@ -19,6 +19,20 @@ export function specMaterialHash(spec) {
|
|
|
19
19
|
return `sha256:${createHash("sha256").update(canonicalJson(materialSpec(spec))).digest("hex")}`;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// The spec material with every page's page_url left out (funnels[].pages[]
|
|
23
|
+
// and the legacy funnel_pages[] mirror). Intake and record spec record it in
|
|
24
|
+
// the Build Context, so record spec can tell a change of routes alone from
|
|
25
|
+
// one that also touched anything else the build reads (#531).
|
|
26
|
+
export function routeMaskedSpecMaterialHash(spec) {
|
|
27
|
+
if (!spec || typeof spec !== "object" || Array.isArray(spec)) return specMaterialHash(spec);
|
|
28
|
+
const masked = JSON.parse(JSON.stringify(spec));
|
|
29
|
+
const lists = [...(Array.isArray(masked.funnels) ? masked.funnels.map((funnel) => funnel?.pages) : []), masked.funnel_pages];
|
|
30
|
+
for (const pages of lists) {
|
|
31
|
+
for (const page of Array.isArray(pages) ? pages : []) if (page && typeof page === "object") delete page.page_url;
|
|
32
|
+
}
|
|
33
|
+
return specMaterialHash(masked);
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
// The three pure comparators live in `./spec-hash.mjs`, a dependency-free
|
|
23
37
|
// leaf, so that browser-targeted consumers of the public
|
|
24
38
|
// `./commercial-journey` export can reach them without pulling this module's
|
package/src/stage-ledger.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
NEXT_STAGE_CONTRACTS,
|
|
12
12
|
NEXT_STAGE_OWNERS,
|
|
13
13
|
stageIsBlocked,
|
|
14
|
+
stageIsTerminal,
|
|
14
15
|
} from "./orchestration-stage-contract.mjs";
|
|
15
16
|
|
|
16
17
|
const PRODUCER_STAGES = new Set(["doctor", "qa"]);
|
|
@@ -226,6 +227,20 @@ export function restorePolishKeptWithOutput(polish, { fingerprint, sourcePackage
|
|
|
226
227
|
return { ...restored, stage: "polish", ...stamps };
|
|
227
228
|
}
|
|
228
229
|
|
|
230
|
+
/**
|
|
231
|
+
* True when the campaign has been handed off (#721): its deploy or its QA was
|
|
232
|
+
* completed, as the live record or as a record the stage's history keeps
|
|
233
|
+
* (a deploy or QA made owed again by a later change still happened). From
|
|
234
|
+
* then on the repo, not the design source, is the authority for which pages
|
|
235
|
+
* the funnel has, so `spec derive` adds and removes pages from the page tree
|
|
236
|
+
* and `record spec` binds that change without intake.
|
|
237
|
+
*/
|
|
238
|
+
export function campaignHandedOff(report) {
|
|
239
|
+
const completedIn = (stage) => isPlainObject(stage) && (isCompletedStatus(stage.status)
|
|
240
|
+
|| (Array.isArray(stage.history) && stage.history.some((entry) => isPlainObject(entry) && isCompletedStatus(entry.status))));
|
|
241
|
+
return completedIn(report?.stages?.deploy) || completedIn(report?.stages?.qa);
|
|
242
|
+
}
|
|
243
|
+
|
|
229
244
|
/**
|
|
230
245
|
* True when any stage of `report` carries a non-empty history: recorded
|
|
231
246
|
* operator-visible state an intake must not silently drop.
|
|
@@ -573,6 +588,30 @@ export function qaRecordedBuildFingerprint(report) {
|
|
|
573
588
|
|| optionalString(report?.stages?.qa?.evidence?.source_build_fingerprint);
|
|
574
589
|
}
|
|
575
590
|
|
|
591
|
+
// #720: the build whose completed QA a page-scoped run extends. A completed
|
|
592
|
+
// QA record extends its own build. A page-scoped run that did not complete
|
|
593
|
+
// extends the build of the full QA it was based on, which was completed when
|
|
594
|
+
// that run was accepted. Any other record (blocked or pending full QA, a
|
|
595
|
+
// skip, a required stage) extends nothing, so it owes a full QA run.
|
|
596
|
+
const QA_EXTENDABLE_STATUSES = Object.freeze(["completed", "completed_with_warnings"]);
|
|
597
|
+
export function qaExtendedBuildFingerprint(report) {
|
|
598
|
+
const qa = report?.stages?.qa;
|
|
599
|
+
if (QA_EXTENDABLE_STATUSES.includes(qa?.status)) return qaRecordedBuildFingerprint(report);
|
|
600
|
+
if (qa?.status === "blocked") return optionalString(qa?.evidence?.page_scope?.base_build_fingerprint);
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// The build Polish last reviewed: the output a terminal Polish is bound to,
|
|
605
|
+
// or, once a rebuild has made it required, the build that rebuild kept for
|
|
606
|
+
// it. A blocked or pending Polish reviewed nothing.
|
|
607
|
+
export function polishReviewedBuild(polish) {
|
|
608
|
+
if (stageIsTerminal(String(polish?.status || ""))) return optionalString(polish.source_build_fingerprint);
|
|
609
|
+
// A brief or spec change since then (input_change, which only a restored
|
|
610
|
+
// or new Polish clears) owes the full review, whatever the build change.
|
|
611
|
+
if (Object.hasOwn(polish || {}, "input_change")) return null;
|
|
612
|
+
return polish?.status === "required" && polish.required_by === "build" ? optionalString(polish.owed_since_build_fingerprint) : null;
|
|
613
|
+
}
|
|
614
|
+
|
|
576
615
|
export function qaRecordedForCurrentBuild(report, buildFingerprint) {
|
|
577
616
|
const recorded = qaRecordedBuildFingerprint(report);
|
|
578
617
|
const current = optionalString(buildFingerprint);
|
|
@@ -716,7 +755,7 @@ function commitAssemblyReportUnderLock(workspace, mutate, {
|
|
|
716
755
|
const doctor = refreshDoctor(outcome);
|
|
717
756
|
if (doctor !== null && doctor !== undefined) writeDoctorSidecar(doctorOutPath, doctor, { command: command.trim(), targetRepo });
|
|
718
757
|
} else if (outcome.written) {
|
|
719
|
-
markDoctorSidecarStale(targetRepo, { command: command.trim(), reason: staleReason.trim() });
|
|
758
|
+
markDoctorSidecarStale(targetRepo, { command: command.trim(), reason: staleReason.trim(), ...(doctorOutPath ? { path: doctorOutPath } : {}) });
|
|
720
759
|
}
|
|
721
760
|
return outcome;
|
|
722
761
|
};
|
package/src/stage-record.mjs
CHANGED
|
@@ -36,7 +36,9 @@ import { isObject, optionalString, portableArtifactPaths, readJsonIfExists, requ
|
|
|
36
36
|
import { LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT, isLocalServePacket } from "./local-proof.mjs";
|
|
37
37
|
import { polishCarriedForwardForLadder } from "./local-preview-policy.mjs";
|
|
38
38
|
import { isLoopbackHostname } from "./remit.mjs";
|
|
39
|
-
import { campaignRouteRoot } from "./route-identity.mjs";
|
|
39
|
+
import { campaignRouteRoot, normalizePublicRouteSlug } from "./route-identity.mjs";
|
|
40
|
+
import { buildChangeEntry, buildCommerceSignature, nextBuildChanges } from "./change-scope.mjs";
|
|
41
|
+
import { assessEvidenceDebt } from "./evidence-debt.mjs";
|
|
40
42
|
import { writeJsonAtomic } from "./doctor-sidecar.mjs";
|
|
41
43
|
import { doctorPacket } from "./doctor/inspect.mjs";
|
|
42
44
|
import { validateAssemblyReport } from "./doctor/checks.mjs";
|
|
@@ -52,7 +54,7 @@ import {
|
|
|
52
54
|
import { evaluateRecordedHiddenEagerMediaCheckpoint } from "./polish-node.mjs";
|
|
53
55
|
import { effectiveStageStatus, effectiveStatusIsTerminal, inputStamps, stageWriteInputs } from "./input-currency.mjs";
|
|
54
56
|
import { demoteStages, recordBrief, recordSpec } from "./input-refresh.mjs";
|
|
55
|
-
import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor, restorePolishKeptWithOutput } from "./stage-ledger.mjs";
|
|
57
|
+
import { applyDerivedAssemblyReportSummary, archiveStageRecord, archiveSupersededRecord, assemblyReportMatchesPacket, commitAssemblyReport, inputChangeFor, polishReviewedBuild, qaExtendedBuildFingerprint, qaRecordedBuildFingerprint, restorePolishKeptWithOutput } from "./stage-ledger.mjs";
|
|
56
58
|
import { withTargetLockSync } from "./target-lock.mjs";
|
|
57
59
|
import { commerceScopeFromScope } from "./theme-gate.mjs";
|
|
58
60
|
|
|
@@ -330,17 +332,39 @@ function currentStamps(inputs) {
|
|
|
330
332
|
return Object.fromEntries(Object.entries(stamps).filter(([, value]) => value !== null));
|
|
331
333
|
}
|
|
332
334
|
|
|
333
|
-
|
|
335
|
+
// #707/#720: what this rebuild changed against the build recorded before it,
|
|
336
|
+
// classified page-only or commerce-affecting, appended to the kept history so
|
|
337
|
+
// a stage that reviewed an older build can still read its changed pages; and
|
|
338
|
+
// the commerce signature of this output, for the next rebuild to compare
|
|
339
|
+
// against. Read from the same packet and output doctor fingerprinted.
|
|
340
|
+
function buildChangeFacts(report, { packet, packetPath, fingerprint, now }) {
|
|
341
|
+
if (!packet || !packetPath) return {};
|
|
342
|
+
const previousAssembly = stageObject(report, "assembly");
|
|
343
|
+
const targetRepo = targetRepoFor(packetPath, packet);
|
|
344
|
+
const slug = normalizePublicRouteSlug(packet?.campaign?.public_route_slug);
|
|
345
|
+
const outputRoot = join(targetRepo, "_site", slug);
|
|
346
|
+
const signature = buildCommerceSignature({ scope: resolveBuiltSiteScope(outputRoot), outputRoot, targetRepo, publicRouteSlug: slug });
|
|
347
|
+
const from = optionalString(previousAssembly.build_fingerprint);
|
|
348
|
+
if (!from || from === fingerprint) return { signature, buildChanges: previousAssembly.build_changes };
|
|
349
|
+
const debt = assessEvidenceDebt({ packet, packetPath, report });
|
|
350
|
+
const entry = buildChangeEntry({ from, to: fingerprint, at: now, changes: debt.changes,
|
|
351
|
+
classification: { kind: debt.changes.kind, reasons: debt.changes.reasons } });
|
|
352
|
+
const anchors = [qaRecordedBuildFingerprint(report), qaExtendedBuildFingerprint(report), polishReviewedBuild(stageObject(report, "polish"))];
|
|
353
|
+
return { signature, buildChanges: nextBuildChanges(previousAssembly.build_changes, entry, anchors) };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function composeBuild(report, { now, recordedBy, fingerprint, manifest = null, buildEnvironment = null, adapterDecisions = null, fallbackAdapterDecisions = null, inputs = {}, deviationReason = null, packet = null, packetPath = null }) {
|
|
334
357
|
const nextReport = adapterDecisions ? { ...report, adapter_decisions: { ...(report.adapter_decisions || fallbackAdapterDecisions || {}), ...adapterDecisions } } : report;
|
|
335
358
|
const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
|
|
336
359
|
const previousAssembly = stageObject(report, "assembly");
|
|
360
|
+
const { signature, buildChanges } = buildChangeFacts(report, { packet, packetPath, fingerprint, now });
|
|
337
361
|
const stamps = currentStamps(inputs);
|
|
338
362
|
// A replaced completed record whose stamps differ from the current inputs
|
|
339
363
|
// records the change now, if nothing recorded it before.
|
|
340
364
|
const writeInputs = stageWriteInputs("assembly", inputs);
|
|
341
365
|
const detected = REPLACED_COMPLETED_STATUSES.includes(previousAssembly.status) ? writeInputs.detectChange(previousAssembly) : null;
|
|
342
366
|
let assembly = {
|
|
343
|
-
...withoutKeys(previousAssembly, ["source_package_material_fingerprint", "unchanged_output_reason", "build_manifest", ...STAMP_FIELDS]),
|
|
367
|
+
...withoutKeys(previousAssembly, ["source_package_material_fingerprint", "unchanged_output_reason", "build_manifest", "commerce_signature", "build_changes", ...STAMP_FIELDS]),
|
|
344
368
|
...(buildEnvironment ? { evidence: { ...(isObject(previousAssembly.evidence) ? previousAssembly.evidence : {}), build_environment: buildEnvironment } } : {}),
|
|
345
369
|
stage: "assembly",
|
|
346
370
|
status: "completed",
|
|
@@ -349,6 +373,14 @@ function composeBuild(report, { now, recordedBy, fingerprint, manifest = null, b
|
|
|
349
373
|
// (doctor's built_output.fingerprint_stale, polish capture) can name the
|
|
350
374
|
// files that differ. Left out when it cannot be read back as this value.
|
|
351
375
|
...(manifest ? { build_manifest: manifest } : {}),
|
|
376
|
+
// One hash per built page over its commerce-bearing markup, and one of
|
|
377
|
+
// the campaign's Page Kit config entry, so the next change can be read
|
|
378
|
+
// as page-only or commerce-affecting (change-scope.mjs).
|
|
379
|
+
...(signature ? { commerce_signature: signature } : {}),
|
|
380
|
+
// The rebuilds since the builds Polish and QA last reviewed, each with
|
|
381
|
+
// its changed pages and classification, so the changed-page list
|
|
382
|
+
// survives this record (readback evidence_debt.<stage>.change).
|
|
383
|
+
...(Array.isArray(buildChanges) && buildChanges.length ? { build_changes: buildChanges } : {}),
|
|
352
384
|
...(sourcePackageFingerprint ? { source_package_material_fingerprint: sourcePackageFingerprint } : {}),
|
|
353
385
|
...stamps,
|
|
354
386
|
completed_at: now,
|
|
@@ -393,14 +425,18 @@ function composeBuild(report, { now, recordedBy, fingerprint, manifest = null, b
|
|
|
393
425
|
if (keptPolish) return { report: { ...nextReport, stages: { ...nextReport.stages, assembly, polish: keptPolish, ...deploy } }, context: null };
|
|
394
426
|
const polishStillCurrent = stageIsTerminal(String(previousPolish.status || ""))
|
|
395
427
|
&& optionalString(previousPolish.source_build_fingerprint) === fingerprint;
|
|
428
|
+
// The build Polish reviewed stays named once its binding is cleared, so a
|
|
429
|
+
// page-only change since then reads as advisory (evidence-debt.mjs).
|
|
430
|
+
const reviewed = polishReviewedBuild(previousPolish);
|
|
396
431
|
const polish = polishStillCurrent
|
|
397
432
|
? previousPolish
|
|
398
433
|
: {
|
|
399
|
-
...withoutKeys(previousPolish, ["performed_by", "source_build_fingerprint", "source_package_material_fingerprint", "completed_at", "recorded_by", ...STAMP_FIELDS]),
|
|
434
|
+
...withoutKeys(previousPolish, ["performed_by", "source_build_fingerprint", "source_package_material_fingerprint", "completed_at", "recorded_by", "owed_since_build_fingerprint", ...STAMP_FIELDS]),
|
|
400
435
|
stage: "polish",
|
|
401
436
|
status: "required",
|
|
402
437
|
required_by: "build",
|
|
403
438
|
required_for: ["qa"],
|
|
439
|
+
...(reviewed ? { owed_since_build_fingerprint: reviewed } : {}),
|
|
404
440
|
};
|
|
405
441
|
return { report: { ...nextReport, stages: { ...nextReport.stages, assembly, polish, ...deploy } }, context: null };
|
|
406
442
|
}
|
|
@@ -456,7 +492,7 @@ function composePolish(report, { now, recordedBy, fingerprint, input, inputs = {
|
|
|
456
492
|
: previous.evidence;
|
|
457
493
|
const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
|
|
458
494
|
let polish = {
|
|
459
|
-
...withoutKeys(previous, ["source_package_material_fingerprint", "completed_at", "skip_reason", "evidence", ...STAMP_FIELDS]),
|
|
495
|
+
...withoutKeys(previous, ["source_package_material_fingerprint", "completed_at", "skip_reason", "evidence", "owed_since_build_fingerprint", ...STAMP_FIELDS]),
|
|
460
496
|
stage: "polish",
|
|
461
497
|
status: input.status,
|
|
462
498
|
performed_by: POLISH_PRODUCER,
|
|
@@ -519,12 +555,12 @@ const routeKey = (route) => String(route || "").replace(/^\/+|\/+$/g, "");
|
|
|
519
555
|
// Context's theme.generated.fonts_css_path (theme generate --out-dir moves
|
|
520
556
|
// it); the default path is read only when the context names none. Any page
|
|
521
557
|
// that breaks the rule is a refusal naming the page.
|
|
522
|
-
function brandLayerFacts(doctor, context = null) {
|
|
558
|
+
function brandLayerFacts(doctor, context = null, runtimeDir = resolve(doctor.derived?.target_repo || ".", ".campaign-runtime")) {
|
|
523
559
|
const derived = doctor.derived || {};
|
|
524
560
|
const recordedFonts = context?.theme?.generated?.fonts_css_path;
|
|
525
561
|
const generatedFonts = typeof recordedFonts === "string" && recordedFonts
|
|
526
562
|
? resolve(derived.target_repo, recordedFonts)
|
|
527
|
-
: (context?.theme?.generated ? null : resolve(
|
|
563
|
+
: (context?.theme?.generated ? null : resolve(runtimeDir, "theme", BRAND_FONTS_FILENAME));
|
|
528
564
|
const fontsRequired = Boolean(generatedFonts) && existsSync(generatedFonts);
|
|
529
565
|
const insideSite = (file) => {
|
|
530
566
|
const rel = relative(site.site_root, file);
|
|
@@ -616,10 +652,10 @@ function brandLayerFacts(doctor, context = null) {
|
|
|
616
652
|
: `No built commerce page loads ${CORE_STYLESHEET} (${unstyled.join(", ")}${outOfScope.length ? `; declared but not built: ${outOfScope.join(", ")}` : ""}), so no page renders family components for a brand layer to style. If shipping without one is intended, record that with ${cmd("theme")} waive.`]);
|
|
617
653
|
}
|
|
618
654
|
const cssPath = relative(derived.target_repo, layers[0]);
|
|
619
|
-
const generated = resolve(
|
|
655
|
+
const generated = resolve(runtimeDir, "theme/brand-theme.css");
|
|
620
656
|
if (existsSync(generated)) {
|
|
621
657
|
const same = readFileSync(generated, "utf8") === readFileSync(layers[0], "utf8");
|
|
622
|
-
evidence.push(`${cssPath} ${same ? "matches" : "differs from"} the generated .
|
|
658
|
+
evidence.push(`${cssPath} ${same ? "matches" : "differs from"} the generated ${relative(derived.target_repo, generated).split(sep).join("/")}.`);
|
|
623
659
|
}
|
|
624
660
|
return {
|
|
625
661
|
cssPath,
|
|
@@ -1148,6 +1184,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
1148
1184
|
...(stage === "build" && Array.isArray(composed.report.stages.assembly.build_manifest) ? [`stages.assembly.build_manifest lists ${composed.report.stages.assembly.build_manifest.length} file(s), so a later fingerprint mismatch names the paths that differ`] : []),
|
|
1149
1185
|
...(stage === "build" && buildEnvironment ? [`stages.assembly.evidence.build_environment = ${buildEnvironment}`] : []),
|
|
1150
1186
|
...(stage === "build" && adapterDecisions ? Object.entries(adapterDecisions).map(([key, value]) => `report.adapter_decisions.${key} = ${value}`) : []),
|
|
1187
|
+
...(stage === "build" ? buildChangeReadyLines(composed.report.stages.assembly) : []),
|
|
1151
1188
|
...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
|
|
1152
1189
|
...(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)`] : []),
|
|
1153
1190
|
...(composed.context ? ["Build Context scaffold.required = false"] : []),
|
|
@@ -1163,7 +1200,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
1163
1200
|
...(dryRun ? { would_write: writes } : { written: writes }),
|
|
1164
1201
|
build_fingerprint: facts.fingerprint || null,
|
|
1165
1202
|
// The manifest is kept in the report, not echoed: it lists every built file.
|
|
1166
|
-
record: stage === "theme" ? composed.report.theme : stage === "build" ? withoutKeys(composed.report.stages.assembly, ["build_manifest"]) : composed.report.stages[stageKey],
|
|
1203
|
+
record: stage === "theme" ? composed.report.theme : stage === "build" ? withoutKeys(composed.report.stages.assembly, ["build_manifest", "commerce_signature"]) : composed.report.stages[stageKey],
|
|
1167
1204
|
...(stage === "build" ? { polish: composed.report.stages.polish } : {}),
|
|
1168
1205
|
...(composed.context ? { scaffold: composed.context.scaffold } : {}),
|
|
1169
1206
|
...(stage === "polish" && input.hasRepairLoopDefect ? { repair_loop_defect: input.repairLoopDefect } : {}),
|
|
@@ -1175,6 +1212,14 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
1175
1212
|
};
|
|
1176
1213
|
}
|
|
1177
1214
|
|
|
1215
|
+
// One line for the rebuild this record kept, when it replaced another build.
|
|
1216
|
+
function buildChangeReadyLines(assembly) {
|
|
1217
|
+
const latest = Array.isArray(assembly.build_changes) ? assembly.build_changes.at(-1) : null;
|
|
1218
|
+
if (!latest || latest.build_fingerprint !== assembly.build_fingerprint) return [];
|
|
1219
|
+
const pages = latest.changed_pages.map((page) => page.page_id).join(", ") || "none";
|
|
1220
|
+
return [`stages.assembly.build_changes: this rebuild is ${latest.kind.replace("_", "-")} (changed pages: ${pages}${latest.unmapped_file_count ? `; ${latest.unmapped_file_count} file(s) outside any page` : ""})`];
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1178
1223
|
// Everything a record reads from the target, in order, all under the target
|
|
1179
1224
|
// lock (except a dry run, which writes nothing): the packet and the Build
|
|
1180
1225
|
// Context's report pointer (the workspace), the report, the Build Context
|
|
@@ -1218,12 +1263,12 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
|
|
|
1218
1263
|
}
|
|
1219
1264
|
facts = doctorFacts(stage, doctor, report, packet);
|
|
1220
1265
|
if (typeof afterDoctorRead === "function") afterDoctorRead();
|
|
1221
|
-
if (stage === "theme") layer = brandLayerFacts(doctor, readJsonIfExists(contextPath));
|
|
1266
|
+
if (stage === "theme") layer = brandLayerFacts(doctor, readJsonIfExists(contextPath), workspace.runtimeDir);
|
|
1222
1267
|
if (stage === "deploy") deployFacts(doctor, packet, input, facts.fingerprint);
|
|
1223
1268
|
const next = stage === "setup"
|
|
1224
1269
|
? composeSetup(report, context, { now: timestamp, recordedBy })
|
|
1225
1270
|
: stage === "build"
|
|
1226
|
-
? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, manifest: outputManifest(facts), buildEnvironment, adapterDecisions, fallbackAdapterDecisions: adapterDecisions && !report.adapter_decisions ? readJsonIfExists(contextPath)?.adapter_decisions || packet.source_html?.adapter_contract : null, inputs: recordInputs(doctor), deviationReason })
|
|
1271
|
+
? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, manifest: outputManifest(facts), buildEnvironment, adapterDecisions, fallbackAdapterDecisions: adapterDecisions && !report.adapter_decisions ? readJsonIfExists(contextPath)?.adapter_decisions || packet.source_html?.adapter_contract : null, inputs: recordInputs(doctor), deviationReason, packet, packetPath })
|
|
1227
1272
|
: stage === "theme"
|
|
1228
1273
|
? composeTheme(report, { now: timestamp, recordedBy, layer })
|
|
1229
1274
|
: stage === "deploy"
|
package/src/target-lock.mjs
CHANGED
|
@@ -22,9 +22,19 @@ export function targetLockPath(targetRepo) {
|
|
|
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
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
|
|
25
|
+
// lock path that is not a lock at all, named by what it is, and a lock held
|
|
26
|
+
// by a live process of another campaigns-os release, which is refused at once
|
|
27
|
+
// (#514).
|
|
26
28
|
function unavailable(targetRepo, lockPath, command) {
|
|
27
29
|
return (error) => {
|
|
30
|
+
if (error?.code === "ELOCKFORMAT") {
|
|
31
|
+
return Object.assign(new Error(
|
|
32
|
+
`${command}: the target lock at ${lockPath} is held by process ${error.holderPid}, which runs a different campaigns-os release. `
|
|
33
|
+
+ "Two releases taking this lock at the same moment can both get it, so this run stops instead of waiting. "
|
|
34
|
+
+ `Let that run finish, then retry with one campaigns-os release working on ${targetRepo} at a time.`,
|
|
35
|
+
{ cause: error },
|
|
36
|
+
), { code: error.code, holderPid: error.holderPid, lockFormat: error.lockFormat });
|
|
37
|
+
}
|
|
28
38
|
if (error?.code === "ENOTLOCK") {
|
|
29
39
|
return Object.assign(new Error(
|
|
30
40
|
`${command}: the target lock path ${lockPath} is occupied by ${error.obstruction}. `
|