← back to Designer Wallcoverings
cadence/activate-gated: publish DRAFT->ACTIVE promotes to Online Store channel
d1277e57a6465fc9679e39e1717ef1e8accdbe82 · 2026-06-26 12:59:39 -0700 · Steve
Root cause of recurring ACTIVE-but-published_at-NULL (PDP 404) bug: this promote
path called productUpdate{status:ACTIVE} then STOPPED. Shopify status:ACTIVE does
NOT auto-publish to the Online Store sales channel, so promoted products stayed
invisible (published_at=null) — e.g. the Schumacher-265 06-11 batch. Mirror the
cadence-import.js publishToChannels() fix: after a successful ACTIVE mutation,
publishablePublish to all channel publications with await + idempotent-retry, log
(not swallow) any publish failure, audit the published flag, and warn in the
summary when promoted != published. Code-only/reversible; deploy is gated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
M shopify/scripts/cadence/activate-gated.js
Diff
commit d1277e57a6465fc9679e39e1717ef1e8accdbe82
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jun 26 12:59:39 2026 -0700
cadence/activate-gated: publish DRAFT->ACTIVE promotes to Online Store channel
Root cause of recurring ACTIVE-but-published_at-NULL (PDP 404) bug: this promote
path called productUpdate{status:ACTIVE} then STOPPED. Shopify status:ACTIVE does
NOT auto-publish to the Online Store sales channel, so promoted products stayed
invisible (published_at=null) — e.g. the Schumacher-265 06-11 batch. Mirror the
cadence-import.js publishToChannels() fix: after a successful ACTIVE mutation,
publishablePublish to all channel publications with await + idempotent-retry, log
(not swallow) any publish failure, audit the published flag, and warn in the
summary when promoted != published. Code-only/reversible; deploy is gated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
shopify/scripts/cadence/activate-gated.js | 51 ++++++++++++++++++++++++++++---
1 file changed, 47 insertions(+), 4 deletions(-)
diff --git a/shopify/scripts/cadence/activate-gated.js b/shopify/scripts/cadence/activate-gated.js
index 53822388..581c3cba 100644
--- a/shopify/scripts/cadence/activate-gated.js
+++ b/shopify/scripts/cadence/activate-gated.js
@@ -82,6 +82,36 @@ async function gqlRetry(q, v) {
return { status: 429, raw: 'throttled' };
}
+// ---- publish-to-sales-channels (the missing step — root-caused 2026-06-26) ----
+// Shopify status:ACTIVE alone does NOT put a product on the Online Store; it stays
+// invisible to shoppers (published_at=null) until publishablePublish runs against
+// the channel publications. This DRAFT→ACTIVE promote path set status:ACTIVE and
+// STOPPED — so every product it promoted (e.g. the Schumacher-265 06-11 batch) went
+// ACTIVE-but-unpublished. Mirror the cadence-import.js fix (publishToChannels) here
+// so --activate truly means "live on the Online Store".
+const M_PUBLISH = `mutation($id:ID!,$input:[PublicationInput!]!){
+ publishablePublish(id:$id, input:$input){ userErrors{field message} }
+}`;
+let PUBLICATION_IDS = null; // [{id,name}], loaded once per run
+async function loadPublications() {
+ if (PUBLICATION_IDS) return PUBLICATION_IDS;
+ const r = await gqlRetry(`{publications(first:50){edges{node{id name}}}}`, {});
+ PUBLICATION_IDS = (r.json?.data?.publications?.edges || []).map(e => e.node);
+ return PUBLICATION_IDS;
+}
+// Publish an ACTIVE product to ALL channel publications (Online Store first).
+// "already published" userErrors are benign (idempotent re-runs); real errors return.
+async function publishToChannels(productId) {
+ const pubs = await loadPublications();
+ if (!pubs.length) return { published: false, why: 'no-publications-scope' };
+ const input = pubs.map(p => ({ publicationId: p.id }));
+ const r = await gqlRetry(M_PUBLISH, { id: productId, input });
+ const ue = (r.json?.data?.publishablePublish?.userErrors) || [];
+ const real = ue.filter(e => !/already|cannot be published to itself/i.test(e.message || ''));
+ if (real.length) return { published: false, errors: real };
+ return { published: true, channels: pubs.length };
+}
+
// IMAGE+WIDTH gate from PG (clean source image, has width); the PRICE gate is
// applied against Shopify's actual per-unit variant price below (the true listed
// price — robust to per-vendor catalog price-column differences).
@@ -130,7 +160,7 @@ const SL = 'single_line_text_field';
if (!TOKEN) { console.error('no SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
fs.mkdirSync(DATADIR, { recursive: true });
const tables = Object.entries(VENDORS).filter(([v]) => !ONLY || v.toLowerCase() === ONLY.toLowerCase());
- let promoted = 0, alreadyActive = 0, skippedArchived = 0, totalCand = 0;
+ let promoted = 0, publishedCount = 0, alreadyActive = 0, skippedArchived = 0, totalCand = 0;
let pushedMf = 0, taggedGaps = 0;
for (const [vendor, cfg] of tables) {
@@ -231,12 +261,24 @@ const SL = 'single_line_text_field';
fs.appendFileSync(AUDIT, JSON.stringify({ ts:new Date().toISOString(), vendor, dw_sku:d.dw_sku, product_id:d.gid, action:'metafield-push', metafields:d.mfPush.map(m=>m.namespace+'.'+m.key) }) + '\n');
}
}
- if (!COMMIT) { console.log(` · would ACTIVATE ${d.dw_sku}`); continue; }
+ if (!COMMIT) { console.log(` · would ACTIVATE+PUBLISH ${d.dw_sku}`); continue; }
const r = await gqlRetry(ACTIVATE, { id: d.gid });
const ue = r.json?.data?.productUpdate?.userErrors;
if (ue && ue.length) { console.log(` ✗ ${d.dw_sku}: ${JSON.stringify(ue).slice(0,120)}`); continue; }
promoted++;
- fs.appendFileSync(AUDIT, JSON.stringify({ ts: new Date().toISOString(), vendor, dw_sku: d.dw_sku, product_id: d.gid, from: 'DRAFT', to: 'ACTIVE' }) + '\n');
+ // PUBLISH STEP (the fix, 2026-06-26): status:ACTIVE is invisible on the
+ // storefront until the product is published to the Online Store channel.
+ // Publish here so the promote truly means "live" (await + idempotent-retry
+ // inside publishToChannels). A publish failure is logged, NOT swallowed
+ // silently — it surfaces in stdout and the audit so the canary/rerun catches it.
+ let published = false;
+ try {
+ const pub = await publishToChannels(d.gid);
+ published = !!pub.published;
+ if (!published) console.log(` ⚠ ${d.dw_sku} ACTIVE but publish failed: ${JSON.stringify(pub.errors||pub.why).slice(0,120)}`);
+ } catch (e) { console.log(` ⚠ ${d.dw_sku} publish error: ${String(e.message).slice(0,120)}`); }
+ if (published) publishedCount++;
+ fs.appendFileSync(AUDIT, JSON.stringify({ ts: new Date().toISOString(), vendor, dw_sku: d.dw_sku, product_id: d.gid, from: 'DRAFT', to: 'ACTIVE', published }) + '\n');
if (promoted % 10 === 0) process.stdout.write(`\r promoted ${promoted}…`);
}
@@ -258,7 +300,8 @@ const SL = 'single_line_text_field';
fs.appendFileSync(AUDIT, JSON.stringify({ ts:new Date().toISOString(), vendor, dw_sku:g.dw_sku, product_id:g.gid, action:'tagged', tags:g.tags }) + '\n');
}
}
- console.log(`\n${COMMIT ? `PROMOTED ${promoted} DRAFT→ACTIVE` : `DRY-RUN: ${totalCand} gate-passing rows scanned`}, ${alreadyActive} already active, ${skippedArchived} archived/other skipped.`);
+ console.log(`\n${COMMIT ? `PROMOTED ${promoted} DRAFT→ACTIVE (${publishedCount} published to Online Store)` : `DRY-RUN: ${totalCand} gate-passing rows scanned`}, ${alreadyActive} already active, ${skippedArchived} archived/other skipped.`);
+ if (COMMIT && promoted !== publishedCount) console.log(` ⚠ ${promoted - publishedCount} product(s) went ACTIVE but did NOT publish to the Online Store — re-run or let the publish canary reconcile.`);
console.log(COMMIT
? ` pushed ${pushedMf} width/spec metafield(s) · tagged ${taggedGaps} gap product(s)`
: ` (dry-run logged push-then-activate + gap-tag intents above — no writes)`);
← 08e99376 snapshot before git gc
·
back to Designer Wallcoverings
·
auto-save: 2026-06-26T15:34:21 (6 files) — pending-approval/ d0429e1a →