climaybe 3.2.0 → 3.3.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/README.md CHANGED
@@ -94,7 +94,11 @@ Switch your local dev environment to a specific store (multi-store only).
94
94
  npx climaybe switch voldt-norway
95
95
  ```
96
96
 
97
- Copies `stores/<alias>/` JSON files to the repo root so you can preview that store locally, and sets `default_store` in `climaybe.config.json` so `climaybe serve` / `shopify theme dev` targets that store. If your current git branch is `staging-<alias>` or `live-<alias>`, `climaybe serve` uses that store’s domain from config (even if `default_store` differs), matching CI preview behavior.
97
+ Copies `stores/<alias>/` JSON files to the repo root so you can preview that store locally, and sets `default_store` in `climaybe.config.json` so `climaybe serve` / `shopify theme dev` targets that store. If your current git branch is `staging-<alias>` or `live-<alias>`, `climaybe serve` still passes that branch’s store to Shopify for `--store` (even if `default_store` differs), matching CI preview behavior.
98
+
99
+ ### `climaybe serve` / `climaybe theme serve`
100
+
101
+ Runs Tailwind/scripts/schema watchers and `shopify theme dev` (see the theme dev kit section for flags). In **multi-store** mode, the first step asks **which store to serve** (interactive terminal); the default choice is the store matching `default_store` (same as after `climaybe switch`). If you pick a **different** store, the CLI saves the current root `config/`, `templates/`, and `sections/` JSON files into `stores/<previous-alias>/` (same paths as `climaybe sync`), then copies the selected store’s JSONs to the root and updates `default_store`, so work in progress is not lost. In **CI** or when **stdin is not a TTY**, there is no prompt: it uses `default_store` unless you set **`CLIMAYBE_SERVE_STORE=<alias>`** to a configured alias.
98
102
 
99
103
  ### `climaybe sync [alias]` / `climaybe theme sync`
100
104
 
@@ -227,12 +231,14 @@ Enabled via `climaybe init` prompt (`Enable preview + cleanup workflows?`; defau
227
231
 
228
232
  | Workflow | Trigger | What it does |
229
233
  |----------|---------|-------------|
230
- | `pr-update.yml` | PR opened/synchronize/reopened (base: main, staging, develop, staging-*, live-*) | Shares draft theme, renames with `-PR<number>`, comments preview + customize URLs; uses default store for main/staging/develop, or the store for staging-&lt;alias&gt;/live-&lt;alias&gt;. **Path filter:** runs only when the PR changes theme paths (`assets/`, `blocks/`, `config/`, `layout/`, `locales/`, `sections/`, `snippets/`, `templates/`, `_scripts/`, `_styles/`, `shopify.theme.toml`, `stores/**`); docs-only or tooling-only PRs skip preview work. |
231
- | `pr-close.yml` | PR closed (same branch set) | Deletes matching preview themes and comments deleted count + names |
232
- | `reusable-share-theme.yml` | workflow_call | Shares Shopify draft theme and returns `theme_id` |
233
- | `reusable-rename-theme.yml` | workflow_call | Renames shared theme to include `PR<number>` (fails job on rename failure) |
234
- | `reusable-comment-on-pr.yml` | workflow_call | Posts preview comment including Customize URL |
235
- | `reusable-cleanup-themes.yml` | workflow_call | Deletes preview themes by PR number and exposes cleanup outputs |
234
+ | `pr-update.yml` | PR opened/synchronize/reopened (base: main, staging, develop, staging-*, live-*) | Shares draft theme, renames with `-PR<number>`, comments preview + customize URLs. **Multi-store + base not** `staging-<alias>` **or** `live-<alias>`**:** publishes to **every** configured store (matrix) and posts **all** links in one PR comment. For `staging-<alias>` / `live-<alias>` bases, only that store is used. **Path filter:** theme paths only (`assets/`, `blocks/`, `config/`, `layout/`, `locales/`, `sections/`, `snippets/`, `templates/`, `_scripts/`, `_styles/`, `shopify.theme.toml`, `stores/**`). |
235
+ | `pr-close.yml` | PR closed (same branch set) | Deletes this PR’s preview themes using the **same store matrix rule** as `pr-update`; PR comment shows **total** deleted count across stores. |
236
+ | `cleanup-orphan-preview-themes.yml` | Weekly (Mon 06:00 UTC) + `workflow_dispatch` | Per store: deletes themes ending with `-PR<n>` when PR `#n` is **not** open (merged/closed without cleanup). Uses `gh pr list` (limit 1000 open PRs). |
237
+ | `reusable-publish-pr-preview-store.yml` | workflow_call | Share + rename + upload comment fragment for **one** store (matrix leg in `pr-update`). |
238
+ | `reusable-share-theme.yml` | workflow_call | Shares Shopify draft theme and returns `theme_id` (still available if you call it elsewhere) |
239
+ | `reusable-rename-theme.yml` | workflow_call | Renames shared theme to include `PR<number>` (still available for custom flows) |
240
+ | `reusable-comment-on-pr.yml` | workflow_call | Posts preview comment; aggregates `preview-fragment-*` artifacts when `use_preview_fragments` is true |
241
+ | `reusable-cleanup-themes.yml` | workflow_call | `cleanup_mode: by_pr` deletes names ending with `-PR{padded}`; `orphan_pr` deletes `-PR<n>` when PR `n` is not open. Optional `result_artifact_prefix` for matrix fan-in on `pr-close` |
236
242
  | `reusable-extract-pr-number.yml` | workflow_call | Extracts padded/unpadded PR number outputs for naming and API-safe usage |
237
243
 
238
244
  ### Optional build + Lighthouse package
@@ -374,7 +380,7 @@ Add the following secrets to your GitHub repository (or use **GitLab CI/CD varia
374
380
 
375
381
  **Store URL:** During `climaybe init` (or `add-store`), store URL secret(s) are set from your configured store domain(s); theme tokens are optional prompts.
376
382
 
377
- **Multi-store:** Per-store secrets `SHOPIFY_STORE_URL_<ALIAS>` and `SHOPIFY_THEME_ACCESS_TOKEN_<ALIAS>` — the URL is set from config; you must provide the theme token per store. `<ALIAS>` is uppercase with hyphens as underscores (e.g. `voldt-norway` → `SHOPIFY_STORE_URL_VOLDT_NORWAY`). Preview and cleanup: for PRs targeting **main**, **staging**, or **develop** the workflows use the **default store** (from `config.default_store` or first in `config.stores`); for PRs targeting **staging-&lt;alias&gt;** or **live-&lt;alias&gt;** they use that store’s credentials. Set either the plain `SHOPIFY_*` secrets or the `_<ALIAS>` pair for each store you use in preview.
383
+ **Multi-store:** Per-store secrets `SHOPIFY_STORE_URL_<ALIAS>` and `SHOPIFY_THEME_ACCESS_TOKEN_<ALIAS>` — the URL is set from config; you must provide the theme token per store. `<ALIAS>` is uppercase with hyphens as underscores (e.g. `voldt-norway` → `SHOPIFY_STORE_URL_VOLDT_NORWAY`). **Preview:** for PRs targeting **staging-&lt;alias&gt;** or **live-&lt;alias&gt;** only that store is used. For **main**, **staging**, **develop**, or other bases with **multiple** stores in `climaybe.config.json`, **pr-update** publishes the preview theme to **every** store (each must have secrets); **pr-close** cleans that PR’s preview themes on **all** stores. For a **single** store in config, behavior matches the default-store case. Optional **cleanup-orphan-preview-themes** (weekly + manual) removes stale `-PR<n>` themes when PR `n` is no longer open.
378
384
 
379
385
  ## Directory Structure (Multi-store)
380
386
 
package/bin/version.txt CHANGED
@@ -1 +1 @@
1
- 3.2.0
1
+ 3.3.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "climaybe",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "description": "Shopify CLI by Electric Maybe for theme CI/CD workflows, branch orchestration, app setup, and dev tooling",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -64,8 +64,12 @@ function registerThemeCommands(cmd) {
64
64
  .command('serve')
65
65
  .description('Run local theme dev (Shopify + assets; Theme Check off by default)')
66
66
  .option('--theme-check', 'Enable Theme Check watcher')
67
- .action((opts) => serveAll({ includeThemeCheck: opts.themeCheck === true }));
68
- cmd.command('serve:shopify').description('Run Shopify theme dev server').action(() => serveShopify());
67
+ .action(async (opts) => {
68
+ await serveAll({ includeThemeCheck: opts.themeCheck === true });
69
+ });
70
+ cmd.command('serve:shopify').description('Run Shopify theme dev server').action(async () => {
71
+ await serveShopify();
72
+ });
69
73
  cmd
70
74
  .command('serve:assets')
71
75
  .description('Run assets watch (Tailwind + scripts; Theme Check off by default)')
package/src/lib/config.js CHANGED
@@ -180,6 +180,23 @@ export function getStoreAliases(cwd = process.cwd()) {
180
180
  return Object.keys(config.stores);
181
181
  }
182
182
 
183
+ /**
184
+ * Resolve the store alias for `default_store` (the domain last set by `climaybe switch` or serve).
185
+ * @param {string} [cwd]
186
+ * @returns {string | null}
187
+ */
188
+ export function getAliasForDefaultStore(cwd = process.cwd()) {
189
+ const config = readConfig(cwd);
190
+ const domain = config?.default_store;
191
+ const stores = config?.stores;
192
+ if (!domain || !stores || typeof stores !== 'object') return null;
193
+ const normalized = String(domain).trim();
194
+ for (const [alias, storeDomain] of Object.entries(stores)) {
195
+ if (String(storeDomain).trim() === normalized) return alias;
196
+ }
197
+ return null;
198
+ }
199
+
183
200
  /**
184
201
  * Determine the current mode: 'single' or 'multi'.
185
202
  */
