anthodev/lapin-futepublic Fork
⑂ 8f2b8fe
Commits
⬇ Clone ▾
git clone https://git.rickub.com/anthodev/lapin-fute.git
git clone ssh://git@rickub.com/anthodev/lapin-fute.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

ci(catalog): separate catalog publishing from release deployment

Cedric ANTHONY committed 2026-09-20T06:23:34+02:00 Browse files
8f2b8fe parent: f5f0f7d
modified .github/workflows/deploy-bunny.yml +5 -64
@@ -4,17 +4,6 @@ on:
44 push:
55 tags: ["v*", "pre-v*"]
66 workflow_dispatch:
7- inputs:
8- refresh_catalog:
9- description: Rebuild and publish the IDFM catalog
10- required: true
11- default: false
12- type: boolean
13- force_catalog_upload:
14- description: Upload a rebuilt catalog even when its IDFM source revision is already hosted
15- required: true
16- default: false
17- type: boolean
187
198 permissions:
209 contents: read
@@ -117,7 +106,7 @@ jobs:
117106 deploy:
118107 name: Build and deploy configuration site
119108 needs: test
120- if: ${{ startsWith(github.ref_name, 'v') || (github.event_name == 'workflow_dispatch' && inputs.refresh_catalog) }}
109+ if: ${{ startsWith(github.ref_name, 'v') }}
121110 runs-on: ubuntu-latest
122111 environment: production
123112 steps:
@@ -130,42 +119,8 @@ jobs:
130119
131120 - run: npm ci
132121
133- - name: Refresh production catalog
134- if: ${{ github.event_name == 'workflow_dispatch' && inputs.refresh_catalog }}
135- env:
136- IDFM_DATASET_TOKEN: ${{ secrets.IDFM_DATASET_TOKEN }}
137- run: npm run catalog:refresh
138-
139- - name: Plan catalog upload
140- id: catalog
141- env:
142- CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }}
143- EVENT_NAME: ${{ github.event_name }}
144- FORCE_CATALOG_UPLOAD: ${{ inputs.force_catalog_upload }}
145- run: |
146- set -euo pipefail
147- if [[ "$EVENT_NAME" != workflow_dispatch ]]; then
148- include_catalog=false
149- reason="release tags update the configuration page only"
150- elif [[ "$FORCE_CATALOG_UPLOAD" = true ]]; then
151- include_catalog=true
152- reason="catalog upload was forced"
153- else
154- include_catalog="$(node scripts/plan-catalog-upload.mjs)"
155- reason="catalog source-revision comparison completed"
156- fi
157- echo "include_catalog=$include_catalog" >> "$GITHUB_OUTPUT"
158- echo "$reason: include_catalog=$include_catalog"
159-
160- - name: Build static configuration site
161- env:
162- INCLUDE_CATALOG: ${{ steps.catalog.outputs.include_catalog }}
163- run: |
164- if [[ "$INCLUDE_CATALOG" = true ]]; then
165- npm run build:config-site
166- else
167- npm run build:config-page
168- fi
122+ - name: Build static configuration page
123+ run: npm run build:config-page
169124
170125 - name: Back up mutable production files
171126 id: backup
@@ -188,9 +143,6 @@ jobs:
188143
189144 while IFS= read -r -d '' file; do
190145 relative_path="${file#var/config-site/}"
191- if [[ "$relative_path" =~ ^catalog/[a-f0-9]{64}/ ]]; then
192- continue
193- fi
194146 case "$relative_path" in
195147 *[!A-Za-z0-9._/-]* | /* | *../*)
196148 echo "Refusing unsafe backup path: $relative_path" >&2
@@ -266,15 +218,10 @@ jobs:
266218
267219 export endpoint BUNNY_STORAGE_PASSWORD
268220 export -f upload_file
269- find var/config-site -type f ! -path 'var/config-site/catalog/manifest.json' -print0 \
221+ find var/config-site -type f -print0 \
270222 | xargs -0 --no-run-if-empty --max-args=1 --max-procs=32 \
271223 bash -c 'upload_file "$1"' _
272224
273- # Publish the catalog pointer only after every immutable catalog file.
274- if [[ -f var/config-site/catalog/manifest.json ]]; then
275- upload_file var/config-site/catalog/manifest.json
276- fi
277-
278225 - name: Purge Bunny CDN cache
279226 env:
280227 BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }}
@@ -292,15 +239,9 @@ jobs:
292239 - name: Verify served static site
293240 env:
294241 CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }}
295- INCLUDE_CATALOG: ${{ steps.catalog.outputs.include_catalog }}
296242 run: |
297243 for attempt in 1 2 3; do
298- if [[ "$INCLUDE_CATALOG" = true ]]; then
299- command=(npm run verify:config-site)
300- else
301- command=(npm run verify:config-site -- --page-only)
302- fi
303- if "${command[@]}"; then
244+ if npm run verify:config-site -- --page-only; then
304245 exit 0
305246 fi
306247 if [[ "$attempt" != 3 ]]; then sleep 10; fi
@@ -4,17 +4,6 @@ on:
4 push:4 push:
5 tags: ["v*", "pre-v*"]5 tags: ["v*", "pre-v*"]
6 workflow_dispatch:6 workflow_dispatch:
7- inputs:
8- refresh_catalog:
9- description: Rebuild and publish the IDFM catalog
10- required: true
11- default: false
12- type: boolean
13- force_catalog_upload:
14- description: Upload a rebuilt catalog even when its IDFM source revision is already hosted
15- required: true
16- default: false
17- type: boolean
18 7
19 permissions:8 permissions:
20 contents: read9 contents: read
@@ -117,7 +106,7 @@ jobs:
117 deploy:106 deploy:
118 name: Build and deploy configuration site107 name: Build and deploy configuration site
119 needs: test108 needs: test
120- if: ${{ startsWith(github.ref_name, 'v') || (github.event_name == 'workflow_dispatch' && inputs.refresh_catalog) }}109+ if: ${{ startsWith(github.ref_name, 'v') }}
121 runs-on: ubuntu-latest110 runs-on: ubuntu-latest
122 environment: production111 environment: production
123 steps:112 steps:
@@ -130,42 +119,8 @@ jobs:
130 119
131 - run: npm ci120 - run: npm ci
132 121
133- - name: Refresh production catalog122+ - name: Build static configuration page
134- if: ${{ github.event_name == 'workflow_dispatch' && inputs.refresh_catalog }}123+ run: npm run build:config-page
135- env:
136- IDFM_DATASET_TOKEN: ${{ secrets.IDFM_DATASET_TOKEN }}
137- run: npm run catalog:refresh
138-
139- - name: Plan catalog upload
140- id: catalog
141- env:
142- CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }}
143- EVENT_NAME: ${{ github.event_name }}
144- FORCE_CATALOG_UPLOAD: ${{ inputs.force_catalog_upload }}
145- run: |
146- set -euo pipefail
147- if [[ "$EVENT_NAME" != workflow_dispatch ]]; then
148- include_catalog=false
149- reason="release tags update the configuration page only"
150- elif [[ "$FORCE_CATALOG_UPLOAD" = true ]]; then
151- include_catalog=true
152- reason="catalog upload was forced"
153- else
154- include_catalog="$(node scripts/plan-catalog-upload.mjs)"
155- reason="catalog source-revision comparison completed"
156- fi
157- echo "include_catalog=$include_catalog" >> "$GITHUB_OUTPUT"
158- echo "$reason: include_catalog=$include_catalog"
159-
160- - name: Build static configuration site
161- env:
162- INCLUDE_CATALOG: ${{ steps.catalog.outputs.include_catalog }}
163- run: |
164- if [[ "$INCLUDE_CATALOG" = true ]]; then
165- npm run build:config-site
166- else
167- npm run build:config-page
168- fi
169 124
170 - name: Back up mutable production files125 - name: Back up mutable production files
171 id: backup126 id: backup
@@ -188,9 +143,6 @@ jobs:
188 143
189 while IFS= read -r -d '' file; do144 while IFS= read -r -d '' file; do
190 relative_path="${file#var/config-site/}"145 relative_path="${file#var/config-site/}"
191- if [[ "$relative_path" =~ ^catalog/[a-f0-9]{64}/ ]]; then
192- continue
193- fi
194 case "$relative_path" in146 case "$relative_path" in
195 *[!A-Za-z0-9._/-]* | /* | *../*)147 *[!A-Za-z0-9._/-]* | /* | *../*)
196 echo "Refusing unsafe backup path: $relative_path" >&2148 echo "Refusing unsafe backup path: $relative_path" >&2
@@ -266,15 +218,10 @@ jobs:
266 218
267 export endpoint BUNNY_STORAGE_PASSWORD219 export endpoint BUNNY_STORAGE_PASSWORD
268 export -f upload_file220 export -f upload_file
269- find var/config-site -type f ! -path 'var/config-site/catalog/manifest.json' -print0 \221+ find var/config-site -type f -print0 \
270 | xargs -0 --no-run-if-empty --max-args=1 --max-procs=32 \222 | xargs -0 --no-run-if-empty --max-args=1 --max-procs=32 \
271 bash -c 'upload_file "$1"' _223 bash -c 'upload_file "$1"' _
272 224
273- # Publish the catalog pointer only after every immutable catalog file.
274- if [[ -f var/config-site/catalog/manifest.json ]]; then
275- upload_file var/config-site/catalog/manifest.json
276- fi
277-
278 - name: Purge Bunny CDN cache225 - name: Purge Bunny CDN cache
279 env:226 env:
280 BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }}227 BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }}
@@ -292,15 +239,9 @@ jobs:
292 - name: Verify served static site239 - name: Verify served static site
293 env:240 env:
294 CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }}241 CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }}
295- INCLUDE_CATALOG: ${{ steps.catalog.outputs.include_catalog }}
296 run: |242 run: |
297 for attempt in 1 2 3; do243 for attempt in 1 2 3; do
298- if [[ "$INCLUDE_CATALOG" = true ]]; then244+ if npm run verify:config-site -- --page-only; then
299- command=(npm run verify:config-site)
300- else
301- command=(npm run verify:config-site -- --page-only)
302- fi
303- if "${command[@]}"; then
304 exit 0245 exit 0
305 fi246 fi
306 if [[ "$attempt" != 3 ]]; then sleep 10; fi247 if [[ "$attempt" != 3 ]]; then sleep 10; fi
added .github/workflows/rebuild-catalog.yml +263 -0
new file mode 100644
@@ -0,0 +1,263 @@
1+name: Rebuild and publish IDFM catalog
2+
3+on:
4+ workflow_dispatch:
5+ inputs:
6+ force_catalog_upload:
7+ description: Upload a rebuilt catalog even when its IDFM source revision is already hosted
8+ required: true
9+ default: false
10+ type: boolean
11+
12+permissions:
13+ contents: read
14+
15+concurrency:
16+ group: deploy-bunny-production
17+ # Keep catalog publication, page deployment, and rollback serialized.
18+ cancel-in-progress: false
19+
20+jobs:
21+ test:
22+ name: Run quality checks
23+ runs-on: ubuntu-latest
24+ steps:
25+ - uses: actions/checkout@v4
26+
27+ - uses: actions/setup-node@v4
28+ with:
29+ node-version-file: .node-version
30+ cache: npm
31+
32+ - run: npm ci
33+
34+ - run: npm test
35+
36+ deploy:
37+ name: Rebuild and publish catalog
38+ needs: test
39+ runs-on: ubuntu-latest
40+ environment: production
41+ steps:
42+ - uses: actions/checkout@v4
43+
44+ - uses: actions/setup-node@v4
45+ with:
46+ node-version-file: .node-version
47+ cache: npm
48+
49+ - run: npm ci
50+
51+ - name: Refresh production catalog
52+ env:
53+ IDFM_DATASET_TOKEN: ${{ secrets.IDFM_DATASET_TOKEN }}
54+ run: npm run catalog:refresh
55+
56+ - name: Plan catalog upload
57+ id: catalog
58+ env:
59+ CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }}
60+ FORCE_CATALOG_UPLOAD: ${{ inputs.force_catalog_upload }}
61+ run: |
62+ set -euo pipefail
63+ if [[ "$FORCE_CATALOG_UPLOAD" = true ]]; then
64+ include_catalog=true
65+ reason="catalog upload was forced"
66+ else
67+ include_catalog="$(node scripts/plan-catalog-upload.mjs)"
68+ reason="catalog source-revision comparison completed"
69+ fi
70+ echo "include_catalog=$include_catalog" >> "$GITHUB_OUTPUT"
71+ echo "$reason: include_catalog=$include_catalog"
72+
73+
74+ - name: Back up mutable production files
75+ id: backup
76+ if: ${{ steps.catalog.outputs.include_catalog == 'true' }}
77+ shell: bash
78+ env:
79+ BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }}
80+ BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
81+ run: |
82+ set -euo pipefail
83+
84+ test -n "$BUNNY_STORAGE_ENDPOINT"
85+ test -n "$BUNNY_STORAGE_PASSWORD"
86+ endpoint="${BUNNY_STORAGE_ENDPOINT%/}"
87+ case "$endpoint" in
88+ https://*) ;;
89+ *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;;
90+ esac
91+ mkdir -p var/deploy-backup/files
92+ : > var/deploy-backup/missing
93+
94+ while IFS= read -r -d '' file; do
95+ relative_path="catalog/${file#var/catalog/static/}"
96+ case "$relative_path" in
97+ *[!A-Za-z0-9._/-]* | /* | *../*)
98+ echo "Refusing unsafe backup path: $relative_path" >&2
99+ exit 1
100+ ;;
101+ esac
102+
103+ target="var/deploy-backup/files/$relative_path"
104+ mkdir -p "$(dirname "$target")"
105+ status="$(curl --silent --show-error \
106+ --output "$target" \
107+ --write-out '%{http_code}' \
108+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
109+ "$endpoint/$relative_path")"
110+ case "$status" in
111+ 200) ;;
112+ 404)
113+ rm -f "$target"
114+ printf '%s\0' "$relative_path" >> var/deploy-backup/missing
115+ ;;
116+ *)
117+ echo "Could not back up $relative_path: HTTP $status" >&2
118+ exit 1
119+ ;;
120+ esac
121+ done < <(find var/catalog/static -maxdepth 1 -type f -name manifest.json -print0)
122+
123+ - name: Retain rollback bundle
124+ if: ${{ steps.catalog.outputs.include_catalog == 'true' }}
125+ uses: actions/upload-artifact@v4
126+ with:
127+ name: bunny-rollback-${{ github.run_id }}
128+ path: var/deploy-backup/
129+ if-no-files-found: error
130+ retention-days: 30
131+
132+ - name: Upload catalog to Bunny Storage
133+ id: upload
134+ if: ${{ steps.catalog.outputs.include_catalog == 'true' }}
135+ shell: bash
136+ env:
137+ BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }}
138+ BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
139+ run: |
140+ set -euo pipefail
141+
142+ test -n "$BUNNY_STORAGE_ENDPOINT"
143+ test -n "$BUNNY_STORAGE_PASSWORD"
144+ endpoint="${BUNNY_STORAGE_ENDPOINT%/}"
145+ case "$endpoint" in
146+ https://*) ;;
147+ *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;;
148+ esac
149+
150+ upload_file() {
151+ local file="$1"
152+ local relative_path="catalog/${file#var/catalog/static/}"
153+ local mime_type
154+ case "$relative_path" in
155+ *[!A-Za-z0-9._/-]* | /* | *../*)
156+ echo "Refusing unsafe deployment path: $relative_path" >&2
157+ exit 1
158+ ;;
159+ esac
160+ mime_type="$(file --brief --mime-type "$file")"
161+ echo "Uploading $relative_path"
162+ curl --fail --silent --show-error \
163+ --retry 3 --retry-all-errors \
164+ --request PUT \
165+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
166+ --header "Content-Type: $mime_type" \
167+ --upload-file "$file" \
168+ "$endpoint/$relative_path"
169+ }
170+
171+ export endpoint BUNNY_STORAGE_PASSWORD
172+ export -f upload_file
173+ find var/catalog/static -type f ! -path 'var/catalog/static/manifest.json' -print0 \
174+ | xargs -0 --no-run-if-empty --max-args=1 --max-procs=75 \
175+ bash -c 'upload_file "$1"' _
176+
177+ # Publish the catalog pointer only after every immutable catalog file.
178+ upload_file var/catalog/static/manifest.json
179+
180+ - name: Purge Bunny CDN cache
181+ if: ${{ steps.catalog.outputs.include_catalog == 'true' }}
182+ env:
183+ BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }}
184+ BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID }}
185+ run: |
186+ set -euo pipefail
187+ test -n "$BUNNY_API_KEY"
188+ test -n "$BUNNY_PULL_ZONE_ID"
189+ curl --fail --silent --show-error \
190+ --retry 3 --retry-all-errors \
191+ --request POST \
192+ --header "AccessKey: $BUNNY_API_KEY" \
193+ "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache"
194+
195+ - name: Verify served catalog
196+ if: ${{ steps.catalog.outputs.include_catalog == 'true' }}
197+ env:
198+ CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }}
199+ run: |
200+ for attempt in 1 2 3; do
201+ if npm run verify:config-site -- --catalog-only; then
202+ exit 0
203+ fi
204+ if [[ "$attempt" != 3 ]]; then sleep 10; fi
205+ done
206+ exit 1
207+
208+ - name: Restore previous mutable files after failure
209+ if: ${{ failure() && steps.backup.outcome == 'success' }}
210+ shell: bash
211+ env:
212+ BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }}
213+ BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
214+ BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }}
215+ BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID }}
216+ run: |
217+ set -euo pipefail
218+
219+ endpoint="${BUNNY_STORAGE_ENDPOINT%/}"
220+ case "$endpoint" in
221+ https://*) ;;
222+ *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;;
223+ esac
224+ restore_file() {
225+ local file="$1"
226+ local relative_path="${file#var/deploy-backup/files/}"
227+ local mime_type
228+ mime_type="$(file --brief --mime-type "$file")"
229+ echo "Restoring $relative_path"
230+ curl --fail --silent --show-error \
231+ --request PUT \
232+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
233+ --header "Content-Type: $mime_type" \
234+ --upload-file "$file" \
235+ "$endpoint/$relative_path"
236+ }
237+ export endpoint BUNNY_STORAGE_PASSWORD
238+ export -f restore_file
239+ find var/deploy-backup/files -type f -print0 \
240+ | xargs -0 --no-run-if-empty --max-args=1 --max-procs=16 \
241+ bash -c 'restore_file "$1"' _
242+
243+ while IFS= read -r -d '' relative_path; do
244+ echo "Removing newly introduced mutable file $relative_path"
245+ status="$(curl --silent --show-error \
246+ --request DELETE \
247+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
248+ --output /dev/null \
249+ --write-out '%{http_code}' \
250+ "$endpoint/$relative_path")"
251+ case "$status" in
252+ 200 | 201 | 204 | 404) ;;
253+ *)
254+ echo "Could not remove $relative_path during rollback: HTTP $status" >&2
255+ exit 1
256+ ;;
257+ esac
258+ done < var/deploy-backup/missing
259+
260+ curl --fail --silent --show-error \
261+ --request POST \
262+ --header "AccessKey: $BUNNY_API_KEY" \
263+ "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache"
new file mode 100644
@@ -0,0 +1,263 @@
1+name: Rebuild and publish IDFM catalog
2+
3+on:
4+ workflow_dispatch:
5+ inputs:
6+ force_catalog_upload:
7+ description: Upload a rebuilt catalog even when its IDFM source revision is already hosted
8+ required: true
9+ default: false
10+ type: boolean
11+
12+permissions:
13+ contents: read
14+
15+concurrency:
16+ group: deploy-bunny-production
17+ # Keep catalog publication, page deployment, and rollback serialized.
18+ cancel-in-progress: false
19+
20+jobs:
21+ test:
22+ name: Run quality checks
23+ runs-on: ubuntu-latest
24+ steps:
25+ - uses: actions/checkout@v4
26+
27+ - uses: actions/setup-node@v4
28+ with:
29+ node-version-file: .node-version
30+ cache: npm
31+
32+ - run: npm ci
33+
34+ - run: npm test
35+
36+ deploy:
37+ name: Rebuild and publish catalog
38+ needs: test
39+ runs-on: ubuntu-latest
40+ environment: production
41+ steps:
42+ - uses: actions/checkout@v4
43+
44+ - uses: actions/setup-node@v4
45+ with:
46+ node-version-file: .node-version
47+ cache: npm
48+
49+ - run: npm ci
50+
51+ - name: Refresh production catalog
52+ env:
53+ IDFM_DATASET_TOKEN: ${{ secrets.IDFM_DATASET_TOKEN }}
54+ run: npm run catalog:refresh
55+
56+ - name: Plan catalog upload
57+ id: catalog
58+ env:
59+ CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }}
60+ FORCE_CATALOG_UPLOAD: ${{ inputs.force_catalog_upload }}
61+ run: |
62+ set -euo pipefail
63+ if [[ "$FORCE_CATALOG_UPLOAD" = true ]]; then
64+ include_catalog=true
65+ reason="catalog upload was forced"
66+ else
67+ include_catalog="$(node scripts/plan-catalog-upload.mjs)"
68+ reason="catalog source-revision comparison completed"
69+ fi
70+ echo "include_catalog=$include_catalog" >> "$GITHUB_OUTPUT"
71+ echo "$reason: include_catalog=$include_catalog"
72+
73+
74+ - name: Back up mutable production files
75+ id: backup
76+ if: ${{ steps.catalog.outputs.include_catalog == 'true' }}
77+ shell: bash
78+ env:
79+ BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }}
80+ BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
81+ run: |
82+ set -euo pipefail
83+
84+ test -n "$BUNNY_STORAGE_ENDPOINT"
85+ test -n "$BUNNY_STORAGE_PASSWORD"
86+ endpoint="${BUNNY_STORAGE_ENDPOINT%/}"
87+ case "$endpoint" in
88+ https://*) ;;
89+ *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;;
90+ esac
91+ mkdir -p var/deploy-backup/files
92+ : > var/deploy-backup/missing
93+
94+ while IFS= read -r -d '' file; do
95+ relative_path="catalog/${file#var/catalog/static/}"
96+ case "$relative_path" in
97+ *[!A-Za-z0-9._/-]* | /* | *../*)
98+ echo "Refusing unsafe backup path: $relative_path" >&2
99+ exit 1
100+ ;;
101+ esac
102+
103+ target="var/deploy-backup/files/$relative_path"
104+ mkdir -p "$(dirname "$target")"
105+ status="$(curl --silent --show-error \
106+ --output "$target" \
107+ --write-out '%{http_code}' \
108+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
109+ "$endpoint/$relative_path")"
110+ case "$status" in
111+ 200) ;;
112+ 404)
113+ rm -f "$target"
114+ printf '%s\0' "$relative_path" >> var/deploy-backup/missing
115+ ;;
116+ *)
117+ echo "Could not back up $relative_path: HTTP $status" >&2
118+ exit 1
119+ ;;
120+ esac
121+ done < <(find var/catalog/static -maxdepth 1 -type f -name manifest.json -print0)
122+
123+ - name: Retain rollback bundle
124+ if: ${{ steps.catalog.outputs.include_catalog == 'true' }}
125+ uses: actions/upload-artifact@v4
126+ with:
127+ name: bunny-rollback-${{ github.run_id }}
128+ path: var/deploy-backup/
129+ if-no-files-found: error
130+ retention-days: 30
131+
132+ - name: Upload catalog to Bunny Storage
133+ id: upload
134+ if: ${{ steps.catalog.outputs.include_catalog == 'true' }}
135+ shell: bash
136+ env:
137+ BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }}
138+ BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
139+ run: |
140+ set -euo pipefail
141+
142+ test -n "$BUNNY_STORAGE_ENDPOINT"
143+ test -n "$BUNNY_STORAGE_PASSWORD"
144+ endpoint="${BUNNY_STORAGE_ENDPOINT%/}"
145+ case "$endpoint" in
146+ https://*) ;;
147+ *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;;
148+ esac
149+
150+ upload_file() {
151+ local file="$1"
152+ local relative_path="catalog/${file#var/catalog/static/}"
153+ local mime_type
154+ case "$relative_path" in
155+ *[!A-Za-z0-9._/-]* | /* | *../*)
156+ echo "Refusing unsafe deployment path: $relative_path" >&2
157+ exit 1
158+ ;;
159+ esac
160+ mime_type="$(file --brief --mime-type "$file")"
161+ echo "Uploading $relative_path"
162+ curl --fail --silent --show-error \
163+ --retry 3 --retry-all-errors \
164+ --request PUT \
165+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
166+ --header "Content-Type: $mime_type" \
167+ --upload-file "$file" \
168+ "$endpoint/$relative_path"
169+ }
170+
171+ export endpoint BUNNY_STORAGE_PASSWORD
172+ export -f upload_file
173+ find var/catalog/static -type f ! -path 'var/catalog/static/manifest.json' -print0 \
174+ | xargs -0 --no-run-if-empty --max-args=1 --max-procs=75 \
175+ bash -c 'upload_file "$1"' _
176+
177+ # Publish the catalog pointer only after every immutable catalog file.
178+ upload_file var/catalog/static/manifest.json
179+
180+ - name: Purge Bunny CDN cache
181+ if: ${{ steps.catalog.outputs.include_catalog == 'true' }}
182+ env:
183+ BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }}
184+ BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID }}
185+ run: |
186+ set -euo pipefail
187+ test -n "$BUNNY_API_KEY"
188+ test -n "$BUNNY_PULL_ZONE_ID"
189+ curl --fail --silent --show-error \
190+ --retry 3 --retry-all-errors \
191+ --request POST \
192+ --header "AccessKey: $BUNNY_API_KEY" \
193+ "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache"
194+
195+ - name: Verify served catalog
196+ if: ${{ steps.catalog.outputs.include_catalog == 'true' }}
197+ env:
198+ CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }}
199+ run: |
200+ for attempt in 1 2 3; do
201+ if npm run verify:config-site -- --catalog-only; then
202+ exit 0
203+ fi
204+ if [[ "$attempt" != 3 ]]; then sleep 10; fi
205+ done
206+ exit 1
207+
208+ - name: Restore previous mutable files after failure
209+ if: ${{ failure() && steps.backup.outcome == 'success' }}
210+ shell: bash
211+ env:
212+ BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }}
213+ BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
214+ BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }}
215+ BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID }}
216+ run: |
217+ set -euo pipefail
218+
219+ endpoint="${BUNNY_STORAGE_ENDPOINT%/}"
220+ case "$endpoint" in
221+ https://*) ;;
222+ *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;;
223+ esac
224+ restore_file() {
225+ local file="$1"
226+ local relative_path="${file#var/deploy-backup/files/}"
227+ local mime_type
228+ mime_type="$(file --brief --mime-type "$file")"
229+ echo "Restoring $relative_path"
230+ curl --fail --silent --show-error \
231+ --request PUT \
232+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
233+ --header "Content-Type: $mime_type" \
234+ --upload-file "$file" \
235+ "$endpoint/$relative_path"
236+ }
237+ export endpoint BUNNY_STORAGE_PASSWORD
238+ export -f restore_file
239+ find var/deploy-backup/files -type f -print0 \
240+ | xargs -0 --no-run-if-empty --max-args=1 --max-procs=16 \
241+ bash -c 'restore_file "$1"' _
242+
243+ while IFS= read -r -d '' relative_path; do
244+ echo "Removing newly introduced mutable file $relative_path"
245+ status="$(curl --silent --show-error \
246+ --request DELETE \
247+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
248+ --output /dev/null \
249+ --write-out '%{http_code}' \
250+ "$endpoint/$relative_path")"
251+ case "$status" in
252+ 200 | 201 | 204 | 404) ;;
253+ *)
254+ echo "Could not remove $relative_path during rollback: HTTP $status" >&2
255+ exit 1
256+ ;;
257+ esac
258+ done < var/deploy-backup/missing
259+
260+ curl --fail --silent --show-error \
261+ --request POST \
262+ --header "AccessKey: $BUNNY_API_KEY" \
263+ "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache"
modified .github/workflows/rollback-bunny.yml +7 -2
@@ -35,7 +35,11 @@ jobs:
3535 --jq '[.status, .path, .event, .head_repository.full_name] | @tsv')"
3636 IFS=$'\t' read -r status workflow_path event head_repository <<< "$metadata"
3737 test "$status" = completed
38- test "$workflow_path" = ".github/workflows/deploy-bunny.yml"
38+ case "$workflow_path" in
39+ .github/workflows/deploy-bunny.yml) ;;
40+ .github/workflows/rebuild-catalog.yml) test "$event" = workflow_dispatch ;;
41+ *) echo "Unsupported deployment workflow: $workflow_path" >&2; exit 1 ;;
42+ esac
3943 test "$head_repository" = "$GH_REPO"
4044 [[ "$event" = push || "$event" = workflow_dispatch ]]
4145
@@ -49,8 +53,9 @@ jobs:
4953 --repo "$GH_REPO" \
5054 --name "bunny-rollback-$DEPLOYMENT_RUN_ID" \
5155 --dir var/deploy-backup
52- test -d var/deploy-backup/files
5356 test -f var/deploy-backup/missing
57+ # Artifact downloads omit empty directories on first catalog publication.
58+ mkdir -p var/deploy-backup/files
5459
5560 - name: Restore previous mutable files
5661 env:
@@ -35,7 +35,11 @@ jobs:
35 --jq '[.status, .path, .event, .head_repository.full_name] | @tsv')"35 --jq '[.status, .path, .event, .head_repository.full_name] | @tsv')"
36 IFS=$'\t' read -r status workflow_path event head_repository <<< "$metadata"36 IFS=$'\t' read -r status workflow_path event head_repository <<< "$metadata"
37 test "$status" = completed37 test "$status" = completed
38- test "$workflow_path" = ".github/workflows/deploy-bunny.yml"38+ case "$workflow_path" in
39+ .github/workflows/deploy-bunny.yml) ;;
40+ .github/workflows/rebuild-catalog.yml) test "$event" = workflow_dispatch ;;
41+ *) echo "Unsupported deployment workflow: $workflow_path" >&2; exit 1 ;;
42+ esac
39 test "$head_repository" = "$GH_REPO"43 test "$head_repository" = "$GH_REPO"
40 [[ "$event" = push || "$event" = workflow_dispatch ]]44 [[ "$event" = push || "$event" = workflow_dispatch ]]
41 45
@@ -49,8 +53,9 @@ jobs:
49 --repo "$GH_REPO" \53 --repo "$GH_REPO" \
50 --name "bunny-rollback-$DEPLOYMENT_RUN_ID" \54 --name "bunny-rollback-$DEPLOYMENT_RUN_ID" \
51 --dir var/deploy-backup55 --dir var/deploy-backup
52- test -d var/deploy-backup/files
53 test -f var/deploy-backup/missing56 test -f var/deploy-backup/missing
57+ # Artifact downloads omit empty directories on first catalog publication.
58+ mkdir -p var/deploy-backup/files
54 59
55 - name: Restore previous mutable files60 - name: Restore previous mutable files
56 env:61 env:
modified CHANGELOG.md +9 -0
@@ -1,5 +1,14 @@
11 # Changelog
22
3+## [Unreleased]
4+
5+Catalog rebuilds and publication now run independently of settings-page deployment and watch releases.
6+
7+### Changes
8+
9+- ci: extract a manual catalog-only workflow with up to 75 concurrent curl uploads, manifest-last publication, and rollback support.
10+- ci: keep PBW builds, settings-page deployment, and GitHub releases in the release workflow.
11+
312 ## [1.2.0] - 2026-09-20
413
514 The configuration page can now override the watch app language independently of the watch system settings. Automatic remains the default; explicit French or English choices are saved on the phone and survive restarts.
@@ -1,5 +1,14 @@
1 # Changelog1 # Changelog
2 2
3+## [Unreleased]
4+
5+Catalog rebuilds and publication now run independently of settings-page deployment and watch releases.
6+
7+### Changes
8+
9+- ci: extract a manual catalog-only workflow with up to 75 concurrent curl uploads, manifest-last publication, and rollback support.
10+- ci: keep PBW builds, settings-page deployment, and GitHub releases in the release workflow.
11+
3 ## [1.2.0] - 2026-09-2012 ## [1.2.0] - 2026-09-20
4 13
5 The configuration page can now override the watch app language independently of the watch system settings. Automatic remains the default; explicit French or English choices are saved on the phone and survive restarts.14 The configuration page can now override the watch app language independently of the watch system settings. Automatic remains the default; explicit French or English choices are saved on the phone and survive restarts.
modified README.md +4 -0
@@ -110,6 +110,10 @@ npm run build:config-site # assemble var/config-site; f
110110
111111 `node --env-file=.env scripts/probe-catalog.mjs` runs a small live check against PRIM, one request per transport mode, and needs a `PRIM_API_KEY`. Operator tokens live only in `.env` and are never printed or embedded in build output.
112112
113+To rebuild the hosted catalog independently, run **Rebuild and publish IDFM catalog** from GitHub Actions, using `.github/workflows/rebuild-catalog.yml`. It uses the existing `production` environment's IDFM and Bunny credentials and publishes only catalog files, with up to 75 concurrent curl uploads. The manifest is uploaded last. It does not build the PBW, deploy the settings page, or create a GitHub release.
114+
115+Uploads are skipped when the hosted IDFM source revision is unchanged. Enable `force_catalog_upload` when republishing a catalog format change from the same source data. The workflow retains the previous manifest for rollback and shares the production publication lock with page deployments and rollbacks. Release tags continue through `deploy-bunny.yml`, without rebuilding the catalog.
116+
113117 ## Contributing
114118
115119 Bug reports are welcome on the [issue tracker](https://github.com/Anthodev/lapin-fute/issues). A useful report includes the watch model and firmware, the phone setup, the steps to reproduce, and whether the data shown was live or cached. Never paste your PRIM token or other personal data in an issue.
@@ -110,6 +110,10 @@ npm run build:config-site # assemble var/config-site; f
110 110
111 `node --env-file=.env scripts/probe-catalog.mjs` runs a small live check against PRIM, one request per transport mode, and needs a `PRIM_API_KEY`. Operator tokens live only in `.env` and are never printed or embedded in build output.111 `node --env-file=.env scripts/probe-catalog.mjs` runs a small live check against PRIM, one request per transport mode, and needs a `PRIM_API_KEY`. Operator tokens live only in `.env` and are never printed or embedded in build output.
112 112
113+To rebuild the hosted catalog independently, run **Rebuild and publish IDFM catalog** from GitHub Actions, using `.github/workflows/rebuild-catalog.yml`. It uses the existing `production` environment's IDFM and Bunny credentials and publishes only catalog files, with up to 75 concurrent curl uploads. The manifest is uploaded last. It does not build the PBW, deploy the settings page, or create a GitHub release.
114+
115+Uploads are skipped when the hosted IDFM source revision is unchanged. Enable `force_catalog_upload` when republishing a catalog format change from the same source data. The workflow retains the previous manifest for rollback and shares the production publication lock with page deployments and rollbacks. Release tags continue through `deploy-bunny.yml`, without rebuilding the catalog.
116+
113 ## Contributing117 ## Contributing
114 118
115 Bug reports are welcome on the [issue tracker](https://github.com/Anthodev/lapin-fute/issues). A useful report includes the watch model and firmware, the phone setup, the steps to reproduce, and whether the data shown was live or cached. Never paste your PRIM token or other personal data in an issue.119 Bug reports are welcome on the [issue tracker](https://github.com/Anthodev/lapin-fute/issues). A useful report includes the watch model and firmware, the phone setup, the steps to reproduce, and whether the data shown was live or cached. Never paste your PRIM token or other personal data in an issue.
modified scripts/verify-config-site.mjs +20 -11
@@ -58,13 +58,15 @@ function parseJson(path, label) {
5858 }
5959 }
6060
61-function verifyLocalSite(sitePath, includeCatalog) {
61+function verifyLocalSite(sitePath, includeCatalog, catalogOnly) {
6262 const root = resolve(sitePath);
6363 if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
6464 fail(`site directory does not exist: ${root}`);
6565 }
66- const indexPath = join(root, "index.html");
67- if (!statSync(indexPath, { throwIfNoEntry: false })?.isFile()) fail("index.html is missing");
66+ if (!catalogOnly) {
67+ const indexPath = join(root, "index.html");
68+ if (!statSync(indexPath, { throwIfNoEntry: false })?.isFile()) fail("index.html is missing");
69+ }
6870 if (!includeCatalog) {
6971 return {
7072 root,
@@ -74,12 +76,13 @@ function verifyLocalSite(sitePath, includeCatalog) {
7476 };
7577 }
7678
77- const manifestPath = join(root, "catalog", "manifest.json");
79+ const catalogRoot = catalogOnly ? root : join(root, "catalog");
80+ const manifestPath = join(catalogRoot, "manifest.json");
7881 if (!statSync(manifestPath, { throwIfNoEntry: false })?.isFile()) fail("catalog/manifest.json is missing");
7982
8083 const manifest = parseJson(manifestPath, "catalog manifest");
8184 if (!isCatalogManifest(manifest)) fail("catalog manifest does not satisfy the static catalog contract");
82- const revisionRoot = join(root, "catalog", manifest.revision);
85+ const revisionRoot = join(catalogRoot, manifest.revision);
8386 if (!statSync(revisionRoot, { throwIfNoEntry: false })?.isDirectory()) {
8487 fail(`catalog revision directory is missing: ${manifest.revision}`);
8588 }
@@ -187,10 +190,13 @@ export async function verifyConfigSite({
187190 origin,
188191 fetcher = globalThis.fetch,
189192 includeCatalog = true,
193+ catalogOnly = false,
190194 } = {}) {
191- const local = verifyLocalSite(sitePath, includeCatalog);
195+ if (catalogOnly && !includeCatalog) fail("catalog-only and page-only modes are mutually exclusive");
196+ const local = verifyLocalSite(sitePath, includeCatalog, catalogOnly);
192197 const parsedOrigin = siteOrigin(origin);
193- await verifyServedFiles(local, parsedOrigin, fetcher);
198+ const servedOrigin = catalogOnly ? new URL("catalog/", parsedOrigin) : parsedOrigin;
199+ await verifyServedFiles(local, servedOrigin, fetcher);
194200 return {
195201 origin: parsedOrigin.href,
196202 revision: local.manifest?.revision ?? null,
@@ -204,14 +210,17 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
204210 try {
205211 const arguments_ = process.argv.slice(2);
206212 const pageOnly = arguments_.includes("--page-only");
207- const positional = arguments_.filter((argument) => argument !== "--page-only");
208- if (positional.length > 2 || arguments_.length !== positional.length + (pageOnly ? 1 : 0)) {
209- fail("usage: verify-config-site.mjs [site-path] [origin] [--page-only]");
213+ const catalogOnly = arguments_.includes("--catalog-only");
214+ const positional = arguments_.filter((argument) => argument !== "--page-only" && argument !== "--catalog-only");
215+ if (positional.length > 2
216+ || arguments_.length !== positional.length + (pageOnly ? 1 : 0) + (catalogOnly ? 1 : 0)) {
217+ fail("usage: verify-config-site.mjs [site-path] [origin] [--page-only | --catalog-only]");
210218 }
211219 const result = await verifyConfigSite({
212- sitePath: positional[0] ?? DEFAULT_SITE_PATH,
220+ sitePath: positional[0] ?? (catalogOnly ? join(PROJECT_ROOT, "var/catalog/static") : DEFAULT_SITE_PATH),
213221 origin: positional[1] ?? process.env.CONFIG_SITE_ORIGIN,
214222 includeCatalog: !pageOnly,
223+ catalogOnly,
215224 });
216225 console.log(JSON.stringify(result, null, 2));
217226 } catch (error) {
@@ -58,13 +58,15 @@ function parseJson(path, label) {
58 }58 }
59 }59 }
60 60
61-function verifyLocalSite(sitePath, includeCatalog) {61+function verifyLocalSite(sitePath, includeCatalog, catalogOnly) {
62 const root = resolve(sitePath);62 const root = resolve(sitePath);
63 if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) {63 if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
64 fail(`site directory does not exist: ${root}`);64 fail(`site directory does not exist: ${root}`);
65 }65 }
66- const indexPath = join(root, "index.html");66+ if (!catalogOnly) {
67- if (!statSync(indexPath, { throwIfNoEntry: false })?.isFile()) fail("index.html is missing");67+ const indexPath = join(root, "index.html");
68+ if (!statSync(indexPath, { throwIfNoEntry: false })?.isFile()) fail("index.html is missing");
69+ }
68 if (!includeCatalog) {70 if (!includeCatalog) {
69 return {71 return {
70 root,72 root,
@@ -74,12 +76,13 @@ function verifyLocalSite(sitePath, includeCatalog) {
74 };76 };
75 }77 }
76 78
77- const manifestPath = join(root, "catalog", "manifest.json");79+ const catalogRoot = catalogOnly ? root : join(root, "catalog");
80+ const manifestPath = join(catalogRoot, "manifest.json");
78 if (!statSync(manifestPath, { throwIfNoEntry: false })?.isFile()) fail("catalog/manifest.json is missing");81 if (!statSync(manifestPath, { throwIfNoEntry: false })?.isFile()) fail("catalog/manifest.json is missing");
79 82
80 const manifest = parseJson(manifestPath, "catalog manifest");83 const manifest = parseJson(manifestPath, "catalog manifest");
81 if (!isCatalogManifest(manifest)) fail("catalog manifest does not satisfy the static catalog contract");84 if (!isCatalogManifest(manifest)) fail("catalog manifest does not satisfy the static catalog contract");
82- const revisionRoot = join(root, "catalog", manifest.revision);85+ const revisionRoot = join(catalogRoot, manifest.revision);
83 if (!statSync(revisionRoot, { throwIfNoEntry: false })?.isDirectory()) {86 if (!statSync(revisionRoot, { throwIfNoEntry: false })?.isDirectory()) {
84 fail(`catalog revision directory is missing: ${manifest.revision}`);87 fail(`catalog revision directory is missing: ${manifest.revision}`);
85 }88 }
@@ -187,10 +190,13 @@ export async function verifyConfigSite({
187 origin,190 origin,
188 fetcher = globalThis.fetch,191 fetcher = globalThis.fetch,
189 includeCatalog = true,192 includeCatalog = true,
193+ catalogOnly = false,
190 } = {}) {194 } = {}) {
191- const local = verifyLocalSite(sitePath, includeCatalog);195+ if (catalogOnly && !includeCatalog) fail("catalog-only and page-only modes are mutually exclusive");
196+ const local = verifyLocalSite(sitePath, includeCatalog, catalogOnly);
192 const parsedOrigin = siteOrigin(origin);197 const parsedOrigin = siteOrigin(origin);
193- await verifyServedFiles(local, parsedOrigin, fetcher);198+ const servedOrigin = catalogOnly ? new URL("catalog/", parsedOrigin) : parsedOrigin;
199+ await verifyServedFiles(local, servedOrigin, fetcher);
194 return {200 return {
195 origin: parsedOrigin.href,201 origin: parsedOrigin.href,
196 revision: local.manifest?.revision ?? null,202 revision: local.manifest?.revision ?? null,
@@ -204,14 +210,17 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) {
204 try {210 try {
205 const arguments_ = process.argv.slice(2);211 const arguments_ = process.argv.slice(2);
206 const pageOnly = arguments_.includes("--page-only");212 const pageOnly = arguments_.includes("--page-only");
207- const positional = arguments_.filter((argument) => argument !== "--page-only");213+ const catalogOnly = arguments_.includes("--catalog-only");
208- if (positional.length > 2 || arguments_.length !== positional.length + (pageOnly ? 1 : 0)) {214+ const positional = arguments_.filter((argument) => argument !== "--page-only" && argument !== "--catalog-only");
209- fail("usage: verify-config-site.mjs [site-path] [origin] [--page-only]");215+ if (positional.length > 2
216+ || arguments_.length !== positional.length + (pageOnly ? 1 : 0) + (catalogOnly ? 1 : 0)) {
217+ fail("usage: verify-config-site.mjs [site-path] [origin] [--page-only | --catalog-only]");
210 }218 }
211 const result = await verifyConfigSite({219 const result = await verifyConfigSite({
212- sitePath: positional[0] ?? DEFAULT_SITE_PATH,220+ sitePath: positional[0] ?? (catalogOnly ? join(PROJECT_ROOT, "var/catalog/static") : DEFAULT_SITE_PATH),
213 origin: positional[1] ?? process.env.CONFIG_SITE_ORIGIN,221 origin: positional[1] ?? process.env.CONFIG_SITE_ORIGIN,
214 includeCatalog: !pageOnly,222 includeCatalog: !pageOnly,
223+ catalogOnly,
215 });224 });
216 console.log(JSON.stringify(result, null, 2));225 console.log(JSON.stringify(result, null, 2));
217 } catch (error) {226 } catch (error) {