@nextcommerce/campaigns-os 1.55.0 → 1.59.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 +124 -0
- package/CONTEXT.md +11 -0
- package/campaign-spec/README.md +15 -1
- package/campaign-spec/dist/checkout-flow.d.ts +97 -0
- package/campaign-spec/dist/checkout-flow.js +245 -0
- package/campaign-spec/dist/index.d.ts +1 -0
- package/campaign-spec/dist/index.js +6 -1
- package/campaign-spec/dist/normalize.d.ts +3 -2
- package/campaign-spec/dist/normalize.js +7 -5
- package/campaign-spec/dist/routing.d.ts +6 -3
- package/campaign-spec/dist/routing.js +6 -3
- package/campaign-spec/dist/rules/checkout-forward-target.d.ts +17 -0
- package/campaign-spec/dist/rules/checkout-forward-target.js +48 -0
- package/campaign-spec/dist/rules/checkout-step-reaches-checkout.d.ts +12 -0
- package/campaign-spec/dist/rules/checkout-step-reaches-checkout.js +37 -0
- package/campaign-spec/dist/rules/exit-intent-validation.js +2 -1
- package/campaign-spec/dist/rules/index.d.ts +4 -0
- package/campaign-spec/dist/rules/index.js +12 -0
- package/campaign-spec/dist/rules/one-checkout-per-path.d.ts +25 -0
- package/campaign-spec/dist/rules/one-checkout-per-path.js +84 -0
- package/campaign-spec/dist/rules/pre-payment-forward-target.d.ts +12 -0
- package/campaign-spec/dist/rules/pre-payment-forward-target.js +42 -0
- package/campaign-spec/dist/rules/promo-code-input-validation.js +2 -1
- package/campaign-spec/dist/rules/schema-version.d.ts +9 -4
- package/campaign-spec/dist/rules/schema-version.js +10 -5
- package/campaign-spec/dist/types.d.ts +20 -3
- package/campaign-spec/dist/types.js +3 -2
- package/compatibility.json +2 -2
- package/contracts/campaign-cart-sdk-support-policy.v0.json +2 -2
- package/contracts/commerce-surface-catalog.json +26 -27
- package/contracts/effects.v1.json +5 -5
- package/contracts/fixtures/runtime-recipe/accept/current.json +6 -1
- package/contracts/fixtures/runtime-recipe/accept/minimal.json +6 -1
- package/contracts/fixtures/runtime-recipe/dist-states.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/advisory-enforcement.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/allowlist-without-hosts.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/committed-output-claim.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/engines-disagreement-warns.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/lifecycle-scripts-enabled.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/missing-required-field.json +1 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-kind.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-network-policy.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-output-check.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unknown-revision.json +5 -0
- package/contracts/fixtures/runtime-recipe/reject/unknown-step-id.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unperformable-check-skipped.json +6 -1
- package/contracts/fixtures/runtime-recipe/reject/unpinned-lockfile.json +6 -1
- package/contracts/release-ledger.json +1137 -0
- package/contracts/runtime-recipe.campaigns-os-node-v1.json +6 -1
- package/contracts/supported-surface.json +9 -5
- package/contracts/template-brand-contract.shared-commerce.v0.json +1 -1
- package/docs/build-packet.md +68 -10
- package/docs/local-setup.md +1 -1
- package/docs/orientation-contract-reference.md +1 -1
- package/docs/qa-and-test-orders.md +124 -22
- package/docs/runtime-readiness.md +8 -3
- package/docs/skills-revision.md +10 -10
- package/docs/supported-surface.md +1 -1
- package/docs/versioning.md +4 -1
- package/package.json +2 -1
- package/schemas/campaign-runtime-build-context.v0.schema.json +4 -1
- package/schemas/campaign-runtime-build-packet.v0.schema.json +4 -1
- package/schemas/campaign-spec.v5.schema.json +586 -0
- package/skills/campaign-lifecycle-orientation/SKILL.md +10 -8
- 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 +6 -4
- 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 +21 -6
- package/skills/next-campaigns-qa/SKILL.md +4 -3
- package/skills.json +10 -10
- package/src/adapter-decision-contract.mjs +1 -1
- package/src/build-brief.mjs +14 -5
- package/src/built-site-scope.mjs +91 -0
- package/src/cli-helpers.mjs +129 -0
- package/src/cli.mjs +117 -57
- package/src/commercial-journey.mjs +25 -2
- package/src/design-source-package.mjs +1 -1
- package/src/design-source-publication.mjs +7 -7
- package/src/doctor/checks.mjs +269 -16
- package/src/doctor/inspect.mjs +4 -2
- package/src/doctor/next-step.mjs +18 -9
- package/src/doctor-sidecar.mjs +5 -3
- package/src/gate-actions.mjs +21 -1
- package/src/input-refresh.mjs +6 -6
- package/src/local-preview-policy.mjs +110 -12
- package/src/polish-node.mjs +8 -7
- package/src/qa-browser.mjs +363 -36
- package/src/qa-cart-entry.mjs +183 -14
- package/src/qa-node.mjs +38 -34
- package/src/qa-test-order-topology.mjs +118 -1
- package/src/qa-verdict-discovery.mjs +21 -4
- package/src/qa-verdict.mjs +24 -1
- package/src/runtime-state-ignore.mjs +7 -5
- package/src/sdk-attribute-index.mjs +8 -7
- package/src/source-html-intake.mjs +58 -7
- package/src/stage-ledger.mjs +4 -2
- package/src/stage-record.mjs +203 -36
package/src/qa-verdict.mjs
CHANGED
|
@@ -256,7 +256,8 @@ export function summarizePurchaseProof({ verdict = null, proofPolicy = null } =
|
|
|
256
256
|
return {
|
|
257
257
|
declared_order_path_depth: optionalString(proofPolicy?.order_path_depth),
|
|
258
258
|
declared_typed_card_depth: optionalString(proofPolicy?.typed_card_depth),
|
|
259
|
-
order_paths_executed: orders.length,
|
|
259
|
+
order_paths_executed: orders.filter((order) => !isTestOrderRetry(order)).length,
|
|
260
|
+
test_order_attempts: orders.length,
|
|
260
261
|
orders_created: created.length,
|
|
261
262
|
orders_verified: orders.filter((order) => order.verification?.verified === true).length,
|
|
262
263
|
// null, not false, when nothing ran: "no order was out of test mode" and
|
|
@@ -265,6 +266,28 @@ export function summarizePurchaseProof({ verdict = null, proofPolicy = null } =
|
|
|
265
266
|
};
|
|
266
267
|
}
|
|
267
268
|
|
|
269
|
+
// A re-run of a planned path is its own test_orders[] entry with `attempt` 2;
|
|
270
|
+
// it is an attempt, not another path. Entries written before `attempt` existed
|
|
271
|
+
// each count as a path, which is what they were counted as then.
|
|
272
|
+
function isTestOrderRetry(order) {
|
|
273
|
+
const attempt = Number(order?.attempt);
|
|
274
|
+
return Number.isInteger(attempt) && attempt > 1;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function plural(count, noun) {
|
|
278
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// The human summary line for a run that drove typed-card test orders: planned
|
|
282
|
+
// paths, attempts (a re-run is a second attempt at the same path) and orders
|
|
283
|
+
// the platform created, kept apart so a path that was retried is not read as
|
|
284
|
+
// two paths against the --max-test-orders cap. null when no order path ran.
|
|
285
|
+
export function formatTestOrderSummary(verdict) {
|
|
286
|
+
const summary = summarizePurchaseProof({ verdict });
|
|
287
|
+
if (!summary.test_order_attempts) return null;
|
|
288
|
+
return `Test orders: ${plural(summary.order_paths_executed, "path")}, ${plural(summary.test_order_attempts, "attempt")}, ${plural(summary.orders_created, "order")} created`;
|
|
289
|
+
}
|
|
290
|
+
|
|
268
291
|
// The browser placeholder-text residue gate (H3.1) emits one assertion per
|
|
269
292
|
// page as `template-residue:<page_id>:placeholder-text`. Both the emitter
|
|
270
293
|
// (qa-browser.mjs) and the stage-ledger summary below build the id from this
|
|
@@ -29,6 +29,7 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
|
|
|
29
29
|
".campaign-runtime/workflow-findings.jsonl",
|
|
30
30
|
".campaign-runtime/run-records/",
|
|
31
31
|
".campaign-runtime/fetched-specs/",
|
|
32
|
+
".campaign-runtime/progress/",
|
|
32
33
|
".campaign-runtime/polish-evidence/",
|
|
33
34
|
".campaign-runtime/evidence/",
|
|
34
35
|
// Full QA verdicts carry live storefront URLs and order references; the
|
|
@@ -41,11 +42,12 @@ export const RUNTIME_STATE_IGNORED_PATHS = [
|
|
|
41
42
|
|
|
42
43
|
// Entries the toolkit adds to an existing managed block when they are
|
|
43
44
|
// missing from the file. The block is otherwise the operator's to edit, but
|
|
44
|
-
// these name directories the toolkit itself fills with
|
|
45
|
-
//
|
|
46
|
-
// next setup command rather than on the next accidental
|
|
47
|
-
// operator has placed anywhere else in the file counts as
|
|
48
|
-
|
|
45
|
+
// these name directories the toolkit itself fills with campaign observations,
|
|
46
|
+
// live URLs, or order references. A target set up before the entry existed
|
|
47
|
+
// must gain it on the next setup command rather than on the next accidental
|
|
48
|
+
// commit. An entry an operator has placed anywhere else in the file counts as
|
|
49
|
+
// present.
|
|
50
|
+
export const RUNTIME_STATE_REQUIRED_IGNORED_PATHS = ["qa-output/", ".campaign-runtime/progress/"];
|
|
49
51
|
|
|
50
52
|
export const RUNTIME_STATE_IGNORE_BLOCK = [
|
|
51
53
|
RUNTIME_STATE_IGNORE_MARKER,
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// checks (#303).
|
|
3
3
|
//
|
|
4
4
|
// Source: the SDK's own source in NextCommerceCo/campaign-cart at tag
|
|
5
|
-
// v0.4.
|
|
5
|
+
// v0.4.42 (the SDK pin every certified starter family ships today): every
|
|
6
6
|
// data-next-* name the SDK's non-test TypeScript names as a string, plus every
|
|
7
7
|
// `dataset.next*` property it reads or writes, turned back into its
|
|
8
8
|
// attribute. Only the data-next-* names are kept: the checks that read this
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
// whole from the source at a named tag, never edited by hand.
|
|
16
16
|
//
|
|
17
17
|
// paths=('src/**/*.ts' ':!src/**/*.test.ts' ':!src/**/tests/**' ':!src/docs/**')
|
|
18
|
-
// { git -C ../campaign-cart grep -h -oP 'data-next-[a-z0-9]+(?:-[a-z0-9]+)*-?' v0.4.
|
|
19
|
-
// git -C ../campaign-cart grep -h -oP 'dataset\.next[A-Z][A-Za-z0-9]*' v0.4.
|
|
18
|
+
// { git -C ../campaign-cart grep -h -oP 'data-next-[a-z0-9]+(?:-[a-z0-9]+)*-?' v0.4.42 -- "${paths[@]}"
|
|
19
|
+
// git -C ../campaign-cart grep -h -oP 'dataset\.next[A-Z][A-Za-z0-9]*' v0.4.42 -- "${paths[@]}" \
|
|
20
20
|
// | sed 's/^dataset\.//' | perl -pe 's/([A-Z])/-\L$1/g; s/^/data-/'
|
|
21
21
|
// } | sort -u
|
|
22
22
|
//
|
|
@@ -30,14 +30,15 @@
|
|
|
30
30
|
// manifests never did (the address block's, data-next-i18n,
|
|
31
31
|
// data-next-bump-section, data-next-button-text, the variant option state).
|
|
32
32
|
// At v0.4.41 it returns every name of the v0.4.38 list this replaces, plus
|
|
33
|
-
// those and data-next-phone-e164 / data-next-phone-country.
|
|
33
|
+
// those and data-next-phone-e164 / data-next-phone-country. At v0.4.42 it
|
|
34
|
+
// returns the same 149 names as at v0.4.41.
|
|
34
35
|
//
|
|
35
36
|
// A name ending in "-" (data-next-class-) is a prefix the SDK reads with any
|
|
36
37
|
// suffix.
|
|
37
38
|
|
|
38
39
|
import { RELEASED_SDK_VERSION_PATTERN } from "../campaign-spec/dist/index.js";
|
|
39
40
|
|
|
40
|
-
export const SDK_ATTRIBUTE_INDEX_VERSION = "0.4.
|
|
41
|
+
export const SDK_ATTRIBUTE_INDEX_VERSION = "0.4.42";
|
|
41
42
|
|
|
42
43
|
export const SDK_DATA_NEXT_ATTRIBUTES = Object.freeze([
|
|
43
44
|
"data-next-accordion",
|
|
@@ -364,8 +365,8 @@ export const SDK_TEMPLATE_PLACEHOLDERS = Object.freeze({
|
|
|
364
365
|
});
|
|
365
366
|
|
|
366
367
|
// The SDK versions whose renderer files above were verified unchanged from
|
|
367
|
-
// v0.4.38 (git blob ids equal at v0.4.38, v0.4.39, v0.4.40 and v0.4.
|
|
368
|
+
// v0.4.38 (git blob ids equal at v0.4.38, v0.4.39, v0.4.40, v0.4.41 and v0.4.42, for
|
|
368
369
|
// the eight renderer files cited above plus cart-item-list.enhancer.ts and
|
|
369
370
|
// order-item-list.enhancer.ts). A page whose loader pins another version
|
|
370
371
|
// cannot pass the placeholder check.
|
|
371
|
-
export const SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS = Object.freeze(["0.4.38", "0.4.39", "0.4.40", "0.4.41"]);
|
|
372
|
+
export const SDK_TEMPLATE_PLACEHOLDERS_VERIFIED_PINS = Object.freeze(["0.4.38", "0.4.39", "0.4.40", "0.4.41", "0.4.42"]);
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
} from "./source-html-manifest.mjs";
|
|
5
5
|
// Built output of campaign-spec (same import shape as src/page-kit-sdk-version.mjs),
|
|
6
6
|
// so build-time wiring and spec-time analysis share one edge resolver.
|
|
7
|
-
import { declineRouteTarget, forwardRouteTarget } from "../campaign-spec/dist/index.js";
|
|
7
|
+
import { checkoutPathFrom, declineRouteTarget, forwardRouteTarget, forwardTargetPage, isCheckoutStepPage, isPrePaymentPage } from "../campaign-spec/dist/index.js";
|
|
8
8
|
import { isAbsoluteHttpUrl, normalizePageKitRoute, normalizePublicRouteSlug, stripPublicRoutePrefix } from "./route-identity.mjs";
|
|
9
9
|
|
|
10
10
|
const CPK_PAGE_TYPES = new Set(["product", "checkout", "upsell", "receipt"]);
|
|
@@ -30,7 +30,7 @@ export function createSourceHtmlIntake({
|
|
|
30
30
|
const mappings = matched.mappings.map((mapping) => {
|
|
31
31
|
const specPage = pageById.get(mapping.page_id);
|
|
32
32
|
if (!specPage || !mapping.path) return mapping;
|
|
33
|
-
const pageKit = pageKitProjectionForPage(specPage, { pageById, publicRouteSlug, outputDir });
|
|
33
|
+
const pageKit = pageKitProjectionForPage(specPage, { pageById, publicRouteSlug, outputDir, specPages: specPages || [] });
|
|
34
34
|
projectionDecisions.push({
|
|
35
35
|
id: `dec_page_kit_target_${specPage.id}`,
|
|
36
36
|
stage: "prepare_build",
|
|
@@ -559,7 +559,7 @@ function addSpecHints(mapping, page) {
|
|
|
559
559
|
if (variantLabels) mapping.variant_labels = variantLabels;
|
|
560
560
|
}
|
|
561
561
|
|
|
562
|
-
function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir }) {
|
|
562
|
+
function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir, specPages = [] }) {
|
|
563
563
|
const specRoute = publicRouteForPage(page);
|
|
564
564
|
const relativeRoute = stripPublicRoutePrefix(specRoute, publicRouteSlug);
|
|
565
565
|
const publicRoute = rootedCampaignRoute(relativeRoute, publicRouteSlug);
|
|
@@ -570,10 +570,12 @@ function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir }
|
|
|
570
570
|
const frontmatter = { page_type: pageType };
|
|
571
571
|
if (permalinkRequired) frontmatter.permalink = publicRoute;
|
|
572
572
|
|
|
573
|
-
const
|
|
574
|
-
|
|
573
|
+
const forwardUrl = nextUrlForPage(page, pageById, publicRouteSlug);
|
|
574
|
+
const checkoutFlow = checkoutFlowFrontmatter(page, forwardUrl, { specPages, pageById, publicRouteSlug });
|
|
575
|
+
if (checkoutFlow) Object.assign(frontmatter, checkoutFlow);
|
|
576
|
+
else if (forwardUrl) frontmatter.next_url = forwardUrl;
|
|
575
577
|
const declineUrl = declineUrlForPage(page, pageById, publicRouteSlug);
|
|
576
|
-
if (declineUrl && declineUrl !==
|
|
578
|
+
if (declineUrl && declineUrl !== forwardUrl) frontmatter.decline_url = declineUrl;
|
|
577
579
|
|
|
578
580
|
return {
|
|
579
581
|
target_path: targetPath,
|
|
@@ -586,6 +588,51 @@ function pageKitProjectionForPage(page, { pageById, publicRouteSlug, outputDir }
|
|
|
586
588
|
};
|
|
587
589
|
}
|
|
588
590
|
|
|
591
|
+
// Multi-step checkout wiring (campaigns-os#641) for a select or checkout_step
|
|
592
|
+
// page on a path to a Checkout. The keys follow the starter-template contract
|
|
593
|
+
// (campaign-cart-starter-templates#223). Null for any other page, whose
|
|
594
|
+
// next_url stays its own forward link: a landing page's call-to-action reads
|
|
595
|
+
// next_url, so pointing it past the Checkout would skip payment.
|
|
596
|
+
// next_url where a completed order goes: the Checkout's post-payment
|
|
597
|
+
// destination (its own next_url), the meaning next_url has on a
|
|
598
|
+
// single-page checkout. The page's next-success-url meta tag
|
|
599
|
+
// carries it, because the SDK reads that tag for express orders
|
|
600
|
+
// placed on this page; an express order on step 1 must land on
|
|
601
|
+
// the first upsell, not on step 2.
|
|
602
|
+
// next_step this page's forward link: a step form's
|
|
603
|
+
// data-next-checkout-step, a select page's checkout link.
|
|
604
|
+
// step_number on a checkout_step only: its position among the steps of its
|
|
605
|
+
// path, from 1, for the form's data-next-step-number.
|
|
606
|
+
function checkoutFlowFrontmatter(page, forwardUrl, { specPages, pageById, publicRouteSlug }) {
|
|
607
|
+
// A path of two or more pages means the page has a resolvable forward link,
|
|
608
|
+
// so forwardUrl is set; the guard keeps the pair together regardless, never
|
|
609
|
+
// emitting the Checkout's destination without a next_step to navigate by.
|
|
610
|
+
if (!isPrePaymentPage(page) || !forwardUrl) return null;
|
|
611
|
+
const path = checkoutPathFrom(specPages, page);
|
|
612
|
+
if (!path || path.length < 2) return null;
|
|
613
|
+
const out = {};
|
|
614
|
+
const successUrl = nextUrlForPage(path[path.length - 1], pageById, publicRouteSlug);
|
|
615
|
+
if (successUrl) out.next_url = successUrl;
|
|
616
|
+
out.next_step = forwardUrl;
|
|
617
|
+
if (isCheckoutStepPage(page)) out.step_number = checkoutStepNumber(page, specPages);
|
|
618
|
+
return out;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function checkoutStepNumber(page, specPages) {
|
|
622
|
+
let number = 1;
|
|
623
|
+
const seen = new Set([page]);
|
|
624
|
+
let current = page;
|
|
625
|
+
for (;;) {
|
|
626
|
+
const previous = specPages.find((candidate) => !seen.has(candidate)
|
|
627
|
+
&& isCheckoutStepPage(candidate)
|
|
628
|
+
&& forwardTargetPage(specPages, candidate) === current);
|
|
629
|
+
if (!previous) return number;
|
|
630
|
+
number += 1;
|
|
631
|
+
seen.add(previous);
|
|
632
|
+
current = previous;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
589
636
|
function pageKitTargetPrompts(mappings) {
|
|
590
637
|
const prompts = [];
|
|
591
638
|
const byOutputPath = new Map();
|
|
@@ -664,12 +711,15 @@ function cpkPageTypeForSpecType(type) {
|
|
|
664
711
|
if (type === "presell" || type === "landing") return "product";
|
|
665
712
|
if (type === "thankyou" || type === "receipt") return "receipt";
|
|
666
713
|
if (type === "upsell" || type === "downsell") return "upsell";
|
|
667
|
-
|
|
714
|
+
// select, checkout_step and checkout all render the SDK checkout surface
|
|
715
|
+
// (campaigns-os#641); only the Checkout places the order.
|
|
716
|
+
if (type === "checkout" || type === "checkout_step" || type === "select") return "checkout";
|
|
668
717
|
return CPK_PAGE_TYPES.has(type) ? type : "product";
|
|
669
718
|
}
|
|
670
719
|
|
|
671
720
|
function defaultRouteForSpecType(type) {
|
|
672
721
|
if (type === "thankyou" || type === "receipt") return "receipt/";
|
|
722
|
+
if (type === "checkout_step") return "checkout-step/";
|
|
673
723
|
if (["presell", "landing", "checkout", "upsell", "downsell"].includes(type)) return `${type}/`;
|
|
674
724
|
return `${type || "page"}/`;
|
|
675
725
|
}
|
|
@@ -696,6 +746,7 @@ function pageMatchKeys(page, ordinal) {
|
|
|
696
746
|
}
|
|
697
747
|
if (page.type === "landing" || page.type === "presell") keys.add("index");
|
|
698
748
|
if (page.type === "checkout") keys.add("checkout");
|
|
749
|
+
if (page.type === "checkout_step") keys.add("checkout-step");
|
|
699
750
|
if (page.type === "upsell") keys.add("upsell");
|
|
700
751
|
if (page.type === "downsell") keys.add("downsell");
|
|
701
752
|
return [...keys];
|
package/src/stage-ledger.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { STATUS as QA_STATUS } from "./qa-verdict.mjs";
|
|
|
5
5
|
import { assessInputCurrency, currentPacketInputs, effectiveStageStatus, effectiveStatusIsTerminal, wellFormedBriefMaterial } from "./input-currency.mjs";
|
|
6
6
|
import { isPlainObject, normalizeString as optionalString } from "./repo-scan.mjs";
|
|
7
7
|
import { withTargetLockSync } from "./target-lock.mjs";
|
|
8
|
+
import { portableArtifactPaths } from "./cli-helpers.mjs";
|
|
8
9
|
import {
|
|
9
10
|
ASSEMBLY_REPORT_STAGE_KEYS,
|
|
10
11
|
NEXT_STAGE_CONTRACTS,
|
|
@@ -687,7 +688,7 @@ function commitAssemblyReportUnderLock(workspace, mutate, {
|
|
|
687
688
|
const finish = () => {
|
|
688
689
|
if (hasRefresh) {
|
|
689
690
|
const doctor = refreshDoctor(outcome);
|
|
690
|
-
if (doctor !== null && doctor !== undefined) writeDoctorSidecar(doctorOutPath, doctor, { command: command.trim() });
|
|
691
|
+
if (doctor !== null && doctor !== undefined) writeDoctorSidecar(doctorOutPath, doctor, { command: command.trim(), targetRepo });
|
|
691
692
|
} else if (outcome.written) {
|
|
692
693
|
markDoctorSidecarStale(targetRepo, { command: command.trim(), reason: staleReason.trim() });
|
|
693
694
|
}
|
|
@@ -731,7 +732,8 @@ function commitAssemblyReportUnderLock(workspace, mutate, {
|
|
|
731
732
|
// (every mutator in this repo returns a copy), so the restatement is in
|
|
732
733
|
// place rather than a second deep clone. Stages are read at their effective
|
|
733
734
|
// status against the inputs the workspace's packet names now.
|
|
734
|
-
const
|
|
735
|
+
const nextReport = applyDerivedAssemblyReportSummary(mutated, workspaceInputs(workspace));
|
|
736
|
+
const next = targetRepo ? portableArtifactPaths(nextReport, targetRepo, { artifactPath: reportPath }) : nextReport;
|
|
735
737
|
if (stage && producerStageOutcomeUnchanged(report, next, stage)) {
|
|
736
738
|
outcome.skipped = "unchanged";
|
|
737
739
|
return finish();
|
package/src/stage-record.mjs
CHANGED
|
@@ -17,19 +17,22 @@
|
|
|
17
17
|
// polish` validate the context they read but write only the report. `record
|
|
18
18
|
// theme` writes `report.theme` only after reading, in each built commerce
|
|
19
19
|
// page, that a brand layer stylesheet is linked after next-core.css. `record
|
|
20
|
-
// deploy` records a
|
|
21
|
-
// stages.deploy, after every built page answers on the served loopback
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
// deploy` records a served deploy: the packet's deploy.preview_url and
|
|
21
|
+
// stages.deploy, after every built page answers on the served URL (loopback
|
|
22
|
+
// for a local-serve packet; https for a hosted one, whose served scripts and
|
|
23
|
+
// stylesheets must also match the built output byte for byte).
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
26
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
24
27
|
import { fileURLToPath } from "node:url";
|
|
25
28
|
|
|
26
29
|
import Ajv2020 from "ajv/dist/2020.js";
|
|
27
30
|
|
|
28
31
|
import { BRAND_LAYER_FILENAMES } from "./brand-theme.mjs";
|
|
29
32
|
import { ADAPTER_DECISION_SCALAR_VALUES } from "./adapter-decision-contract.mjs";
|
|
30
|
-
import { computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
|
|
33
|
+
import { buildManifestEntries, computeBuildFingerprint, resolveBuiltSiteScope } from "./built-site-scope.mjs";
|
|
31
34
|
import { resolveCampaignWorkspace, targetRepoFor } from "./campaign-workspace.mjs";
|
|
32
|
-
import { isObject, optionalString, readJsonIfExists, requireArg } from "./cli-helpers.mjs";
|
|
35
|
+
import { isObject, optionalString, portableArtifactPaths, readJsonIfExists, requireArg } from "./cli-helpers.mjs";
|
|
33
36
|
import { LOCAL_PROOF_BUILD_ENVIRONMENT, LOCAL_PROOF_PRODUCTION_ENVIRONMENT, isLocalServePacket } from "./local-proof.mjs";
|
|
34
37
|
import { polishCarriedForwardForLadder } from "./local-preview-policy.mjs";
|
|
35
38
|
import { isLoopbackHostname } from "./remit.mjs";
|
|
@@ -327,7 +330,7 @@ function currentStamps(inputs) {
|
|
|
327
330
|
return Object.fromEntries(Object.entries(stamps).filter(([, value]) => value !== null));
|
|
328
331
|
}
|
|
329
332
|
|
|
330
|
-
function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment = null, adapterDecisions = null, fallbackAdapterDecisions = null, inputs = {}, deviationReason = null }) {
|
|
333
|
+
function composeBuild(report, { now, recordedBy, fingerprint, manifest = null, buildEnvironment = null, adapterDecisions = null, fallbackAdapterDecisions = null, inputs = {}, deviationReason = null }) {
|
|
331
334
|
const nextReport = adapterDecisions ? { ...report, adapter_decisions: { ...(report.adapter_decisions || fallbackAdapterDecisions || {}), ...adapterDecisions } } : report;
|
|
332
335
|
const sourcePackageFingerprint = currentSourcePackageMaterialFingerprint(report);
|
|
333
336
|
const previousAssembly = stageObject(report, "assembly");
|
|
@@ -337,11 +340,15 @@ function composeBuild(report, { now, recordedBy, fingerprint, buildEnvironment =
|
|
|
337
340
|
const writeInputs = stageWriteInputs("assembly", inputs);
|
|
338
341
|
const detected = REPLACED_COMPLETED_STATUSES.includes(previousAssembly.status) ? writeInputs.detectChange(previousAssembly) : null;
|
|
339
342
|
let assembly = {
|
|
340
|
-
...withoutKeys(previousAssembly, ["source_package_material_fingerprint", "unchanged_output_reason", ...STAMP_FIELDS]),
|
|
343
|
+
...withoutKeys(previousAssembly, ["source_package_material_fingerprint", "unchanged_output_reason", "build_manifest", ...STAMP_FIELDS]),
|
|
341
344
|
...(buildEnvironment ? { evidence: { ...(isObject(previousAssembly.evidence) ? previousAssembly.evidence : {}), build_environment: buildEnvironment } } : {}),
|
|
342
345
|
stage: "assembly",
|
|
343
346
|
status: "completed",
|
|
344
347
|
build_fingerprint: fingerprint,
|
|
348
|
+
// The path + sha256 list the fingerprint hashes, so a later mismatch
|
|
349
|
+
// (doctor's built_output.fingerprint_stale, polish capture) can name the
|
|
350
|
+
// files that differ. Left out when it cannot be read back as this value.
|
|
351
|
+
...(manifest ? { build_manifest: manifest } : {}),
|
|
345
352
|
...(sourcePackageFingerprint ? { source_package_material_fingerprint: sourcePackageFingerprint } : {}),
|
|
346
353
|
...stamps,
|
|
347
354
|
completed_at: now,
|
|
@@ -587,30 +594,143 @@ function composeTheme(report, { now, recordedBy, layer }) {
|
|
|
587
594
|
return { report: { ...report, theme }, context: null };
|
|
588
595
|
}
|
|
589
596
|
|
|
590
|
-
// The
|
|
591
|
-
//
|
|
592
|
-
//
|
|
593
|
-
// (origin
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
+
// The served preview a `record deploy` URL must name, as recorded (origin
|
|
598
|
+
// plus the packet's route root, "/<slug>/" or "/" for a root-served
|
|
599
|
+
// campaign), and the problems that refuse it. A local-serve packet names a
|
|
600
|
+
// loopback http(s) origin. A hosted packet names an https origin unless the
|
|
601
|
+
// host is loopback, where http is accepted too.
|
|
602
|
+
function deployPreviewUrl(packet, rawUrl) {
|
|
603
|
+
const hosted = !isLocalServePacket(packet);
|
|
604
|
+
if (hosted && !optionalString(packet?.deploy?.target)) {
|
|
605
|
+
return { url: null, hosted, problems: ["This packet records no deploy.target; set it (local-serve, or the hosting target) with " + `${cmd("qa")} policy set --packet <p> --deploy-target <target>.`] };
|
|
597
606
|
}
|
|
598
607
|
let url;
|
|
599
608
|
try {
|
|
600
609
|
url = new URL(String(rawUrl));
|
|
601
610
|
} catch {
|
|
602
|
-
return { url: null, problems: [`--base-url ${JSON.stringify(rawUrl)} is not a URL; give the served address, for example http://localhost:<port>/<slug
|
|
611
|
+
return { url: null, hosted, problems: [`--base-url ${JSON.stringify(rawUrl)} is not a URL; give the served address, for example ${hosted ? "https://<preview host>/<slug>/" : "http://localhost:<port>/<slug>/"}.`] };
|
|
603
612
|
}
|
|
604
613
|
const problems = [];
|
|
605
614
|
if (!/^https?:$/.test(url.protocol)) problems.push(`--base-url must be http or https (got ${url.protocol}).`);
|
|
606
|
-
if (!isLoopbackHostname(url.hostname)) problems.push(`--base-url ${url.href} is not a loopback address; a local preview is served on localhost, 127.0.0.1 or [::1].`);
|
|
615
|
+
else if (!hosted && !isLoopbackHostname(url.hostname)) problems.push(`--base-url ${url.href} is not a loopback address; a local preview (deploy.target local-serve) is served on localhost, 127.0.0.1 or [::1].`);
|
|
616
|
+
else if (hosted && url.protocol !== "https:" && !isLoopbackHostname(url.hostname)) problems.push(`--base-url ${url.href} is plain http; a hosted deploy (deploy.target ${packet.deploy.target}) is recorded over https.`);
|
|
607
617
|
const routeRoot = campaignRouteRoot(packet);
|
|
608
618
|
if (!routeRoot) {
|
|
609
619
|
problems.push("The packet records no campaign.public_route_slug, so the served route root is unknown; record it first.");
|
|
610
620
|
} else if (`/${routeKey(url.pathname.replace(/\/index\.html?$/i, "/"))}/`.replace("//", "/") !== routeRoot) {
|
|
611
621
|
problems.push(`--base-url ${url.href} serves ${url.pathname}, but this campaign's route root is ${routeRoot}; give ${url.origin}${routeRoot}.`);
|
|
612
622
|
}
|
|
613
|
-
return { url: problems.length ? null : `${url.origin}${routeRoot}`, problems };
|
|
623
|
+
return { url: problems.length ? null : `${url.origin}${routeRoot}`, hosted, problems };
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function scriptSrcs(html) {
|
|
627
|
+
return [...html.matchAll(/<script\b[^>]*>/gi)]
|
|
628
|
+
.map((match) => (match[0].match(/\bsrc\s*=\s*["']([^"']+)["']/i) || [])[1])
|
|
629
|
+
.filter(Boolean);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const sha256Of = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
633
|
+
|
|
634
|
+
// The scripts and stylesheets the built pages load from the preview's own
|
|
635
|
+
// origin and the build output holds: each one's served URL, its file under
|
|
636
|
+
// _site/ (the host serves _site/ as its origin root) and that file's sha256.
|
|
637
|
+
// A reference to another origin, or to a path the build output does not hold,
|
|
638
|
+
// is not the build's to compare.
|
|
639
|
+
function builtAssets(site, pageUrls) {
|
|
640
|
+
const assets = new Map();
|
|
641
|
+
site.pages.forEach((page, index) => {
|
|
642
|
+
const html = readFileSync(page.built_path, "utf8");
|
|
643
|
+
const pageUrl = new URL(pageUrls[index]);
|
|
644
|
+
for (const ref of [...scriptSrcs(html), ...stylesheetHrefs(html)]) {
|
|
645
|
+
let served;
|
|
646
|
+
try {
|
|
647
|
+
served = new URL(ref, pageUrl);
|
|
648
|
+
} catch {
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (served.origin !== pageUrl.origin) continue;
|
|
652
|
+
served.hash = "";
|
|
653
|
+
let pathname;
|
|
654
|
+
try {
|
|
655
|
+
pathname = decodeURIComponent(served.pathname);
|
|
656
|
+
} catch {
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
const file = resolve(site.site_root, `.${pathname}`);
|
|
660
|
+
if (!file.startsWith(`${resolve(site.site_root)}${sep}`) || !existsSync(file) || !statSync(file).isFile()) continue;
|
|
661
|
+
if (!assets.has(served.href)) {
|
|
662
|
+
const bytes = readFileSync(file);
|
|
663
|
+
assets.set(served.href, { url: served.href, file, sha256: sha256Of(bytes), size: bytes.byteLength });
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
return [...assets.values()];
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// A file's sha256, or null when it cannot be read (removed, or replaced by a
|
|
671
|
+
// directory, since it was listed).
|
|
672
|
+
function fileSha256(file) {
|
|
673
|
+
try {
|
|
674
|
+
return sha256Of(readFileSync(file));
|
|
675
|
+
} catch {
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// A served body, read no further than `limit` bytes: a response that says or
|
|
681
|
+
// streams more than the built file holds cannot be that file, so the read
|
|
682
|
+
// stops there instead of buffering whatever the host sends.
|
|
683
|
+
async function readCapped(response, limit) {
|
|
684
|
+
const declared = Number(response.headers?.get?.("content-length"));
|
|
685
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
686
|
+
await response.body?.cancel?.();
|
|
687
|
+
return { bytes: null, error: `Content-Length ${declared} exceeds the built file's ${limit} bytes` };
|
|
688
|
+
}
|
|
689
|
+
const reader = response.body?.getReader?.();
|
|
690
|
+
// No readable body is a transport failure, not an empty file, unless the
|
|
691
|
+
// built file is itself empty.
|
|
692
|
+
if (!reader) return limit === 0 ? { bytes: Buffer.alloc(0) } : { bytes: null, error: "the response had no readable body" };
|
|
693
|
+
const chunks = [];
|
|
694
|
+
let total = 0;
|
|
695
|
+
for (;;) {
|
|
696
|
+
const { done, value } = await reader.read();
|
|
697
|
+
if (done) break;
|
|
698
|
+
total += value.byteLength;
|
|
699
|
+
if (total > limit) {
|
|
700
|
+
await reader.cancel();
|
|
701
|
+
return { bytes: null, error: `served more than the built file's ${limit} bytes` };
|
|
702
|
+
}
|
|
703
|
+
chunks.push(Buffer.from(value));
|
|
704
|
+
}
|
|
705
|
+
return { bytes: Buffer.concat(chunks) };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// One served asset, compared by bytes: it must answer 200, following
|
|
709
|
+
// redirects only within the preview's own origin as a page request does.
|
|
710
|
+
async function probeAsset(asset, fetchImpl) {
|
|
711
|
+
let current = asset.url;
|
|
712
|
+
try {
|
|
713
|
+
for (let hop = 0; hop <= PROBE_MAX_REDIRECTS; hop += 1) {
|
|
714
|
+
const response = await fetchImpl(current, { redirect: "manual", signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) });
|
|
715
|
+
const location = response.headers?.get?.("location");
|
|
716
|
+
if (response.status >= 300 && response.status < 400 && location) {
|
|
717
|
+
await response.body?.cancel?.();
|
|
718
|
+
const next = new URL(location, current);
|
|
719
|
+
if (next.origin !== new URL(asset.url).origin) return { ...asset, served: null, error: `redirects to ${next.href}, off this preview; nothing was requested there` };
|
|
720
|
+
current = next.href;
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
if (response.status !== 200) {
|
|
724
|
+
await response.body?.cancel?.();
|
|
725
|
+
return { ...asset, served: null, error: `HTTP ${response.status}` };
|
|
726
|
+
}
|
|
727
|
+
const { bytes, error } = await readCapped(response, asset.size);
|
|
728
|
+
return bytes ? { ...asset, served: sha256Of(bytes) } : { ...asset, served: null, error };
|
|
729
|
+
}
|
|
730
|
+
return { ...asset, served: null, error: `more than ${PROBE_MAX_REDIRECTS} redirects` };
|
|
731
|
+
} catch (error) {
|
|
732
|
+
return { ...asset, served: null, error: error?.name === "TimeoutError" ? `no answer within ${PROBE_TIMEOUT_MS / 1000} s` : String(error?.cause?.code || error?.message || error) };
|
|
733
|
+
}
|
|
614
734
|
}
|
|
615
735
|
|
|
616
736
|
const PROBE_TIMEOUT_MS = 5_000;
|
|
@@ -641,32 +761,56 @@ async function probePage(target, fetchImpl) {
|
|
|
641
761
|
|
|
642
762
|
/**
|
|
643
763
|
* What only the running server can say, read before the target lock (like
|
|
644
|
-
* the polish --evidence file): the URL names this campaign's
|
|
645
|
-
* and every built page under it answers 2xx
|
|
646
|
-
*
|
|
764
|
+
* the polish --evidence file): the URL names this campaign's served preview,
|
|
765
|
+
* and every built page under it answers (2xx on a local preview, 200 on a
|
|
766
|
+
* hosted one). On a hosted preview every script and stylesheet the built
|
|
767
|
+
* pages load from that origin must also be served byte-identical to the
|
|
768
|
+
* built output; HTML is not compared, since hosts inject markup. The lock
|
|
769
|
+
* re-checks the packet and the built output; the probe is never trusted for
|
|
770
|
+
* either.
|
|
647
771
|
*/
|
|
648
772
|
export async function probeLocalPreview({ packetPath, baseUrl, fetchImpl = globalThis.fetch }) {
|
|
649
773
|
const packet = readPacketFile("deploy", packetPath);
|
|
650
|
-
const { url, problems } =
|
|
774
|
+
const { url, hosted, problems } = deployPreviewUrl(packet, baseUrl);
|
|
651
775
|
if (problems.length) throw refuseRecord("deploy", problems);
|
|
652
776
|
const site = resolveBuiltSiteScope(targetRepoFor(packetPath, packet), { slug: packet.campaign.public_route_slug });
|
|
653
777
|
if (!site.ok) throw refuseRecord("deploy", [site.error]);
|
|
654
778
|
// The built pages, not the bare route root: a funnel often has no index
|
|
655
779
|
// page there, and servers answer that differently (404, a listing).
|
|
656
|
-
const
|
|
780
|
+
const pageUrls = site.pages.map((page) => (routeKey(page.route) ? new URL(`${routeKey(page.route)}/`, url).href : url));
|
|
781
|
+
const targets = [...new Set(pageUrls)];
|
|
657
782
|
// Requested together; the evidence keeps the built pages' order.
|
|
658
783
|
const routes = await Promise.all(targets.map((target) => probePage(target, fetchImpl)));
|
|
659
|
-
const
|
|
784
|
+
const answered = (status) => (hosted ? status === 200 : status >= 200 && status < 300);
|
|
785
|
+
const failed = routes.filter((route) => !answered(route.status));
|
|
660
786
|
if (failed.length) {
|
|
661
787
|
throw refuseRecord("deploy", [
|
|
662
788
|
...failed.map((route) => `${route.url}: ${route.status ? `HTTP ${route.status}` : route.error}.`),
|
|
663
|
-
|
|
789
|
+
hosted
|
|
790
|
+
? `Deploy the current _site/ build so every page answers HTTP 200 at ${url}, then run ${cmd("record")} deploy again.`
|
|
791
|
+
: `Serve the current _site/ build so every page answers at ${url}, then run ${cmd("record")} deploy again.`,
|
|
792
|
+
]);
|
|
793
|
+
}
|
|
794
|
+
// The build the probe ran against, so a record build before the lock
|
|
795
|
+
// refuses instead of recording a probe of other output.
|
|
796
|
+
const probedBuild = computeBuildFingerprint(site.campaign_dir);
|
|
797
|
+
const buildFingerprint = probedBuild.ok ? probedBuild.fingerprint : null;
|
|
798
|
+
if (!hosted) return { url, routes, buildFingerprint };
|
|
799
|
+
const assets = await Promise.all(builtAssets(site, pageUrls).map((asset) => probeAsset(asset, fetchImpl)));
|
|
800
|
+
const targetRepo = site.target_repo;
|
|
801
|
+
const differing = assets.filter((asset) => asset.served !== asset.sha256);
|
|
802
|
+
if (differing.length) {
|
|
803
|
+
throw refuseRecord("deploy", [
|
|
804
|
+
...differing.map((asset) => (asset.served
|
|
805
|
+
? `${asset.url}: served bytes (${asset.served}) differ from ${relative(targetRepo, asset.file)} (${asset.sha256}).`
|
|
806
|
+
: `${asset.url}: ${asset.error}; ${relative(targetRepo, asset.file)} is in the build output.`)),
|
|
807
|
+
`The preview at ${url} is not serving the recorded build. Deploy the current _site/ build, then run ${cmd("record")} deploy again.`,
|
|
664
808
|
]);
|
|
665
809
|
}
|
|
666
|
-
return { url, routes };
|
|
810
|
+
return { url, routes, buildFingerprint, assets: assets.map(({ url: assetUrl, file, sha256 }) => ({ url: assetUrl, file, sha256 })) };
|
|
667
811
|
}
|
|
668
812
|
|
|
669
|
-
// A recorded local
|
|
813
|
+
// A recorded preview (local or hosted): the packet's deploy.preview_url, and stages.deploy
|
|
670
814
|
// completed with the URL in outputs (what next reads), the probe as evidence,
|
|
671
815
|
// and the build it probed as source_build_fingerprint (doctorFacts has checked
|
|
672
816
|
// it equals stages.assembly.build_fingerprint), so a later record build of
|
|
@@ -678,7 +822,10 @@ function composeDeploy(report, packet, { now, recordedBy, probe, fingerprint })
|
|
|
678
822
|
status: "completed",
|
|
679
823
|
source_build_fingerprint: fingerprint,
|
|
680
824
|
outputs: [probe.url],
|
|
681
|
-
evidence:
|
|
825
|
+
evidence: [
|
|
826
|
+
...probe.routes.map((route) => `${route.url} answered HTTP ${route.status}`),
|
|
827
|
+
...(probe.assets || []).map((asset) => `${asset.url} served ${asset.sha256}, as built`),
|
|
828
|
+
],
|
|
682
829
|
completed_at: now,
|
|
683
830
|
recorded_by: recordedBy,
|
|
684
831
|
blockers: [],
|
|
@@ -808,6 +955,15 @@ function doctorFacts(stage, doctor, report, packet) {
|
|
|
808
955
|
};
|
|
809
956
|
}
|
|
810
957
|
|
|
958
|
+
// The manifest of the output doctor fingerprinted, read with doctor's own
|
|
959
|
+
// function over doctor's own root; null when the output on disk is no longer
|
|
960
|
+
// that value (assertOutputUnchanged then refuses the record).
|
|
961
|
+
function outputManifest(facts) {
|
|
962
|
+
if (!facts.fingerprint || !facts.fingerprintRoot) return null;
|
|
963
|
+
const current = computeBuildFingerprint(facts.fingerprintRoot);
|
|
964
|
+
return current.ok && current.fingerprint === facts.fingerprint ? buildManifestEntries(current) : null;
|
|
965
|
+
}
|
|
966
|
+
|
|
811
967
|
// The last check before the write: the output doctor fingerprinted is still
|
|
812
968
|
// the output on disk. The target lock keeps campaigns-os writers out, but a
|
|
813
969
|
// page-kit build does not take it, so the fingerprint is recomputed here with
|
|
@@ -824,12 +980,21 @@ function assertOutputUnchanged(stage, facts) {
|
|
|
824
980
|
// Under the lock, the probe is checked against the packet as it is now (it
|
|
825
981
|
// could have been retargeted while the probe ran), and the theme gate, which
|
|
826
982
|
// blocks deploy, must not be blocked.
|
|
827
|
-
function deployFacts(doctor, packet, probe) {
|
|
828
|
-
const { url, problems } =
|
|
983
|
+
function deployFacts(doctor, packet, probe, fingerprint) {
|
|
984
|
+
const { url, problems } = deployPreviewUrl(packet, probe.url);
|
|
829
985
|
if (problems.length) throw refuseRecord("deploy", problems);
|
|
830
986
|
if (url !== probe.url) {
|
|
831
987
|
throw refuseRecord("deploy", [`The packet's route root changed while the preview was probed (probed ${probe.url}, now ${url}); run ${cmd("record")} deploy again.`]);
|
|
832
988
|
}
|
|
989
|
+
// The served assets were compared with the files as they were before the
|
|
990
|
+
// lock; a build since then is a refusal, not a record of other output.
|
|
991
|
+
const rebuilt = (probe.assets || []).filter((asset) => fileSha256(asset.file) !== asset.sha256);
|
|
992
|
+
if (rebuilt.length) {
|
|
993
|
+
throw refuseRecord("deploy", [`The built output changed while the preview was probed (${rebuilt.map((asset) => asset.url).join(", ")}); run ${cmd("record")} build, then record deploy again.`]);
|
|
994
|
+
}
|
|
995
|
+
if (probe.buildFingerprint !== undefined && probe.buildFingerprint !== fingerprint) {
|
|
996
|
+
throw refuseRecord("deploy", [`The built output changed while the preview was probed (probed ${probe.buildFingerprint || "no output"}, now ${fingerprint}); run ${cmd("record")} deploy again.`]);
|
|
997
|
+
}
|
|
833
998
|
const gate = doctor.derived?.theme_gate;
|
|
834
999
|
if (gate?.status === "blocked") {
|
|
835
1000
|
throw refuseRecord("deploy", [
|
|
@@ -926,6 +1091,7 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
926
1091
|
] : [
|
|
927
1092
|
`stages.${stageKey}.status = ${composed.report.stages[stageKey].status}`,
|
|
928
1093
|
...(facts.fingerprint ? [`build output fingerprint ${facts.fingerprint} (doctor derived.build_output_fingerprint.value)`] : []),
|
|
1094
|
+
...(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`] : []),
|
|
929
1095
|
...(stage === "build" && buildEnvironment ? [`stages.assembly.evidence.build_environment = ${buildEnvironment}`] : []),
|
|
930
1096
|
...(stage === "build" && adapterDecisions ? Object.entries(adapterDecisions).map(([key, value]) => `report.adapter_decisions.${key} = ${value}`) : []),
|
|
931
1097
|
...(stage === "build" ? [`stages.polish.status = ${composed.report.stages.polish.status}`] : []),
|
|
@@ -942,7 +1108,8 @@ export function recordStageCommand(args, { now = () => new Date(), beforeLock =
|
|
|
942
1108
|
...(composed.context ? { context_path: contextPath } : {}),
|
|
943
1109
|
...(dryRun ? { would_write: writes } : { written: writes }),
|
|
944
1110
|
build_fingerprint: facts.fingerprint || null,
|
|
945
|
-
|
|
1111
|
+
// The manifest is kept in the report, not echoed: it lists every built file.
|
|
1112
|
+
record: stage === "theme" ? composed.report.theme : stage === "build" ? withoutKeys(composed.report.stages.assembly, ["build_manifest"]) : composed.report.stages[stageKey],
|
|
946
1113
|
...(stage === "build" ? { polish: composed.report.stages.polish } : {}),
|
|
947
1114
|
...(composed.context ? { scaffold: composed.context.scaffold } : {}),
|
|
948
1115
|
...(stage === "polish" && input.hasRepairLoopDefect ? { repair_loop_defect: input.repairLoopDefect } : {}),
|
|
@@ -998,11 +1165,11 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
|
|
|
998
1165
|
facts = doctorFacts(stage, doctor, report, packet);
|
|
999
1166
|
if (typeof afterDoctorRead === "function") afterDoctorRead();
|
|
1000
1167
|
if (stage === "theme") layer = brandLayerFacts(doctor);
|
|
1001
|
-
if (stage === "deploy") deployFacts(doctor, packet, input);
|
|
1168
|
+
if (stage === "deploy") deployFacts(doctor, packet, input, facts.fingerprint);
|
|
1002
1169
|
const next = stage === "setup"
|
|
1003
1170
|
? composeSetup(report, context, { now: timestamp, recordedBy })
|
|
1004
1171
|
: stage === "build"
|
|
1005
|
-
? composeBuild(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, buildEnvironment, adapterDecisions, fallbackAdapterDecisions: adapterDecisions && !report.adapter_decisions ? readJsonIfExists(contextPath)?.adapter_decisions || packet.source_html?.adapter_contract : null, inputs: recordInputs(doctor), deviationReason })
|
|
1172
|
+
? 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 })
|
|
1006
1173
|
: stage === "theme"
|
|
1007
1174
|
? composeTheme(report, { now: timestamp, recordedBy, layer })
|
|
1008
1175
|
: stage === "deploy"
|
|
@@ -1010,15 +1177,15 @@ function recordUnderLock({ stage, packetPath, sidecars, lockedTarget, input, dry
|
|
|
1010
1177
|
: composePolish(report, { now: timestamp, recordedBy, fingerprint: facts.fingerprint, input, inputs: recordInputs(doctor) });
|
|
1011
1178
|
applyDerivedAssemblyReportSummary(next.report, recordInputs(doctor));
|
|
1012
1179
|
// The packet's one new value, deploy.preview_url, is checked by
|
|
1013
|
-
//
|
|
1180
|
+
// deployPreviewUrl; the rest of the packet is as the operator left it.
|
|
1014
1181
|
validateRecord(stage, { report: next.report, context: next.context, packet, fingerprint: facts.fingerprint });
|
|
1015
1182
|
assertOutputUnchanged(stage, facts);
|
|
1016
1183
|
composed = next;
|
|
1017
1184
|
if (dryRun) return null;
|
|
1018
1185
|
// Written inside the report's critical section, after every check and
|
|
1019
1186
|
// before the report itself, so the files move together.
|
|
1020
|
-
if (next.context) writeJsonAtomic(contextPath, next.context);
|
|
1021
|
-
if (next.packet) writeJsonAtomic(packetPath, next.packet);
|
|
1187
|
+
if (next.context) writeJsonAtomic(contextPath, portableArtifactPaths(next.context, workspace.targetRepo, { artifactPath: contextPath }));
|
|
1188
|
+
if (next.packet) writeJsonAtomic(packetPath, portableArtifactPaths(next.packet, workspace.targetRepo, { artifactPath: packetPath }));
|
|
1022
1189
|
return next.report;
|
|
1023
1190
|
};
|
|
1024
1191
|
// Already inside the target lock, which commitAssemblyReport re-enters.
|