climaybe 3.1.4 → 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.
@@ -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"
@@ -18,13 +29,33 @@ on:
18
29
  deleted_themes:
19
30
  description: "Deleted theme names, one per line"
20
31
  value: ${{ jobs.cleanup.outputs.deleted_themes }}
32
+ matched_count:
33
+ description: "Number of themes matching this PR (before delete)"
34
+ value: ${{ jobs.cleanup.outputs.matched_count }}
35
+ matched_themes:
36
+ description: "Matched theme names, one per line (before delete)"
37
+ value: ${{ jobs.cleanup.outputs.matched_themes }}
38
+ skipped_reason:
39
+ description: "Why cleanup was skipped (empty if not skipped)"
40
+ value: ${{ jobs.cleanup.outputs.skipped_reason }}
41
+ store_hint:
42
+ description: "Redacted store host (best-effort; may be masked)"
43
+ value: ${{ jobs.cleanup.outputs.store_hint }}
21
44
 
22
45
  jobs:
23
46
  cleanup:
24
47
  runs-on: ubuntu-latest
48
+ permissions:
49
+ contents: read
50
+ pull-requests: read
51
+ actions: write
25
52
  outputs:
26
53
  deleted_count: ${{ steps.cleanup.outputs.deleted_count }}
27
54
  deleted_themes: ${{ steps.cleanup.outputs.deleted_themes }}
55
+ matched_count: ${{ steps.cleanup.outputs.matched_count }}
56
+ matched_themes: ${{ steps.cleanup.outputs.matched_themes }}
57
+ skipped_reason: ${{ steps.cleanup.outputs.skipped_reason }}
58
+ store_hint: ${{ steps.cleanup.outputs.store_hint }}
28
59
  steps:
29
60
  - name: Install Shopify CLI
30
61
  run: npm install -g @shopify/cli @shopify/theme
@@ -35,44 +66,219 @@ jobs:
35
66
  SHOPIFY_STORE_URL: ${{ inputs.store_alias_secret && secrets[format('SHOPIFY_STORE_URL_{0}', inputs.store_alias_secret)] || secrets.SHOPIFY_STORE_URL }}
36
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 }}
37
68
  PR_NUMBER: ${{ inputs.pr_number }}
69
+ CLEANUP_MODE: ${{ inputs.cleanup_mode }}
70
+ STORE_ALIAS_SECRET: ${{ inputs.store_alias_secret }}
71
+ RESULT_ARTIFACT_PREFIX: ${{ inputs.result_artifact_prefix }}
72
+ GH_TOKEN: ${{ github.token }}
38
73
  run: |
39
- if [ -z "$SHOPIFY_STORE_URL" ] || [ -z "$SHOPIFY_THEME_ACCESS_TOKEN" ]; then
74
+ set -euo pipefail
75
+
76
+ STORE_HOST="${SHOPIFY_STORE_URL#https://}"
77
+ STORE_HOST="${STORE_HOST#http://}"
78
+ STORE_HOST="${STORE_HOST%%/*}"
79
+
80
+ {
81
+ echo "### 🧹 Cleanup preview themes"
82
+ echo ""
83
+ echo "- **Mode**: \`${CLEANUP_MODE}\`"
84
+ echo "- **PR (by_pr mode)**: \`${PR_NUMBER:-<n/a>}\`"
85
+ echo "- **Store alias secret**: \`${STORE_ALIAS_SECRET:-<default>}\`"
86
+ echo "- **Store (host)**: \`${STORE_HOST:-<missing>}\`"
87
+ } >> "$GITHUB_STEP_SUMMARY"
88
+
89
+ echo "CLEANUP_MODE=${CLEANUP_MODE}"
90
+ echo "PR=${PR_NUMBER}"
91
+ echo "Store alias secret=${STORE_ALIAS_SECRET:-<default>}"
92
+ echo "Store host=${STORE_HOST:-<missing>}"
93
+
94
+ if [ -z "${SHOPIFY_STORE_URL:-}" ] || [ -z "${SHOPIFY_THEME_ACCESS_TOKEN:-}" ]; then
40
95
  echo "Missing Shopify secrets; skipping theme cleanup (no themes deleted)."
41
- echo "deleted_count=0" >> $GITHUB_OUTPUT
42
- echo "deleted_themes<<DELETED_EOF" >> $GITHUB_OUTPUT
43
- echo "DELETED_EOF" >> $GITHUB_OUTPUT
96
+
97
+ {
98
+ echo ""
99
+ echo "#### Result"
100
+ echo "- **Skipped**: yes (missing Shopify secrets)"
101
+ echo "- **Matched themes**: 0"
102
+ echo "- **Deleted themes**: 0"
103
+ } >> "$GITHUB_STEP_SUMMARY"
104
+
105
+ echo "skipped_reason=missing_secrets" >> "$GITHUB_OUTPUT"
106
+ echo "store_hint=${STORE_HOST:-}" >> "$GITHUB_OUTPUT"
107
+ echo "matched_count=0" >> "$GITHUB_OUTPUT"
108
+ echo "matched_themes<<MATCHED_EOF" >> "$GITHUB_OUTPUT"
109
+ echo "MATCHED_EOF" >> "$GITHUB_OUTPUT"
110
+ echo "deleted_count=0" >> "$GITHUB_OUTPUT"
111
+ echo "deleted_themes<<DELETED_EOF" >> "$GITHUB_OUTPUT"
112
+ echo "DELETED_EOF" >> "$GITHUB_OUTPUT"
44
113
  exit 0
45
114
  fi
46
115
 