@@ -4,6 +4,7 @@ import { watchTree } from './watch.js';
4
4
  import { isAbsolute, join, relative } from 'node:path';
5
5
  import pc from 'picocolors';
6
6
  import { readConfig, getStoreDomainFromBranch } from './config.js';
7
+ import { prepareMultiStoreForServe } from './serve-multi-store.js';
7
8
  import { buildScripts } from './build-scripts.js';
8
9
  import { buildSchemas } from './schema-builder.js';
9
10
  import { runShopify } from './shopify-cli.js';
@@ -209,7 +210,15 @@ function runThemeCheckFiltered({ cwd = process.cwd() } = {}) {
209
210
  return child;
210
211
  }
211
212
 
212
- export function serveShopify({ cwd = process.cwd() } = {}) {
213
+ /**
214
+ * @param {{ cwd?: string; skipMultiStorePrep?: boolean }} [opts]
215
+ * @returns {Promise<import('node:child_process').ChildProcess | null>} Null when multi-store prep failed or was cancelled.
216
+ */
217
+ export async function serveShopify({ cwd = process.cwd(), skipMultiStorePrep = false } = {}) {
218
+ if (!skipMultiStorePrep) {
219
+ const ok = await prepareMultiStoreForServe(cwd);
220
+ if (!ok) return null;
221
+ }
213
222
  const config = readConfig(cwd) || {};
214
223
  const branchStore = getStoreDomainFromBranch(cwd);
215
224
  const store = branchStore || config.default_store || config.store || '';
@@ -365,16 +374,28 @@ export function serveAssets({ cwd = process.cwd(), includeThemeCheck = false } =
365
374
  return { tailwind, devMcp, scriptsWatch, schemasWatch, themeCheckWatch, cleanup };
366
375
  }
367
376
 
368
- export function serveAll({ cwd = process.cwd(), includeThemeCheck = false } = {}) {
377
+ export async function serveAll({ cwd = process.cwd(), includeThemeCheck = false } = {}) {
378
+ const prepOk = await prepareMultiStoreForServe(cwd);
379
+ if (!prepOk) {
380
+ const noop = () => {};
381
+ return { cleanup: noop };
382
+ }
383
+
369
384
  // Start assets first, then bring up Shopify after a short delay.
370
385
  const assets = serveAssets({ cwd, includeThemeCheck });
371
386
  let shopify = null;
372
387
  const shopifyStartDelayMs = 2500;
373
388
  const shopifyTimer = setTimeout(() => {
374
- shopify = serveShopify({ cwd });
375
- shopify.on('exit', () => {
376
- cleanup();
377
- });
389
+ void (async () => {
390
+ shopify = await serveShopify({ cwd, skipMultiStorePrep: true });
391
+ if (shopify) {
392
+ shopify.on('exit', () => {
393
+ cleanup();
394
+ });
395
+ } else {
396
+ cleanup();
397
+ }
398
+ })();
378
399
  }, shopifyStartDelayMs);
379
400
  console.log(pc.dim(` Waiting ${shopifyStartDelayMs}ms before starting Shopify...`));
380
401
 
@@ -229,6 +229,28 @@ export async function promptProjectName(cwd = process.cwd()) {
229
229
  return String(projectName || '').trim();
230
230
  }
231
231
 
232
+ /**
233
+ * Ask which store to use for local theme dev (multi-store `climaybe serve`).
234
+ * @param {{ aliases: string[]; stores: Record<string, string>; suggestedAlias: string }} opts
235
+ * @returns {Promise<string | null>} Selected alias, or null if cancelled.
236
+ */
237
+ export async function promptServeStore({ aliases, stores, suggestedAlias }) {
238
+ const sorted = [...aliases].sort();
239
+ const choices = sorted.map((alias) => ({
240
+ title: `${alias} (${stores[alias]})`,
241
+ value: alias,
242
+ }));
243
+ const initialIndex = sorted.indexOf(suggestedAlias);
244
+ const { alias } = await prompts({
245
+ type: 'select',
246
+ name: 'alias',
247
+ message: 'Which store should we serve?',
248
+ choices,
249
+ initial: initialIndex >= 0 ? initialIndex : 0,
250
+ });
251
+ return alias ?? null;
252
+ }
253
+
232
254
  /**
233
255
  * Prompt for a single new store (used by add-store command).
234
256
  * Takes existing aliases to prevent duplicates.
@@ -0,0 +1,81 @@
1
+ import pc from 'picocolors';
2
+ import { readConfig, getMode, getProjectType, getStoreAliases, writeConfig, getAliasForDefaultStore } from './config.js';
3
+ import { rootToStores, storesToRoot } from './store-sync.js';
4
+ import { promptServeStore } from './prompts.js';
5
+
6
+ function readExplicitServeAlias(cwd) {
7
+ const raw = process.env.CLIMAYBE_SERVE_STORE?.trim();
8
+ if (!raw) return { ok: true, alias: null };
9
+ const aliases = getStoreAliases(cwd);
10
+ if (aliases.includes(raw)) return { ok: true, alias: raw };
11
+ console.log(pc.red(` CLIMAYBE_SERVE_STORE="${raw}" is not a known store alias.`));
12
+ console.log(pc.dim(` Available: ${aliases.join(', ')}\n`));
13
+ return { ok: false, alias: null };
14
+ }
15
+
16
+ function isNonInteractiveServe() {
17
+ if (process.env.CLIMAYBE_SERVE_STORE?.trim()) return true;
18
+ if (process.env.CI === 'true') return true;
19
+ return process.stdin.isTTY !== true;
20
+ }
21
+
22
+ /**
23
+ * In multi-store theme repos, ensure root JSONs and `default_store` match the store
24
+ * the user wants to serve. If the choice differs from the previous switch target,
25
+ * saves current root → `stores/<previous>/` then loads `stores/<selected>/` → root.
26
+ *
27
+ * @param {string} [cwd]
28
+ * @returns {Promise<boolean>} False if the user cancelled or `CLIMAYBE_SERVE_STORE` is invalid.
29
+ */
30
+ export async function prepareMultiStoreForServe(cwd = process.cwd()) {
31
+ if (getProjectType(cwd) === 'app') return true;
32
+ if (getMode(cwd) !== 'multi') return true;
33
+
34
+ const config = readConfig(cwd);
35
+ if (!config?.stores || typeof config.stores !== 'object') return true;
36
+
37
+ const aliases = getStoreAliases(cwd);
38
+ if (aliases.length < 2) return true;
39
+
40
+ const stores = config.stores;
41
+ const suggestedAlias = getAliasForDefaultStore(cwd) ?? aliases[0];
42
+
43
+ const explicit = readExplicitServeAlias(cwd);
44
+ if (!explicit.ok) return false;
45
+
46
+ let selected = explicit.alias;
47
+ if (!selected) {
48
+ if (isNonInteractiveServe()) {
49
+ selected = suggestedAlias;
50
+ } else {
51
+ console.log(pc.bold('\n climaybe — serve (store)\n'));
52
+ selected = await promptServeStore({ aliases, stores, suggestedAlias });
53
+ if (!selected) {
54
+ console.log(pc.dim(' Cancelled.\n'));
55
+ return false;
56
+ }
57
+ }
58
+ }
59
+
60
+ const previousAlias = getAliasForDefaultStore(cwd);
61
+
62
+ if (selected === previousAlias) return true;
63
+
64
+ if (previousAlias) {
65
+ rootToStores(previousAlias, cwd);
66
+ } else {
67
+ console.log(
68
+ pc.yellow(
69
+ ' Warning: default_store did not match any store alias; current root JSONs are not auto-saved to a store folder.'
70
+ )
71
+ );
72
+ }
73
+
74
+ const ok = storesToRoot(selected, cwd);
75
+ if (!ok) return false;
76
+
77
+ const domain = stores[selected];
78
+ if (domain) writeConfig({ default_store: domain }, cwd);
79
+
80
+ return true;
81
+ }
@@ -0,0 +1,59 @@
1
+ # climaybe — Cleanup orphan preview themes (optional)
2
+ # Weekly (and manual): on each configured store, delete themes whose name ends with -PR<n>
3
+ # when PR #n is not open in this repo (merged/closed without theme cleanup).
4
+
5
+ name: Cleanup orphan preview themes
6
+
7
+ on:
8
+ schedule:
9
+ - cron: '0 6 * * 1'
10
+ workflow_dispatch:
11
+
12
+ permissions:
13
+ contents: read
14
+ pull-requests: read
15
+
16
+ jobs:
17
+ configure:
18
+ runs-on: ubuntu-latest
19
+ outputs:
20
+ preview_targets_json: ${{ steps.cfg.outputs.preview_targets_json }}
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+
24
+ - name: Build store matrix from climaybe.config.json
25
+ id: cfg
26
+ run: |
27
+ if [ ! -f climaybe.config.json ]; then
28
+ {
29
+ echo 'preview_targets_json<<PT_JSON'
30
+ echo '[]'
31
+ echo 'PT_JSON'
32
+ } >> "$GITHUB_OUTPUT"
33
+ exit 0
34
+ fi
35
+ node <<'NODE' >>"$GITHUB_OUTPUT"
36
+ const fs = require('fs');
37
+ const cfg = JSON.parse(fs.readFileSync('climaybe.config.json', 'utf8'));
38
+ const stores = cfg?.stores || {};
39
+ const keys = Object.keys(stores);
40
+ const aliasToSecret = (a) => String(a).toUpperCase().replace(/-/g, '_');
41
+ const targets = keys.map((alias) => ({ alias, alias_secret: aliasToSecret(alias) }));
42
+ console.log('preview_targets_json<<PT_JSON');
43
+ console.log(JSON.stringify(targets));
44
+ console.log('PT_JSON');
45
+ NODE
46
+
47
+ cleanup-orphans:
48
+ needs: [configure]
49
+ if: needs.configure.outputs.preview_targets_json != '[]'
50
+ strategy:
51
+ fail-fast: false
52
+ matrix:
53
+ include: ${{ fromJson(needs.configure.outputs.preview_targets_json) }}
54
+ uses: ./.github/workflows/reusable-cleanup-themes.yml
55
+ with:
56
+ cleanup_mode: orphan_pr
57
+ pr_number: ''
58
+ store_alias_secret: ${{ matrix.alias_secret }}
59
+ secrets: inherit
@@ -8,6 +8,10 @@ on:
8
8
  types: [closed]
9
9
  branches: [main, staging, develop, 'staging-*', 'live-*']
10
10
 
11
+ permissions:
12
+ contents: read
13
+ pull-requests: write
14
+
11
15
  jobs:
12
16
  extract-pr-number:
13
17
  uses: ./.github/workflows/reusable-extract-pr-number.yml
@@ -15,73 +19,123 @@ jobs:
15
19
  resolve-store:
16
20
  runs-on: ubuntu-latest
17
21
  outputs:
18
- store_alias: ${{ steps.resolve.outputs.alias }}
19
- store_alias_secret: ${{ steps.resolve.outputs.alias_secret }}
22
+ store_alias: ${{ steps.resolve.outputs.store_alias }}
23
+ store_alias_secret: ${{ steps.resolve.outputs.store_alias_secret }}
24
+ preview_targets_json: ${{ steps.resolve.outputs.preview_targets_json }}
20
25
  steps:
21
26
  - name: Checkout code
22
27
  uses: actions/checkout@v4
23
28
 
24
- - name: Resolve store alias from base branch
29
+ - name: Resolve preview store targets from base branch
25
30
  id: resolve
26
31
  env:
27
32
  BASE_REF: ${{ github.event.pull_request.base.ref }}
28
33
  run: |
29
- if [[ -n "$BASE_REF" && ("$BASE_REF" == staging-* || "$BASE_REF" == live-*) ]]; then
30
- ALIAS="${BASE_REF#staging-}"
31
- ALIAS="${ALIAS#live-}"
32
- else
33
- ALIAS=$(node -e "
34
- const fs = require('fs');
35
- const cfg = JSON.parse(fs.readFileSync('./climaybe.config.json', 'utf8'));
36
- const stores = cfg?.stores || {};
37
- const defaultStoreRaw = cfg?.default_store;
38
- const normalize = (v) => String(v || '')
39
- .toLowerCase()
40
- .replace(/^https?:\\/\\//, '')
41
- .replace(/\\/.*$/, '');
42
- const defaultStore = normalize(defaultStoreRaw);
43
- let alias = '';
44
- if (defaultStore) {
45
- for (const [k, d] of Object.entries(stores)) {
46
- if (normalize(d) === defaultStore) {
47
- alias = k;
48
- break;
49
- }
34
+ node <<'NODE' >>"$GITHUB_OUTPUT"
35
+ const fs = require('fs');
36
+ const cfg = JSON.parse(fs.readFileSync('climaybe.config.json', 'utf8'));
37
+ const stores = cfg?.stores || {};
38
+ const keys = Object.keys(stores);
39
+ const baseRef = process.env.BASE_REF || '';
40
+ const isMulti = keys.length > 1;
41
+ const isStagingLive =
42
+ baseRef && (/^staging-/.test(baseRef) || /^live-/.test(baseRef));
43
+
44
+ const aliasToSecret = (a) => String(a).toUpperCase().replace(/-/g, '_');
45
+ const normalizeDomain = (v) =>
46
+ String(v || '')
47
+ .toLowerCase()
48
+ .replace(/^https?:\/\//, '')
49
+ .replace(/\/.*$/, '');
50
+
51
+ let targets = [];
52
+ if (isMulti && !isStagingLive) {
53
+ targets = keys.map((alias) => ({ alias, alias_secret: aliasToSecret(alias) }));
54
+ } else if (isStagingLive) {
55
+ let a = baseRef.replace(/^staging-/, '').replace(/^live-/, '');
56
+ targets = [{ alias: a, alias_secret: aliasToSecret(a) }];
57
+ } else {
58
+ const defaultStoreNorm = normalizeDomain(cfg?.default_store);
59
+ let alias = '';
60
+ if (defaultStoreNorm) {
61
+ for (const [k, d] of Object.entries(stores)) {
62
+ if (normalizeDomain(d) === defaultStoreNorm) {
63
+ alias = k;
64
+ break;
50
65
  }
51
66
  }
52
- if (!alias) {
53
- alias = Object.keys(stores)[0] || '';
54
- }
55
- if (!alias && defaultStore) {
56
- const subdomain = defaultStore.split('.')[0] || 'default';
57
- alias = subdomain.replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-') || 'default';
58
- }
59
- if (!alias) {
60
- alias = 'default';
61
- }
62
- process.stdout.write(alias);
63
- ")
64
- fi
67
+ }
68
+ if (!alias) alias = keys[0] || '';
69
+ if (!alias && cfg?.default_store) {
70
+ const sub = normalizeDomain(cfg.default_store).split('.')[0] || 'default';
71
+ alias = sub.replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-') || 'default';
72
+ }
73
+ if (!alias) alias = 'default';
74
+ targets = [{ alias, alias_secret: aliasToSecret(alias) }];
75
+ }
65
76
 
66
- if [ -z "$ALIAS" ]; then
67
- echo "Could not resolve store alias from climaybe.config.json config or base branch."
68
- exit 1
69
- fi
77
+ if (targets.length === 0 || targets.some((t) => !t.alias)) {
78
+ console.error('Could not resolve store targets from climaybe.config.json');
79
+ process.exit(1);
80
+ }
70
81
 
71
- ALIAS_SECRET=$(echo "$ALIAS" | tr '[:lower:]-' '[:upper:]_')
72
- echo "alias=$ALIAS" >> $GITHUB_OUTPUT
73
- echo "alias_secret=$ALIAS_SECRET" >> $GITHUB_OUTPUT
82
+ console.log('preview_targets_json<<PT_JSON');
83
+ console.log(JSON.stringify(targets));
84
+ console.log('PT_JSON');
85
+ console.log(`store_alias=${targets[0].alias}`);
86
+ console.log(`store_alias_secret=${targets[0].alias_secret}`);
87
+ NODE
74
88
 
75
89
  cleanup-theme:
76
90
  needs: [extract-pr-number, resolve-store]
91
+ strategy:
92
+ fail-fast: false
93
+ matrix:
94
+ include: ${{ fromJson(needs.resolve-store.outputs.preview_targets_json) }}
77
95
  uses: ./.github/workflows/reusable-cleanup-themes.yml
78
96
  with:
97
+ cleanup_mode: by_pr
79
98
  pr_number: ${{ needs.extract-pr-number.outputs.pr_number }}
80
- store_alias_secret: ${{ needs.resolve-store.outputs.store_alias_secret }}
99
+ store_alias_secret: ${{ matrix.alias_secret }}
100
+ result_artifact_prefix: pr-close-cleanup
81
101
  secrets: inherit
82
102
 
103
+ cleanup-totals:
104
+ needs: [cleanup-theme]
105
+ if: always()
106
+ runs-on: ubuntu-latest
107
+ permissions:
108
+ actions: read
109
+ outputs:
110
+ deleted_count: ${{ steps.sum.outputs.deleted_count }}
111
+ steps:
112
+ - name: Download cleanup result fragments
113
+ continue-on-error: true
114
+ uses: actions/download-artifact@v4
115
+ with:
116
+ pattern: pr-close-cleanup-*
117
+ merge-multiple: true
118
+ path: cleanup-counts
119
+
120
+ - name: Sum deleted themes across stores
121
+ id: sum
122
+ run: |
123
+ total=0
124
+ if [ -d cleanup-counts ]; then
125
+ while IFS= read -r -d '' f; do
126
+ [ -z "$f" ] && continue
127
+ [ ! -f "$f" ] && continue
128
+ n="$(tr -d ' \n\r' < "$f" 2>/dev/null || echo 0)"
129
+ n="${n:-0}"
130
+ total=$((total + n))
131
+ done < <(find cleanup-counts -name 'deleted-count.txt' -type f -print0 2>/dev/null || true)
132
+ fi
133
+ echo "deleted_count=$total" >> "$GITHUB_OUTPUT"
134
+ echo "Total deleted across stores: $total"
135
+
83
136
  comment-on-pr:
84
- needs: [cleanup-theme, resolve-store]
137
+ needs: [cleanup-totals, resolve-store]
138
+ if: always()
85
139
  runs-on: ubuntu-latest
86
140
  steps:
87
141
  - name: Comment on PR about cleanup
@@ -90,8 +144,7 @@ jobs:
90
144
  script: |
91
145
  const prNumber = context.payload.pull_request.number;
92
146
  const storeAlias = '${{ needs.resolve-store.outputs.store_alias }}' || '';
93
- const deletedCount = parseInt('${{ needs.cleanup-theme.outputs.deleted_count }}') || 0;
94
- const deletedThemesRaw = `${{ needs.cleanup-theme.outputs.deleted_themes }}`.trim();
147
+ const deletedCount = parseInt('${{ needs.cleanup-totals.outputs.deleted_count }}') || 0;
95
148
 
96
149
  const lines = [
97
150
  '## 🧹 Theme Cleanup Complete',
@@ -100,22 +153,10 @@ jobs:
100
153
  '',
101
154
  `**Branch:** ${context.payload.pull_request.head.ref}`,
102
155
  `**PR Status:** ${context.payload.pull_request.state}`,
103
- storeAlias ? `**Store:** ${storeAlias}` : null,
104
- `**Deleted Themes:** ${deletedCount}`
156
+ storeAlias ? `**Primary store (config):** ${storeAlias}` : null,
157
+ `**Deleted themes (all preview stores):** ${deletedCount}`
105
158
  ].filter(Boolean);
106
159
 
107
- if (deletedCount > 0 && deletedThemesRaw) {
108
- const deletedThemeLines = deletedThemesRaw
109
- .split('\n')
110
- .map(name => name.trim())
111
- .filter(Boolean)
112
- .map(name => `- ${name}`);
113
-
114
- if (deletedThemeLines.length > 0) {
115
- lines.push('', '### Deleted Theme Names', ...deletedThemeLines);
116
- }
117
- }
118
-
119
160
  lines.push('', '---', '*Theme cleanup runs automatically when a PR is closed.*');
120
161
 
121
162
  await github.rest.issues.createComment({
@@ -44,119 +44,130 @@ jobs:
44
44
  if: needs.filter.outputs.theme == 'true'
45
45
  runs-on: ubuntu-latest
46
46
  outputs:
47
- store_alias: ${{ steps.resolve.outputs.alias }}
48
- store_alias_secret: ${{ steps.resolve.outputs.alias_secret }}
47
+ store_alias: ${{ steps.resolve.outputs.store_alias }}
48
+ store_alias_secret: ${{ steps.resolve.outputs.store_alias_secret }}
49
+ preview_targets_json: ${{ steps.resolve.outputs.preview_targets_json }}
49
50
  steps:
50
51
  - name: Checkout code
51
52
  uses: actions/checkout@v4
52
53
 
53
- - name: Resolve store alias from base branch
54
+ - name: Resolve preview store targets from base branch
54
55
  id: resolve
55
56
  env:
56
57
  BASE_REF: ${{ github.event.pull_request.base.ref }}
57
58
  run: |
58
- # On staging-<alias> or live-<alias> PRs, use that store; otherwise default store
59
- if [[ -n "$BASE_REF" && ("$BASE_REF" == staging-* || "$BASE_REF" == live-*) ]]; then
60
- ALIAS="${BASE_REF#staging-}"
61
- ALIAS="${ALIAS#live-}"
62
- else
63
- ALIAS=$(node -e "
64
- const fs = require('fs');
65
- const cfg = JSON.parse(fs.readFileSync('./climaybe.config.json', 'utf8'));
66
- const stores = cfg?.stores || {};
67
- const defaultStoreRaw = cfg?.default_store;
68
- const normalize = (v) => String(v || '')
69
- .toLowerCase()
70
- .replace(/^https?:\\/\\//, '')
71
- .replace(/\\/.*$/, '');
72
- const defaultStore = normalize(defaultStoreRaw);
73
- let alias = '';
74
- if (defaultStore) {
75
- for (const [k, d] of Object.entries(stores)) {
76
- if (normalize(d) === defaultStore) {
77
- alias = k;
78
- break;
79
- }
59
+ node <<'NODE' >>"$GITHUB_OUTPUT"
60
+ const fs = require('fs');
61
+ const cfg = JSON.parse(fs.readFileSync('climaybe.config.json', 'utf8'));
62
+ const stores = cfg?.stores || {};
63
+ const keys = Object.keys(stores);
64
+ const baseRef = process.env.BASE_REF || '';
65
+ const isMulti = keys.length > 1;
66
+ const isStagingLive =
67
+ baseRef && (/^staging-/.test(baseRef) || /^live-/.test(baseRef));
68
+
69
+ const aliasToSecret = (a) => String(a).toUpperCase().replace(/-/g, '_');
70
+ const normalizeDomain = (v) =>
71
+ String(v || '')
72
+ .toLowerCase()
73
+ .replace(/^https?:\/\//, '')
74
+ .replace(/\/.*$/, '');
75
+
76
+ let targets = [];
77
+ if (isMulti && !isStagingLive) {
78
+ targets = keys.map((alias) => ({ alias, alias_secret: aliasToSecret(alias) }));
79
+ } else if (isStagingLive) {
80
+ let a = baseRef.replace(/^staging-/, '').replace(/^live-/, '');
81
+ targets = [{ alias: a, alias_secret: aliasToSecret(a) }];
82
+ } else {
83
+ const defaultStoreNorm = normalizeDomain(cfg?.default_store);
84
+ let alias = '';
85
+ if (defaultStoreNorm) {
86
+ for (const [k, d] of Object.entries(stores)) {
87
+ if (normalizeDomain(d) === defaultStoreNorm) {
88
+ alias = k;
89
+ break;
80
90
  }
81
91
  }
82
- if (!alias) {
83
- alias = Object.keys(stores)[0] || '';
84
- }
85
- if (!alias && defaultStore) {
86
- const subdomain = defaultStore.split('.')[0] || 'default';
87
- alias = subdomain.replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-') || 'default';
88
- }
89
- if (!alias) {
90
- alias = 'default';
91
- }
92
- process.stdout.write(alias);
93
- ")
94
- fi
92
+ }
93
+ if (!alias) alias = keys[0] || '';
94
+ if (!alias && cfg?.default_store) {
95
+ const sub = normalizeDomain(cfg.default_store).split('.')[0] || 'default';
96
+ alias = sub.replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-') || 'default';
97
+ }
98
+ if (!alias) alias = 'default';
99
+ targets = [{ alias, alias_secret: aliasToSecret(alias) }];
100
+ }
95
101
 
96
- if [ -z "$ALIAS" ]; then
97
- echo "Could not resolve default store alias from climaybe.config.json config."
98
- exit 1
99
- fi
102
+ if (targets.length === 0 || targets.some((t) => !t.alias)) {
103
+ console.error('Could not resolve store targets from climaybe.config.json');
104
+ process.exit(1);
105
+ }
100
106
 
101
- ALIAS_SECRET=$(echo "$ALIAS" | tr '[:lower:]-' '[:upper:]_')
102
- echo "alias=$ALIAS" >> $GITHUB_OUTPUT
103
- echo "alias_secret=$ALIAS_SECRET" >> $GITHUB_OUTPUT
107
+ console.log('preview_targets_json<<PT_JSON');
108
+ console.log(JSON.stringify(targets));
109
+ console.log('PT_JSON');
110
+ console.log(`store_alias=${targets[0].alias}`);
111
+ console.log(`store_alias_secret=${targets[0].alias_secret}`);
112
+ NODE
104
113
 
105
- - name: Validate Shopify credentials
114
+ validate-secrets-per-store:
115
+ needs: [validate-environment]
116
+ strategy:
117
+ fail-fast: false
118
+ matrix:
119
+ include: ${{ fromJson(needs.validate-environment.outputs.preview_targets_json) }}
120
+ runs-on: ubuntu-latest
121
+ steps:
122
+ - name: Require Shopify URL + theme token for this store
106
123
  env:
107
- SHOPIFY_STORE_URL_SCOPED: ${{ secrets[format('SHOPIFY_STORE_URL_{0}', steps.resolve.outputs.alias_secret)] }}
108
- SHOPIFY_THEME_ACCESS_TOKEN_SCOPED: ${{ secrets[format('SHOPIFY_THEME_ACCESS_TOKEN_{0}', steps.resolve.outputs.alias_secret)] }}
124
+ SHOPIFY_STORE_URL_SCOPED: ${{ secrets[format('SHOPIFY_STORE_URL_{0}', matrix.alias_secret)] }}
125
+ SHOPIFY_THEME_ACCESS_TOKEN_SCOPED: ${{ secrets[format('SHOPIFY_THEME_ACCESS_TOKEN_{0}', matrix.alias_secret)] }}
109
126
  SHOPIFY_STORE_URL_DEFAULT: ${{ secrets.SHOPIFY_STORE_URL }}
110
127
  SHOPIFY_THEME_ACCESS_TOKEN_DEFAULT: ${{ secrets.SHOPIFY_THEME_ACCESS_TOKEN }}
111
128
  run: |
112
129
  SHOPIFY_STORE_URL="${SHOPIFY_STORE_URL_SCOPED:-$SHOPIFY_STORE_URL_DEFAULT}"
113
130
  SHOPIFY_THEME_ACCESS_TOKEN="${SHOPIFY_THEME_ACCESS_TOKEN_SCOPED:-$SHOPIFY_THEME_ACCESS_TOKEN_DEFAULT}"
114
-
115
- if [ -z "$SHOPIFY_STORE_URL" ]; then
116
- echo "No store URL secret found. Expected SHOPIFY_STORE_URL_${{ steps.resolve.outputs.alias_secret }} or SHOPIFY_STORE_URL."
131
+ if [ -z "$SHOPIFY_STORE_URL" ] || [ -z "$SHOPIFY_THEME_ACCESS_TOKEN" ]; then
132
+ echo "Missing SHOPIFY_* for ${{ matrix.alias }} (secret suffix ${{ matrix.alias_secret }})."
117
133
  exit 1
118
134
  fi
119
- if [ -z "$SHOPIFY_THEME_ACCESS_TOKEN" ]; then
120
- echo "No theme token secret found. Expected SHOPIFY_THEME_ACCESS_TOKEN_${{ steps.resolve.outputs.alias_secret }} or SHOPIFY_THEME_ACCESS_TOKEN."
121
- exit 1
122
- fi
123
- echo "Shopify credentials are configured for alias: ${{ steps.resolve.outputs.alias }}"
135
+ echo "Shopify credentials OK for ${{ matrix.alias }}"
124
136
 
125
137
  cleanup-themes:
126
- needs: [extract-pr-number, validate-environment]
138
+ needs: [extract-pr-number, validate-secrets-per-store]
139
+ strategy:
140
+ fail-fast: false
141
+ matrix:
142
+ include: ${{ fromJson(needs.validate-environment.outputs.preview_targets_json) }}
127
143
  uses: ./.github/workflows/reusable-cleanup-themes.yml
128
144
  with:
145
+ cleanup_mode: by_pr
129
146
  pr_number: ${{ needs.extract-pr-number.outputs.pr_number }}
130
- store_alias_secret: ${{ needs.validate-environment.outputs.store_alias_secret }}
147
+ store_alias_secret: ${{ matrix.alias_secret }}
131
148
  secrets: inherit
132
149
 
133
- share-theme:
150
+ publish-preview-store:
134
151
  needs: [validate-environment, extract-pr-number, cleanup-themes]
135
- uses: ./.github/workflows/reusable-share-theme.yml
152
+ strategy:
153
+ fail-fast: false
154
+ matrix:
155
+ include: ${{ fromJson(needs.validate-environment.outputs.preview_targets_json) }}
156
+ uses: ./.github/workflows/reusable-publish-pr-preview-store.yml
136
157
  with:
137
158
  pr_number: ${{ needs.extract-pr-number.outputs.pr_number }}
138
- store_alias: ${{ needs.validate-environment.outputs.store_alias }}
139
- store_alias_secret: ${{ needs.validate-environment.outputs.store_alias_secret }}
140
- secrets: inherit
141
-
142
- rename-theme:
143
- needs: [share-theme, extract-pr-number, validate-environment]
144
- uses: ./.github/workflows/reusable-rename-theme.yml
145
- with:
146
- theme_id: ${{ needs.share-theme.outputs.theme_id }}
147
- theme_name: ${{ needs.share-theme.outputs.theme_name }}
148
- pr_number: ${{ needs.extract-pr-number.outputs.pr_number }}
149
- store_alias: ${{ needs.validate-environment.outputs.store_alias }}
150
- store_alias_secret: ${{ needs.validate-environment.outputs.store_alias_secret }}
159
+ store_alias: ${{ matrix.alias }}
160
+ store_alias_secret: ${{ matrix.alias_secret }}
151
161
  secrets: inherit
152
162
 
153
163
  comment-on-pr:
154
- needs: [share-theme, rename-theme, extract-pr-number, validate-environment]
164
+ needs: [publish-preview-store, extract-pr-number, validate-environment]
155
165
  uses: ./.github/workflows/reusable-comment-on-pr.yml
156
166
  with:
157
- theme_id: ${{ needs.share-theme.outputs.theme_id }}
158
- share_output: ${{ needs.share-theme.outputs.share_output }}
167
+ theme_id: ${{ needs.publish-preview-store.outputs.theme_id }}
168
+ share_output: ${{ needs.publish-preview-store.outputs.share_output }}
159
169
  pr_number: ${{ needs.extract-pr-number.outputs.pr_number_unpadded }}
160
170
  store_alias: ${{ needs.validate-environment.outputs.store_alias }}
161
171
  store_alias_secret: ${{ needs.validate-environment.outputs.store_alias_secret }}
172
+ use_preview_fragments: 'true'
162
173
  secrets: inherit
@@ -3,14 +3,25 @@ name: Cleanup Themes
3
3
  on:
4
4
  workflow_call:
5
5
  inputs:
6
+ cleanup_mode:
7
+ required: false
8
+ type: string
9
+ default: 'by_pr'
10
+ description: "by_pr: delete themes whose name ends with -PR{pr_number} (padded). orphan_pr: delete themes ending with -PR<n> when PR n is not open in this repo."
6
11
  pr_number:
7
- required: true
12
+ required: false
8
13
  type: string
9
- description: "PR number to clean up themes for"
14
+ default: ''
15
+ description: "Padded PR number (e.g. 09) when cleanup_mode is by_pr; ignored for orphan_pr."
10
16
  store_alias_secret:
11
17
  required: false
12
18
  type: string
13
19
  description: "Upper snake-case alias for scoped secret (e.g. VOLDT_STAGING). If set, uses SHOPIFY_*_<this>; else uses SHOPIFY_STORE_URL / SHOPIFY_THEME_ACCESS_TOKEN."
20
+ result_artifact_prefix:
21
+ required: false
22
+ type: string
23
+ default: ''
24
+ description: "When non-empty, uploads a tiny artifact named {prefix}-{store_alias_secret} with deleted_count for matrix fan-in."
14
25
  outputs:
15
26
  deleted_count:
16
27
  description: "Number of deleted themes"
@@ -34,6 +45,10 @@ on:
34
45
  jobs:
35
46
  cleanup:
36
47
  runs-on: ubuntu-latest
48
+ permissions:
49
+ contents: read
50
+ pull-requests: read
51
+ actions: write
37
52
  outputs:
38
53
  deleted_count: ${{ steps.cleanup.outputs.deleted_count }}
39
54
  deleted_themes: ${{ steps.cleanup.outputs.deleted_themes }}
@@ -51,7 +66,10 @@ jobs:
51
66
  SHOPIFY_STORE_URL: ${{ inputs.store_alias_secret && secrets[format('SHOPIFY_STORE_URL_{0}', inputs.store_alias_secret)] || secrets.SHOPIFY_STORE_URL }}
52
67
  SHOPIFY_THEME_ACCESS_TOKEN: ${{ inputs.store_alias_secret && secrets[format('SHOPIFY_THEME_ACCESS_TOKEN_{0}', inputs.store_alias_secret)] || secrets.SHOPIFY_THEME_ACCESS_TOKEN }}
53
68
  PR_NUMBER: ${{ inputs.pr_number }}
69
+ CLEANUP_MODE: ${{ inputs.cleanup_mode }}
54
70
  STORE_ALIAS_SECRET: ${{ inputs.store_alias_secret }}
71
+ RESULT_ARTIFACT_PREFIX: ${{ inputs.result_artifact_prefix }}
72
+ GH_TOKEN: ${{ github.token }}
55
73
  run: |
56
74
  set -euo pipefail
57
75
 
@@ -62,11 +80,13 @@ jobs:
62
80
  {
63
81
  echo "### 🧹 Cleanup preview themes"
64
82
  echo ""
65
- echo "- **PR**: \`${PR_NUMBER}\`"
83
+ echo "- **Mode**: \`${CLEANUP_MODE}\`"
84
+ echo "- **PR (by_pr mode)**: \`${PR_NUMBER:-<n/a>}\`"
66
85
  echo "- **Store alias secret**: \`${STORE_ALIAS_SECRET:-<default>}\`"
67
86
  echo "- **Store (host)**: \`${STORE_HOST:-<missing>}\`"
68
87
  } >> "$GITHUB_STEP_SUMMARY"
69
88
 
89
+ echo "CLEANUP_MODE=${CLEANUP_MODE}"
70
90
  echo "PR=${PR_NUMBER}"
71
91
  echo "Store alias secret=${STORE_ALIAS_SECRET:-<default>}"
72
92
  echo "Store host=${STORE_HOST:-<missing>}"
@@ -93,6 +113,11 @@ jobs:
93
113
  exit 0
94
114
  fi
95
115
 
116
+ if [ "$CLEANUP_MODE" = "by_pr" ] && [ -z "${PR_NUMBER:-}" ]; then
117
+ echo "cleanup_mode is by_pr but pr_number is empty."
118
+ exit 1
119
+ fi
120
+
96
121
  echo "store_hint=${STORE_HOST:-}" >> "$GITHUB_OUTPUT"
97
122
  echo "skipped_reason=" >> "$GITHUB_OUTPUT"
98
123
 
@@ -121,7 +146,6 @@ jobs:
121
146
  exit 1
122
147
  fi
123
148
 
124
- # Defensive: if CLI returned empty, treat as an empty list (instead of jq failure).
125
149
  if [ ! -s "$THEME_LIST_JSON" ]; then
126
150
  echo "[]" > "$THEME_LIST_JSON"
127
151
  fi
@@ -134,11 +158,59 @@ jobs:
134
158
  DELETED_COUNT=0
135
159
  DELETED_THEMES=""
136
160
 
161
+ OPEN_PRS_FILE="$(mktemp)"
162
+ if [ "$CLEANUP_MODE" = "orphan_pr" ]; then
163
+ echo "Fetching open PR numbers (limit 1000)…"
164
+ if ! gh pr list --repo "${GITHUB_REPOSITORY}" --state open --limit 1000 --json number -q '.[].number' > "$OPEN_PRS_FILE" 2>/dev/null; then
165
+ echo "gh pr list failed; cannot determine open PRs safely. Skipping orphan cleanup."
166
+ {
167
+ echo ""
168
+ echo "#### Result"
169
+ echo "- **Skipped**: \`gh pr list\` failed (check \`pull-requests: read\` permission)"
170
+ } >> "$GITHUB_STEP_SUMMARY"
171
+ echo "skipped_reason=gh_pr_list_failed" >> "$GITHUB_OUTPUT"
172
+ echo "matched_count=0" >> "$GITHUB_OUTPUT"
173
+ echo "matched_themes<<MATCHED_EOF" >> "$GITHUB_OUTPUT"
174
+ echo "MATCHED_EOF" >> "$GITHUB_OUTPUT"
175
+ echo "deleted_count=0" >> "$GITHUB_OUTPUT"
176
+ echo "deleted_themes<<DELETED_EOF" >> "$GITHUB_OUTPUT"
177
+ echo "DELETED_EOF" >> "$GITHUB_OUTPUT"
178
+ rm -f "$THEME_LIST_ERR" "$THEME_LIST_JSON" "$OPEN_PRS_FILE"
179
+ exit 0
180
+ fi
181
+ echo "Open PR count (lines): $(wc -l < "$OPEN_PRS_FILE" | tr -d ' ')"
182
+ fi
183
+
184
+ should_delete_theme() {
185
+ local id="$1"
186
+ local name="$2"
187
+ local suffix=""
188
+ suffix="$(printf '%s' "$name" | sed -n 's/.*-PR\([0-9][0-9]*\)$/\1/p')"
189
+ if [ -z "$suffix" ]; then
190
+ return 1
191
+ fi
192
+ if [ "$CLEANUP_MODE" = "by_pr" ]; then
193
+ case "$name" in
194
+ *"-PR${PR_NUMBER}") return 0 ;;
195
+ *) return 1 ;;
196
+ esac
197
+ fi
198
+ if [ "$CLEANUP_MODE" = "orphan_pr" ]; then
199
+ local num=""
200
+ num=$((10#$suffix))
201
+ if grep -qx "$num" "$OPEN_PRS_FILE" 2>/dev/null; then
202
+ return 1
203
+ fi
204
+ return 0
205
+ fi
206
+ return 1
207
+ }
208
+
137
209
  while IFS=$'\t' read -r THEME_ID THEME_NAME; do
138
210
  [ -z "$THEME_ID" ] && continue
139
211
  [ -z "$THEME_NAME" ] && continue
140
212
 
141
- if printf "%s" "$THEME_NAME" | grep -q "PR${PR_NUMBER}"; then
213
+ if should_delete_theme "$THEME_ID" "$THEME_NAME"; then
142
214
  MATCHED_COUNT=$((MATCHED_COUNT + 1))
143
215
  MATCHED_THEMES="${MATCHED_THEMES}${THEME_NAME}\n"
144
216
  echo "Matched: $THEME_NAME ($THEME_ID)"
@@ -164,7 +236,7 @@ jobs:
164
236
  echo ""
165
237
  echo "#### Result"
166
238
  echo "- **Total themes on store**: ${ALL_COUNT}"
167
- echo "- **Matched (PR${PR_NUMBER})**: ${MATCHED_COUNT}"
239
+ echo "- **Matched**: ${MATCHED_COUNT}"
168
240
  echo "- **Deleted**: ${DELETED_COUNT}"
169
241
  } >> "$GITHUB_STEP_SUMMARY"
170
242
 
@@ -197,3 +269,16 @@ jobs:
197
269
  printf "%b" "$DELETED_THEMES"
198
270
  echo "DELETED_EOF"
199
271
  } >> "$GITHUB_OUTPUT"
272
+
273
+ rm -f "$THEME_LIST_ERR" "$THEME_LIST_JSON" "$OPEN_PRS_FILE"
274
+
275
+ - name: Upload deleted count for matrix fan-in
276
+ if: inputs.result_artifact_prefix != ''
277
+ run: printf '%s' '${{ steps.cleanup.outputs.deleted_count }}' > deleted-count.txt
278
+ - name: Save cleanup result artifact
279
+ if: inputs.result_artifact_prefix != ''
280
+ uses: actions/upload-artifact@v4
281
+ with:
282
+ name: ${{ inputs.result_artifact_prefix }}-${{ inputs.store_alias_secret }}
283
+ path: deleted-count.txt
284
+ retention-days: 2
@@ -19,11 +19,29 @@ on:
19
19
  required: false
20
20
  type: string
21
21
  description: "Upper snake-case alias for scoped secret (e.g. VOLDT_STAGING). If set, uses SHOPIFY_STORE_URL_<this>; else uses SHOPIFY_STORE_URL."
22
+ use_preview_fragments:
23
+ required: false
24
+ type: string
25
+ default: 'false'
26
+ description: "When 'true', download preview-fragment-* artifacts (from publish matrix) and post one comment with all preview URLs."
22
27
 
23
28
  jobs:
24
29
  comment:
25
30
  runs-on: ubuntu-latest
31
+ permissions:
32
+ contents: read
33
+ actions: read
34
+ pull-requests: write
26
35
  steps:
36
+ - name: Download preview fragments (multi-store / matrix publish)
37
+ if: inputs.use_preview_fragments == 'true'
38
+ continue-on-error: true
39
+ uses: actions/download-artifact@v4
40
+ with:
41
+ pattern: preview-fragment-*
42
+ merge-multiple: true
43
+ path: preview-fragments
44
+
27
45
  - name: Post preview comment
28
46
  uses: actions/github-script@v7
29
47
  env:
@@ -31,39 +49,85 @@ jobs:
31
49
  THEME_ID: ${{ inputs.theme_id }}
32
50
  SHARE_OUTPUT: ${{ inputs.share_output }}
33
51
  PR_NUMBER: ${{ inputs.pr_number }}
52
+ USE_PREVIEW_FRAGMENTS: ${{ inputs.use_preview_fragments }}
34
53
  with:
35
54
  script: |
55
+ const fs = require('fs');
56
+ const path = require('path');
36
57
  const issueNumber = parseInt(process.env.PR_NUMBER);
37
- const storeDomain = (process.env.SHOPIFY_STORE_URL || '')
38
- .replace(/^https?:\/\//, '')
39
- .replace(/\/$/, '');
40
- const themeId = process.env.THEME_ID || '';
41
- const shareOutput = process.env.SHARE_OUTPUT || '';
58
+ const useFragments = process.env.USE_PREVIEW_FRAGMENTS === 'true';
42
59
 
43
- let previewUrl = '';
44
- let customizeUrl = '';
45
- if (storeDomain && themeId) {
46
- previewUrl = `https://${storeDomain}?preview_theme_id=${themeId}`;
47
- customizeUrl = `https://${storeDomain}/admin/themes/${themeId}/editor`;
60
+ const walkJsonFiles = (dir) => {
61
+ const out = [];
62
+ if (!fs.existsSync(dir)) return out;
63
+ const stack = [dir];
64
+ while (stack.length) {
65
+ const d = stack.pop();
66
+ for (const ent of fs.readdirSync(d, { withFileTypes: true })) {
67
+ const p = path.join(d, ent.name);
68
+ if (ent.isDirectory()) stack.push(p);
69
+ else if (ent.name === 'fragment.json' || ent.name.endsWith('.json')) {
70
+ try {
71
+ out.push(JSON.parse(fs.readFileSync(p, 'utf8')));
72
+ } catch {
73
+ // ignore
74
+ }
75
+ }
76
+ }
77
+ }
78
+ return out;
79
+ };
80
+
81
+ let fragments = [];
82
+ if (useFragments) {
83
+ fragments = walkJsonFiles('preview-fragments');
48
84
  }
49
85
 
50
86
  const parts = [
51
87
  '## 🎨 Theme Preview Generated',
52
88
  '',
53
- `**Store:** ${process.env.SHOPIFY_STORE_URL || 'N/A'}`,
54
89
  `**Branch:** ${context.payload.pull_request?.head?.ref || context.ref.replace('refs/heads/', '')}`,
55
90
  `**Commit:** ${(context.payload.pull_request?.head?.sha || context.sha || '').substring(0, 7)}`
56
91
  ];
57
92
 
58
- if (customizeUrl) {
59
- parts.push('', `**Customize URL:** ${customizeUrl}`);
60
- }
61
- if (previewUrl) {
62
- parts.push('', `**Preview URL:** ${previewUrl}`);
63
- }
64
- if (shareOutput.trim()) {
65
- parts.push('', '### Share Output', '```', shareOutput, '```');
93
+ if (fragments.length > 0) {
94
+ parts.push('', '### Preview links (per store)');
95
+ const byAlias = [...fragments].sort((a, b) => String(a.alias).localeCompare(String(b.alias)));
96
+ for (const f of byAlias) {
97
+ const host = (f.store_host || '').replace(/\/$/, '');
98
+ const tid = f.theme_id || '';
99
+ const alias = f.alias || 'store';
100
+ if (host && tid) {
101
+ const previewUrl = `https://${host}?preview_theme_id=${tid}`;
102
+ const customizeUrl = `https://${host}/admin/themes/${tid}/editor`;
103
+ parts.push('', `**${alias}**`, `- Customize: ${customizeUrl}`, `- Preview: ${previewUrl}`);
104
+ }
105
+ }
106
+ } else if (useFragments) {
107
+ parts.push(
108
+ '',
109
+ '*(Could not load per-store preview fragments from workflow artifacts; see the publish job logs.)*'
110
+ );
111
+ } else {
112
+ const storeDomain = (process.env.SHOPIFY_STORE_URL || '')
113
+ .replace(/^https?:\/\//, '')
114
+ .replace(/\/$/, '');
115
+ const themeId = process.env.THEME_ID || '';
116
+ const shareOutput = process.env.SHARE_OUTPUT || '';
117
+ parts.push('', `**Store:** ${process.env.SHOPIFY_STORE_URL || 'N/A'}`);
118
+ let previewUrl = '';
119
+ let customizeUrl = '';
120
+ if (storeDomain && themeId) {
121
+ previewUrl = `https://${storeDomain}?preview_theme_id=${themeId}`;
122
+ customizeUrl = `https://${storeDomain}/admin/themes/${themeId}/editor`;
123
+ }
124
+ if (customizeUrl) parts.push('', `**Customize URL:** ${customizeUrl}`);
125
+ if (previewUrl) parts.push('', `**Preview URL:** ${previewUrl}`);
126
+ if (shareOutput.trim()) {
127
+ parts.push('', '### Share Output', '```', shareOutput, '```');
128
+ }
66
129
  }
130
+
67
131
  parts.push('', '---', '*This preview will be available for 7 days.*');
68
132
 
69
133
  await github.rest.issues.createComment({
@@ -0,0 +1,162 @@
1
+ # Share theme to one Shopify store, rename with -PR{padded}, upload JSON fragment for PR comment fan-in.
2
+ # Intended to be called from a matrix job (one invocation per store).
3
+
4
+ name: Publish PR preview (single store)
5
+
6
+ on:
7
+ workflow_call:
8
+ inputs:
9
+ pr_number:
10
+ required: true
11
+ type: string
12
+ description: "Padded PR number (e.g. 09) for theme name suffix -PR09"
13
+ store_alias:
14
+ required: true
15
+ type: string
16
+ description: "Store alias (for artifact naming and comment JSON)"
17
+ store_alias_secret:
18
+ required: false
19
+ type: string
20
+ default: ''
21
+ description: "Upper snake-case secret suffix; empty uses SHOPIFY_STORE_URL / SHOPIFY_THEME_ACCESS_TOKEN"
22
+ outputs:
23
+ theme_id:
24
+ description: "Theme ID after share/rename"
25
+ value: ${{ jobs.publish.outputs.theme_id }}
26
+ theme_name:
27
+ description: "Final theme name after rename"
28
+ value: ${{ jobs.publish.outputs.theme_name }}
29
+ share_output:
30
+ description: "Raw share command output"
31
+ value: ${{ jobs.publish.outputs.share_output }}
32
+
33
+ jobs:
34
+ publish:
35
+ runs-on: ubuntu-latest
36
+ permissions:
37
+ contents: read
38
+ actions: write
39
+ outputs:
40
+ theme_id: ${{ steps.rename.outputs.theme_id }}
41
+ theme_name: ${{ steps.rename.outputs.theme_name }}
42
+ share_output: ${{ steps.share.outputs.share_output }}
43
+ steps:
44
+ - name: Checkout code
45
+ uses: actions/checkout@v4
46
+
47
+ - name: Validate theme root
48
+ run: |
49
+ if [ ! -f "layout/theme.liquid" ]; then
50
+ echo "layout/theme.liquid not found. Ensure workflow runs at theme repository root."
51
+ exit 1
52
+ fi
53
+
54
+ - name: Install Shopify CLI
55
+ run: npm install -g @shopify/cli @shopify/theme
56
+
57
+ - name: Share theme
58
+ id: share
59
+ env:
60
+ SHOPIFY_STORE_URL: ${{ inputs.store_alias_secret && secrets[format('SHOPIFY_STORE_URL_{0}', inputs.store_alias_secret)] || secrets.SHOPIFY_STORE_URL }}
61
+ SHOPIFY_THEME_ACCESS_TOKEN: ${{ inputs.store_alias_secret && secrets[format('SHOPIFY_THEME_ACCESS_TOKEN_{0}', inputs.store_alias_secret)] || secrets.SHOPIFY_THEME_ACCESS_TOKEN }}
62
+ run: |
63
+ if [ -z "$SHOPIFY_STORE_URL" ] || [ -z "$SHOPIFY_THEME_ACCESS_TOKEN" ]; then
64
+ echo "Missing Shopify secrets."
65
+ exit 1
66
+ fi
67
+
68
+ OUTPUT=$(shopify theme share \
69
+ --store "$SHOPIFY_STORE_URL" \
70
+ --password "$SHOPIFY_THEME_ACCESS_TOKEN" 2>&1)
71
+ STATUS=$?
72
+
73
+ echo "$OUTPUT"
74
+ if [ $STATUS -ne 0 ]; then
75
+ echo "Theme share failed."
76
+ exit $STATUS
77
+ fi
78
+
79
+ THEME_NAME=$(echo "$OUTPUT" | sed -n "s/.*The theme '\([^']*\)'.*/\1/p" | head -1)
80
+ THEME_ID=$(echo "$OUTPUT" | sed -n 's/.*#\([0-9]*\).*/\1/p' | head -1)
81
+
82
+ if [ -z "$THEME_ID" ]; then
83
+ echo "Could not parse theme id from share output."
84
+ exit 1
85
+ fi
86
+
87
+ echo "theme_id=$THEME_ID" >> "$GITHUB_OUTPUT"
88
+ if [ -n "$THEME_NAME" ]; then
89
+ echo "theme_name=$THEME_NAME" >> "$GITHUB_OUTPUT"
90
+ fi
91
+
92
+ {
93
+ echo "share_output<<SHARE_EOF"
94
+ echo "$OUTPUT"
95
+ echo "SHARE_EOF"
96
+ } >> "$GITHUB_OUTPUT"
97
+
98
+ - name: Rename theme with PR suffix
99
+ id: rename
100
+ env:
101
+ SHOPIFY_STORE_URL: ${{ inputs.store_alias_secret && secrets[format('SHOPIFY_STORE_URL_{0}', inputs.store_alias_secret)] || secrets.SHOPIFY_STORE_URL }}
102
+ SHOPIFY_THEME_ACCESS_TOKEN: ${{ inputs.store_alias_secret && secrets[format('SHOPIFY_THEME_ACCESS_TOKEN_{0}', inputs.store_alias_secret)] || secrets.SHOPIFY_THEME_ACCESS_TOKEN }}
103
+ THEME_ID: ${{ steps.share.outputs.theme_id }}
104
+ THEME_NAME: ${{ steps.share.outputs.theme_name }}
105
+ PR_NUMBER: ${{ inputs.pr_number }}
106
+ run: |
107
+ if [ -z "$THEME_ID" ] || [ -z "$THEME_NAME" ]; then
108
+ echo "Missing theme_id/theme_name from share step."
109
+ exit 1
110
+ fi
111
+ if [ -z "$SHOPIFY_STORE_URL" ] || [ -z "$SHOPIFY_THEME_ACCESS_TOKEN" ]; then
112
+ echo "Missing Shopify store URL/token for rename."
113
+ exit 1
114
+ fi
115
+
116
+ NEW_THEME_NAME="${THEME_NAME}-PR${PR_NUMBER}"
117
+ echo "Renaming theme $THEME_ID to '$NEW_THEME_NAME'..."
118
+
119
+ if shopify theme rename \
120
+ --store "$SHOPIFY_STORE_URL" \
121
+ --password "$SHOPIFY_THEME_ACCESS_TOKEN" \
122
+ --theme "$THEME_ID" \
123
+ --name "$NEW_THEME_NAME" 2>&1; then
124
+ echo "Rename succeeded with password auth."
125
+ elif shopify theme rename \
126
+ --store "$SHOPIFY_STORE_URL" \
127
+ --theme "$THEME_ID" \
128
+ --name "$NEW_THEME_NAME" 2>&1; then
129
+ echo "Rename succeeded with authenticated session."
130
+ else
131
+ echo "Failed to rename theme."
132
+ exit 1
133
+ fi
134
+
135
+ echo "theme_id=$THEME_ID" >> "$GITHUB_OUTPUT"
136
+ echo "theme_name=$NEW_THEME_NAME" >> "$GITHUB_OUTPUT"
137
+
138
+ - name: Write preview fragment for PR comment
139
+ env:
140
+ STORE_ALIAS: ${{ inputs.store_alias }}
141
+ THEME_ID: ${{ steps.rename.outputs.theme_id }}
142
+ SHOPIFY_STORE_URL: ${{ inputs.store_alias_secret && secrets[format('SHOPIFY_STORE_URL_{0}', inputs.store_alias_secret)] || secrets.SHOPIFY_STORE_URL }}
143
+ run: |
144
+ node <<'NODE'
145
+ const fs = require('fs');
146
+ const url = (process.env.SHOPIFY_STORE_URL || '')
147
+ .replace(/^https?:\/\//, '')
148
+ .replace(/\/.*$/, '');
149
+ const payload = {
150
+ alias: process.env.STORE_ALIAS || '',
151
+ theme_id: process.env.THEME_ID || '',
152
+ store_host: url,
153
+ };
154
+ fs.writeFileSync('fragment.json', JSON.stringify(payload, null, 0));
155
+ NODE
156
+
157
+ - name: Upload preview fragment
158
+ uses: actions/upload-artifact@v4
159
+ with:
160
+ name: preview-fragment-${{ inputs.store_alias }}
161
+ path: fragment.json
162
+ retention-days: 2