47
- THEME_LIST=$(shopify theme list \
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
+
121
+ echo "store_hint=${STORE_HOST:-}" >> "$GITHUB_OUTPUT"
122
+ echo "skipped_reason=" >> "$GITHUB_OUTPUT"
123
+
124
+ THEME_LIST_ERR="$(mktemp)"
125
+ THEME_LIST_JSON="$(mktemp)"
126
+
127
+ if ! shopify theme list \
48
128
  --store "$SHOPIFY_STORE_URL" \
49
129
  --password "$SHOPIFY_THEME_ACCESS_TOKEN" \
50
- --json 2>/dev/null || echo "[]")
130
+ --json > "$THEME_LIST_JSON" 2> "$THEME_LIST_ERR"; then
131
+ echo "shopify theme list failed."
132
+ echo "Error output (first 120 lines):"
133
+ sed -n '1,120p' "$THEME_LIST_ERR" || true
134
+ {
135
+ echo ""
136
+ echo "#### Result"
137
+ echo "- **Error**: \`shopify theme list\` failed"
138
+ echo ""
139
+ echo "<details><summary>CLI error output</summary>"
140
+ echo ""
141
+ echo "\`\`\`"
142
+ sed -n '1,240p' "$THEME_LIST_ERR" || true
143
+ echo "\`\`\`"
144
+ echo "</details>"
145
+ } >> "$GITHUB_STEP_SUMMARY"
146
+ exit 1
147
+ fi
148
+
149
+ if [ ! -s "$THEME_LIST_JSON" ]; then
150
+ echo "[]" > "$THEME_LIST_JSON"
151
+ fi
152
+
153
+ ALL_COUNT="$(jq -r 'length' "$THEME_LIST_JSON" 2>/dev/null || echo "0")"
154
+ echo "Found ${ALL_COUNT} total theme(s) on store."
51
155
 
156
+ MATCHED_COUNT=0
157
+ MATCHED_THEMES=""
52
158
  DELETED_COUNT=0
53
159
  DELETED_THEMES=""
54
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
+
55
209
  while IFS=$'\t' read -r THEME_ID THEME_NAME; do
56
210
  [ -z "$THEME_ID" ] && continue
57
211
  [ -z "$THEME_NAME" ] && continue
58
212
 
59
- if printf "%s" "$THEME_NAME" | grep -q "PR${PR_NUMBER}"; then
213
+ if should_delete_theme "$THEME_ID" "$THEME_NAME"; then
214
+ MATCHED_COUNT=$((MATCHED_COUNT + 1))
215
+ MATCHED_THEMES="${MATCHED_THEMES}${THEME_NAME}\n"
216
+ echo "Matched: $THEME_NAME ($THEME_ID)"
217
+
218
+ DELETE_ERR="$(mktemp)"
60
219
  if shopify theme delete \
61
220
  --store "$SHOPIFY_STORE_URL" \
62
221
  --password "$SHOPIFY_THEME_ACCESS_TOKEN" \
63
222
  --force \
64
- --theme "$THEME_ID" 2>/dev/null; then
223
+ --theme "$THEME_ID" 1>/dev/null 2>"$DELETE_ERR"; then
65
224
  DELETED_COUNT=$((DELETED_COUNT + 1))
66
225
  DELETED_THEMES="${DELETED_THEMES}${THEME_NAME}\n"
226
+ echo "Deleted: $THEME_NAME ($THEME_ID)"
67
227
  else
68
- echo "Failed to delete theme: $THEME_NAME ($THEME_ID)"
228
+ echo "Failed to delete: $THEME_NAME ($THEME_ID)"
229
+ echo "Delete error output (first 80 lines):"
230
+ sed -n '1,80p' "$DELETE_ERR" || true
69
231
  fi
70
232
  fi
71
- done < <(echo "$THEME_LIST" | jq -r '.[] | [.id, .name] | @tsv')
233
+ done < <(jq -r '.[] | [.id, .name] | @tsv' "$THEME_LIST_JSON")
72
234
 
73
- echo "deleted_count=$DELETED_COUNT" >> $GITHUB_OUTPUT
235
+ {
236
+ echo ""
237
+ echo "#### Result"
238
+ echo "- **Total themes on store**: ${ALL_COUNT}"
239
+ echo "- **Matched**: ${MATCHED_COUNT}"
240
+ echo "- **Deleted**: ${DELETED_COUNT}"
241
+ } >> "$GITHUB_STEP_SUMMARY"
242
+
243
+ if [ "$MATCHED_COUNT" -gt 0 ]; then
244
+ {
245
+ echo ""
246
+ echo "#### Matched theme names"
247
+ printf "%b" "$MATCHED_THEMES" | sed -e 's/^/- /'
248
+ } >> "$GITHUB_STEP_SUMMARY"
249
+ fi
250
+
251
+ if [ "$DELETED_COUNT" -gt 0 ]; then
252
+ {
253
+ echo ""
254
+ echo "#### Deleted theme names"
255
+ printf "%b" "$DELETED_THEMES" | sed -e 's/^/- /'
256
+ } >> "$GITHUB_STEP_SUMMARY"
257
+ fi
258
+
259
+ echo "matched_count=$MATCHED_COUNT" >> "$GITHUB_OUTPUT"
260
+ {
261
+ echo "matched_themes<<MATCHED_EOF"
262
+ printf "%b" "$MATCHED_THEMES"
263
+ echo "MATCHED_EOF"
264
+ } >> "$GITHUB_OUTPUT"
265
+
266
+ echo "deleted_count=$DELETED_COUNT" >> "$GITHUB_OUTPUT"
74
267
  {
75
268
  echo "deleted_themes<<DELETED_EOF"
76
269
  printf "%b" "$DELETED_THEMES"
77
270
  echo "DELETED_EOF"
78
- } >> $GITHUB_OUTPUT
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({