anthodev/lapin-futepublic Fork
⑂ b6ec13d
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

feat: Ship Lapin Futé v1.0.0 (#21)Unverified

* chore: set release version to 1.0.0

* feat: verify published configuration site

* ci: verify deploys and restore failures

* chore: ignore Pebble build lock

* docs: research official v1 release requirements

* ci: package PBW release assets

* ci: retain and rehearse deployment rollback

* docs: add v1 release checklist

* fix: close release verification gaps

* ci: skip unchanged catalog uploads

* docs: streamline project overview and controls

* chore: add RePebble store icons

* chore: add RePebble store screenshots and banner

* fix: normalize Emery store screenshot brightness

* docs: link published RePebble release

* docs: remove completed release planning notes
Cédric Anthony committed 2026-09-13T13:49:39+02:00 Browse files
b6ec13d parent: 835b18b
added .github/store-assets/README.md +22 -0
new file mode 100644
@@ -0,0 +1,22 @@
1+# RePebble store assets
2+
3+These files are prepared for the RePebble app listing:
4+
5+| File | Size | Content |
6+| --- | --- | --- |
7+| `emery_01-favorites.png` | 200 × 228 | Favourite stop overview |
8+| `emery_02-departures.png` | 200 × 228 | Upcoming departures |
9+| `emery_03-traffic.png` | 200 × 228 | Traffic status |
10+| `gabbro_01-favorites.png` | 260 × 260 | Favourite stop overview |
11+| `gabbro_02-departures.png` | 260 × 260 | Upcoming departures |
12+| `gabbro_03-traffic.png` | 260 × 260 | Traffic status |
13+| `lapin-fute-banner-720x320.png` | 720 × 320 | Optional store banner |
14+
15+The screenshots are emulator framebuffer captures made with the project's
16+fixture data. The Emery captures have been normalized from the emulator's
17+backlight-off palette to its full-backlight palette; their UI content is
18+unchanged. They demonstrate the app UI but are not evidence of a production-data
19+or physical-watch test.
20+
21+The banner is derived from the existing Lapin Futé visual identity and contains
22+no screenshot or watch mock-up.
new file mode 100644
@@ -0,0 +1,22 @@
1+# RePebble store assets
2+
3+These files are prepared for the RePebble app listing:
4+
5+| File | Size | Content |
6+| --- | --- | --- |
7+| `emery_01-favorites.png` | 200 × 228 | Favourite stop overview |
8+| `emery_02-departures.png` | 200 × 228 | Upcoming departures |
9+| `emery_03-traffic.png` | 200 × 228 | Traffic status |
10+| `gabbro_01-favorites.png` | 260 × 260 | Favourite stop overview |
11+| `gabbro_02-departures.png` | 260 × 260 | Upcoming departures |
12+| `gabbro_03-traffic.png` | 260 × 260 | Traffic status |
13+| `lapin-fute-banner-720x320.png` | 720 × 320 | Optional store banner |
14+
15+The screenshots are emulator framebuffer captures made with the project's
16+fixture data. The Emery captures have been normalized from the emulator's
17+backlight-off palette to its full-backlight palette; their UI content is
18+unchanged. They demonstrate the app UI but are not evidence of a production-data
19+or physical-watch test.
20+
21+The banner is derived from the existing Lapin Futé visual identity and contains
22+no screenshot or watch mock-up.
added .github/store-assets/emery_01-favorites.png +0 -0
new file mode 100644
Binary files /dev/null and b/.github/store-assets/emery_01-favorites.png differ
new file mode 100644
Binary files /dev/null and b/.github/store-assets/emery_01-favorites.png differBinary files /dev/null and b/.github/store-assets/emery_01-favorites.png differ
added .github/store-assets/emery_02-departures.png +0 -0
new file mode 100644
Binary files /dev/null and b/.github/store-assets/emery_02-departures.png differ
new file mode 100644
Binary files /dev/null and b/.github/store-assets/emery_02-departures.png differBinary files /dev/null and b/.github/store-assets/emery_02-departures.png differ
added .github/store-assets/emery_03-traffic.png +0 -0
new file mode 100644
Binary files /dev/null and b/.github/store-assets/emery_03-traffic.png differ
new file mode 100644
Binary files /dev/null and b/.github/store-assets/emery_03-traffic.png differBinary files /dev/null and b/.github/store-assets/emery_03-traffic.png differ
added .github/store-assets/gabbro_01-favorites.png +0 -0
new file mode 100644
Binary files /dev/null and b/.github/store-assets/gabbro_01-favorites.png differ
new file mode 100644
Binary files /dev/null and b/.github/store-assets/gabbro_01-favorites.png differBinary files /dev/null and b/.github/store-assets/gabbro_01-favorites.png differ
added .github/store-assets/gabbro_02-departures.png +0 -0
new file mode 100644
Binary files /dev/null and b/.github/store-assets/gabbro_02-departures.png differ
new file mode 100644
Binary files /dev/null and b/.github/store-assets/gabbro_02-departures.png differBinary files /dev/null and b/.github/store-assets/gabbro_02-departures.png differ
added .github/store-assets/gabbro_03-traffic.png +0 -0
new file mode 100644
Binary files /dev/null and b/.github/store-assets/gabbro_03-traffic.png differ
new file mode 100644
Binary files /dev/null and b/.github/store-assets/gabbro_03-traffic.png differBinary files /dev/null and b/.github/store-assets/gabbro_03-traffic.png differ
added .github/store-assets/lapin-fute-banner-720x320.png +0 -0
new file mode 100644
Binary files /dev/null and b/.github/store-assets/lapin-fute-banner-720x320.png differ
new file mode 100644
Binary files /dev/null and b/.github/store-assets/lapin-fute-banner-720x320.png differBinary files /dev/null and b/.github/store-assets/lapin-fute-banner-720x320.png differ
modified .github/workflows/deploy-bunny.yml +233 -7
@@ -10,6 +10,11 @@ on:
1010 required: true
1111 default: false
1212 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
1318
1419 permissions:
1520 contents: read
@@ -45,6 +50,62 @@ jobs:
4550
4651 - run: npm test
4752
53+ package:
54+ name: Build release PBW
55+ needs: test
56+ if: ${{ startsWith(github.ref_name, 'v') }}
57+ runs-on: ubuntu-latest
58+ environment: production
59+ steps:
60+ - uses: actions/checkout@v4
61+
62+ - uses: actions/setup-node@v4
63+ with:
64+ node-version-file: .node-version
65+ cache: npm
66+
67+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
68+ with:
69+ bun-version: 1.4.0
70+
71+ - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
72+
73+ - run: npm ci
74+
75+ - name: Install pinned Pebble toolchain
76+ run: |
77+ uv tool install pebble-tool==5.0.40
78+ pebble sdk install 4.33.1
79+
80+ - name: Validate package version
81+ env:
82+ RELEASE_TAG: ${{ github.ref_name }}
83+ run: |
84+ PACKAGE_VERSION="$(node -p "require('./packages/watch/package.json').version")"
85+ test "$RELEASE_TAG" = "v$PACKAGE_VERSION"
86+
87+ - name: Build production PBW
88+ env:
89+ LAPIN_FUTE_CONFIG_URL: ${{ vars.CONFIG_SITE_ORIGIN }}
90+ run: npm run build
91+
92+ - name: Prepare release artifacts
93+ env:
94+ RELEASE_TAG: ${{ github.ref_name }}
95+ run: |
96+ mkdir release-assets
97+ cp packages/watch/build/watch.pbw "release-assets/lapin-fute-$RELEASE_TAG.pbw"
98+ (
99+ cd release-assets
100+ sha256sum "lapin-fute-$RELEASE_TAG.pbw" > SHA256SUMS
101+ )
102+
103+ - uses: actions/upload-artifact@v4
104+ with:
105+ name: lapin-fute-${{ github.ref_name }}
106+ path: release-assets/
107+ if-no-files-found: error
108+
48109 deploy:
49110 name: Build and deploy configuration site
50111 needs: test
@@ -67,15 +128,98 @@ jobs:
67128 IDFM_DATASET_TOKEN: ${{ secrets.IDFM_DATASET_TOKEN }}
68129 run: npm run catalog:refresh
69130
131+ - name: Plan catalog upload
132+ id: catalog
133+ env:
134+ CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }}
135+ EVENT_NAME: ${{ github.event_name }}
136+ FORCE_CATALOG_UPLOAD: ${{ inputs.force_catalog_upload }}
137+ run: |
138+ set -euo pipefail
139+ if [[ "$EVENT_NAME" != workflow_dispatch ]]; then
140+ include_catalog=false
141+ reason="release tags update the configuration page only"
142+ elif [[ "$FORCE_CATALOG_UPLOAD" = true ]]; then
143+ include_catalog=true
144+ reason="catalog upload was forced"
145+ else
146+ include_catalog="$(node scripts/plan-catalog-upload.mjs)"
147+ reason="catalog source-revision comparison completed"
148+ fi
149+ echo "include_catalog=$include_catalog" >> "$GITHUB_OUTPUT"
150+ echo "$reason: include_catalog=$include_catalog"
151+
70152 - name: Build static configuration site
71- if: ${{ github.event_name == 'workflow_dispatch' && inputs.refresh_catalog }}
72- run: npm run build:config-site
153+ env:
154+ INCLUDE_CATALOG: ${{ steps.catalog.outputs.include_catalog }}
155+ run: |
156+ if [[ "$INCLUDE_CATALOG" = true ]]; then
157+ npm run build:config-site
158+ else
159+ npm run build:config-page
160+ fi
161+
162+ - name: Back up mutable production files
163+ id: backup
164+ shell: bash
165+ env:
166+ BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }}
167+ BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
168+ run: |
169+ set -euo pipefail
170+
171+ test -n "$BUNNY_STORAGE_ENDPOINT"
172+ test -n "$BUNNY_STORAGE_PASSWORD"
173+ endpoint="${BUNNY_STORAGE_ENDPOINT%/}"
174+ case "$endpoint" in
175+ https://*) ;;
176+ *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;;
177+ esac
178+ mkdir -p var/deploy-backup/files
179+ : > var/deploy-backup/missing
73180
74- - name: Build configuration page
75- if: ${{ github.event_name != 'workflow_dispatch' || inputs.refresh_catalog != true }}
76- run: npm run build:config-page
181+ while IFS= read -r -d '' file; do
182+ relative_path="${file#var/config-site/}"
183+ if [[ "$relative_path" =~ ^catalog/[a-f0-9]{64}/ ]]; then
184+ continue
185+ fi
186+ case "$relative_path" in
187+ *[!A-Za-z0-9._/-]* | /* | *../*)
188+ echo "Refusing unsafe backup path: $relative_path" >&2
189+ exit 1
190+ ;;
191+ esac
192+
193+ target="var/deploy-backup/files/$relative_path"
194+ mkdir -p "$(dirname "$target")"
195+ status="$(curl --silent --show-error \
196+ --output "$target" \
197+ --write-out '%{http_code}' \
198+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
199+ "$endpoint/$relative_path")"
200+ case "$status" in
201+ 200) ;;
202+ 404)
203+ rm -f "$target"
204+ printf '%s\0' "$relative_path" >> var/deploy-backup/missing
205+ ;;
206+ *)
207+ echo "Could not back up $relative_path: HTTP $status" >&2
208+ exit 1
209+ ;;
210+ esac
211+ done < <(find var/config-site -type f -print0)
212+
213+ - name: Retain rollback bundle
214+ uses: actions/upload-artifact@v4
215+ with:
216+ name: bunny-rollback-${{ github.run_id }}
217+ path: var/deploy-backup/
218+ if-no-files-found: error
219+ retention-days: 30
77220
78221 - name: Upload static site to Bunny Storage
222+ id: upload
79223 shell: bash
80224 env:
81225 BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }}
@@ -137,9 +281,84 @@ jobs:
137281 --header "AccessKey: $BUNNY_API_KEY" \
138282 "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache"
139283
284+ - name: Verify served static site
285+ env:
286+ CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }}
287+ INCLUDE_CATALOG: ${{ steps.catalog.outputs.include_catalog }}
288+ run: |
289+ for attempt in 1 2 3; do
290+ if [[ "$INCLUDE_CATALOG" = true ]]; then
291+ command=(npm run verify:config-site)
292+ else
293+ command=(npm run verify:config-site -- --page-only)
294+ fi
295+ if "${command[@]}"; then
296+ exit 0
297+ fi
298+ if [[ "$attempt" != 3 ]]; then sleep 10; fi
299+ done
300+ exit 1
301+
302+ - name: Restore previous mutable files after failure
303+ if: ${{ failure() && steps.backup.outcome == 'success' }}
304+ shell: bash
305+ env:
306+ BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }}
307+ BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
308+ BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }}
309+ BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID }}
310+ run: |
311+ set -euo pipefail
312+
313+ endpoint="${BUNNY_STORAGE_ENDPOINT%/}"
314+ case "$endpoint" in
315+ https://*) ;;
316+ *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;;
317+ esac
318+ restore_file() {
319+ local file="$1"
320+ local relative_path="${file#var/deploy-backup/files/}"
321+ local mime_type
322+ mime_type="$(file --brief --mime-type "$file")"
323+ echo "Restoring $relative_path"
324+ curl --fail --silent --show-error \
325+ --request PUT \
326+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
327+ --header "Content-Type: $mime_type" \
328+ --upload-file "$file" \
329+ "$endpoint/$relative_path"
330+ }
331+ export endpoint BUNNY_STORAGE_PASSWORD
332+ export -f restore_file
333+ find var/deploy-backup/files -type f -print0 \
334+ | xargs -0 --no-run-if-empty --max-args=1 --max-procs=16 \
335+ bash -c 'restore_file "$1"' _
336+
337+ while IFS= read -r -d '' relative_path; do
338+ echo "Removing newly introduced mutable file $relative_path"
339+ status="$(curl --silent --show-error \
340+ --request DELETE \
341+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
342+ --output /dev/null \
343+ --write-out '%{http_code}' \
344+ "$endpoint/$relative_path")"
345+ case "$status" in
346+ 200 | 201 | 204 | 404) ;;
347+ *)
348+ echo "Could not remove $relative_path during rollback: HTTP $status" >&2
349+ exit 1
350+ ;;
351+ esac
352+ done < var/deploy-backup/missing
353+
354+ curl --fail --silent --show-error \
355+ --request POST \
356+ --header "AccessKey: $BUNNY_API_KEY" \
357+ "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache"
358+
140359 publish:
141360 name: Publish GitHub release notes
142- needs: [test, deploy]
361+ needs: [test, package, deploy]
143362 if: ${{ startsWith(github.ref_name, 'v') }}
144363 runs-on: ubuntu-latest
145364 permissions:
@@ -152,6 +371,12 @@ jobs:
152371 fetch-depth: 0
153372 persist-credentials: false
154373
374+ - name: Download release artifacts
375+ uses: actions/download-artifact@v4
376+ with:
377+ name: lapin-fute-${{ github.ref_name }}
378+ path: release-assets
379+
155380 - name: Generate categorized release notes
156381 shell: bash
157382 run: |
@@ -217,4 +442,5 @@ jobs:
217442 --repo "$GITHUB_REPOSITORY" \
218443 --title "Lapin Futé $GITHUB_REF_NAME" \
219444 --notes-file release-notes.md \
220- --verify-tag
445+ --verify-tag \
446+ release-assets/*
@@ -10,6 +10,11 @@ on:
10 required: true10 required: true
11 default: false11 default: false
12 type: boolean12 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
13 18
14 permissions:19 permissions:
15 contents: read20 contents: read
@@ -45,6 +50,62 @@ jobs:
45 50
46 - run: npm test51 - run: npm test
47 52
53+ package:
54+ name: Build release PBW
55+ needs: test
56+ if: ${{ startsWith(github.ref_name, 'v') }}
57+ runs-on: ubuntu-latest
58+ environment: production
59+ steps:
60+ - uses: actions/checkout@v4
61+
62+ - uses: actions/setup-node@v4
63+ with:
64+ node-version-file: .node-version
65+ cache: npm
66+
67+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
68+ with:
69+ bun-version: 1.4.0
70+
71+ - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
72+
73+ - run: npm ci
74+
75+ - name: Install pinned Pebble toolchain
76+ run: |
77+ uv tool install pebble-tool==5.0.40
78+ pebble sdk install 4.33.1
79+
80+ - name: Validate package version
81+ env:
82+ RELEASE_TAG: ${{ github.ref_name }}
83+ run: |
84+ PACKAGE_VERSION="$(node -p "require('./packages/watch/package.json').version")"
85+ test "$RELEASE_TAG" = "v$PACKAGE_VERSION"
86+
87+ - name: Build production PBW
88+ env:
89+ LAPIN_FUTE_CONFIG_URL: ${{ vars.CONFIG_SITE_ORIGIN }}
90+ run: npm run build
91+
92+ - name: Prepare release artifacts
93+ env:
94+ RELEASE_TAG: ${{ github.ref_name }}
95+ run: |
96+ mkdir release-assets
97+ cp packages/watch/build/watch.pbw "release-assets/lapin-fute-$RELEASE_TAG.pbw"
98+ (
99+ cd release-assets
100+ sha256sum "lapin-fute-$RELEASE_TAG.pbw" > SHA256SUMS
101+ )
102+
103+ - uses: actions/upload-artifact@v4
104+ with:
105+ name: lapin-fute-${{ github.ref_name }}
106+ path: release-assets/
107+ if-no-files-found: error
108+
48 deploy:109 deploy:
49 name: Build and deploy configuration site110 name: Build and deploy configuration site
50 needs: test111 needs: test
@@ -67,15 +128,98 @@ jobs:
67 IDFM_DATASET_TOKEN: ${{ secrets.IDFM_DATASET_TOKEN }}128 IDFM_DATASET_TOKEN: ${{ secrets.IDFM_DATASET_TOKEN }}
68 run: npm run catalog:refresh129 run: npm run catalog:refresh
69 130
131+ - name: Plan catalog upload
132+ id: catalog
133+ env:
134+ CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }}
135+ EVENT_NAME: ${{ github.event_name }}
136+ FORCE_CATALOG_UPLOAD: ${{ inputs.force_catalog_upload }}
137+ run: |
138+ set -euo pipefail
139+ if [[ "$EVENT_NAME" != workflow_dispatch ]]; then
140+ include_catalog=false
141+ reason="release tags update the configuration page only"
142+ elif [[ "$FORCE_CATALOG_UPLOAD" = true ]]; then
143+ include_catalog=true
144+ reason="catalog upload was forced"
145+ else
146+ include_catalog="$(node scripts/plan-catalog-upload.mjs)"
147+ reason="catalog source-revision comparison completed"
148+ fi
149+ echo "include_catalog=$include_catalog" >> "$GITHUB_OUTPUT"
150+ echo "$reason: include_catalog=$include_catalog"
151+
70 - name: Build static configuration site152 - name: Build static configuration site
71- if: ${{ github.event_name == 'workflow_dispatch' && inputs.refresh_catalog }}153+ env:
72- run: npm run build:config-site154+ INCLUDE_CATALOG: ${{ steps.catalog.outputs.include_catalog }}
155+ run: |
156+ if [[ "$INCLUDE_CATALOG" = true ]]; then
157+ npm run build:config-site
158+ else
159+ npm run build:config-page
160+ fi
161+
162+ - name: Back up mutable production files
163+ id: backup
164+ shell: bash
165+ env:
166+ BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }}
167+ BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
168+ run: |
169+ set -euo pipefail
170+
171+ test -n "$BUNNY_STORAGE_ENDPOINT"
172+ test -n "$BUNNY_STORAGE_PASSWORD"
173+ endpoint="${BUNNY_STORAGE_ENDPOINT%/}"
174+ case "$endpoint" in
175+ https://*) ;;
176+ *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;;
177+ esac
178+ mkdir -p var/deploy-backup/files
179+ : > var/deploy-backup/missing
73 180
74- - name: Build configuration page181+ while IFS= read -r -d '' file; do
75- if: ${{ github.event_name != 'workflow_dispatch' || inputs.refresh_catalog != true }}182+ relative_path="${file#var/config-site/}"
76- run: npm run build:config-page183+ if [[ "$relative_path" =~ ^catalog/[a-f0-9]{64}/ ]]; then
184+ continue
185+ fi
186+ case "$relative_path" in
187+ *[!A-Za-z0-9._/-]* | /* | *../*)
188+ echo "Refusing unsafe backup path: $relative_path" >&2
189+ exit 1
190+ ;;
191+ esac
192+
193+ target="var/deploy-backup/files/$relative_path"
194+ mkdir -p "$(dirname "$target")"
195+ status="$(curl --silent --show-error \
196+ --output "$target" \
197+ --write-out '%{http_code}' \
198+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
199+ "$endpoint/$relative_path")"
200+ case "$status" in
201+ 200) ;;
202+ 404)
203+ rm -f "$target"
204+ printf '%s\0' "$relative_path" >> var/deploy-backup/missing
205+ ;;
206+ *)
207+ echo "Could not back up $relative_path: HTTP $status" >&2
208+ exit 1
209+ ;;
210+ esac
211+ done < <(find var/config-site -type f -print0)
212+
213+ - name: Retain rollback bundle
214+ uses: actions/upload-artifact@v4
215+ with:
216+ name: bunny-rollback-${{ github.run_id }}
217+ path: var/deploy-backup/
218+ if-no-files-found: error
219+ retention-days: 30
77 220
78 - name: Upload static site to Bunny Storage221 - name: Upload static site to Bunny Storage
222+ id: upload
79 shell: bash223 shell: bash
80 env:224 env:
81 BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }}225 BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }}
@@ -137,9 +281,84 @@ jobs:
137 --header "AccessKey: $BUNNY_API_KEY" \281 --header "AccessKey: $BUNNY_API_KEY" \
138 "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache"282 "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache"
139 283
284+ - name: Verify served static site
285+ env:
286+ CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }}
287+ INCLUDE_CATALOG: ${{ steps.catalog.outputs.include_catalog }}
288+ run: |
289+ for attempt in 1 2 3; do
290+ if [[ "$INCLUDE_CATALOG" = true ]]; then
291+ command=(npm run verify:config-site)
292+ else
293+ command=(npm run verify:config-site -- --page-only)
294+ fi
295+ if "${command[@]}"; then
296+ exit 0
297+ fi
298+ if [[ "$attempt" != 3 ]]; then sleep 10; fi
299+ done
300+ exit 1
301+
302+ - name: Restore previous mutable files after failure
303+ if: ${{ failure() && steps.backup.outcome == 'success' }}
304+ shell: bash
305+ env:
306+ BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }}
307+ BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
308+ BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }}
309+ BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID }}
310+ run: |
311+ set -euo pipefail
312+
313+ endpoint="${BUNNY_STORAGE_ENDPOINT%/}"
314+ case "$endpoint" in
315+ https://*) ;;
316+ *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;;
317+ esac
318+ restore_file() {
319+ local file="$1"
320+ local relative_path="${file#var/deploy-backup/files/}"
321+ local mime_type
322+ mime_type="$(file --brief --mime-type "$file")"
323+ echo "Restoring $relative_path"
324+ curl --fail --silent --show-error \
325+ --request PUT \
326+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
327+ --header "Content-Type: $mime_type" \
328+ --upload-file "$file" \
329+ "$endpoint/$relative_path"
330+ }
331+ export endpoint BUNNY_STORAGE_PASSWORD
332+ export -f restore_file
333+ find var/deploy-backup/files -type f -print0 \
334+ | xargs -0 --no-run-if-empty --max-args=1 --max-procs=16 \
335+ bash -c 'restore_file "$1"' _
336+
337+ while IFS= read -r -d '' relative_path; do
338+ echo "Removing newly introduced mutable file $relative_path"
339+ status="$(curl --silent --show-error \
340+ --request DELETE \
341+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
342+ --output /dev/null \
343+ --write-out '%{http_code}' \
344+ "$endpoint/$relative_path")"
345+ case "$status" in
346+ 200 | 201 | 204 | 404) ;;
347+ *)
348+ echo "Could not remove $relative_path during rollback: HTTP $status" >&2
349+ exit 1
350+ ;;
351+ esac
352+ done < var/deploy-backup/missing
353+
354+ curl --fail --silent --show-error \
355+ --request POST \
356+ --header "AccessKey: $BUNNY_API_KEY" \
357+ "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache"
358+
140 publish:359 publish:
141 name: Publish GitHub release notes360 name: Publish GitHub release notes
142- needs: [test, deploy]361+ needs: [test, package, deploy]
143 if: ${{ startsWith(github.ref_name, 'v') }}362 if: ${{ startsWith(github.ref_name, 'v') }}
144 runs-on: ubuntu-latest363 runs-on: ubuntu-latest
145 permissions:364 permissions:
@@ -152,6 +371,12 @@ jobs:
152 fetch-depth: 0371 fetch-depth: 0
153 persist-credentials: false372 persist-credentials: false
154 373
374+ - name: Download release artifacts
375+ uses: actions/download-artifact@v4
376+ with:
377+ name: lapin-fute-${{ github.ref_name }}
378+ path: release-assets
379+
155 - name: Generate categorized release notes380 - name: Generate categorized release notes
156 shell: bash381 shell: bash
157 run: |382 run: |
@@ -217,4 +442,5 @@ jobs:
217 --repo "$GITHUB_REPOSITORY" \442 --repo "$GITHUB_REPOSITORY" \
218 --title "Lapin Futé $GITHUB_REF_NAME" \443 --title "Lapin Futé $GITHUB_REF_NAME" \
219 --notes-file release-notes.md \444 --notes-file release-notes.md \
220- --verify-tag445+ --verify-tag \
446+ release-assets/*
added .github/workflows/rollback-bunny.yml +169 -0
new file mode 100644
@@ -0,0 +1,169 @@
1+name: Roll back Bunny configuration site
2+
3+on:
4+ workflow_dispatch:
5+ inputs:
6+ deployment_run_id:
7+ description: Successful deployment run whose pre-deployment bundle must be restored
8+ required: true
9+ type: string
10+
11+permissions:
12+ actions: read
13+ contents: read
14+
15+concurrency:
16+ group: deploy-bunny-production
17+ cancel-in-progress: false
18+
19+jobs:
20+ rollback:
21+ name: Restore retained mutable files
22+ runs-on: ubuntu-latest
23+ environment: production
24+ steps:
25+ - name: Validate deployment run
26+ id: source
27+ env:
28+ DEPLOYMENT_RUN_ID: ${{ inputs.deployment_run_id }}
29+ GH_TOKEN: ${{ github.token }}
30+ GH_REPO: ${{ github.repository }}
31+ run: |
32+ set -euo pipefail
33+ [[ "$DEPLOYMENT_RUN_ID" =~ ^[1-9][0-9]*$ ]]
34+ metadata="$(gh api "repos/$GH_REPO/actions/runs/$DEPLOYMENT_RUN_ID" \
35+ --jq '[.status, .path, .event, .head_repository.full_name] | @tsv')"
36+ IFS=$'\t' read -r status workflow_path event head_repository <<< "$metadata"
37+ test "$status" = completed
38+ test "$workflow_path" = ".github/workflows/deploy-bunny.yml"
39+ test "$head_repository" = "$GH_REPO"
40+ [[ "$event" = push || "$event" = workflow_dispatch ]]
41+
42+ - name: Download retained rollback bundle
43+ env:
44+ DEPLOYMENT_RUN_ID: ${{ inputs.deployment_run_id }}
45+ GH_TOKEN: ${{ github.token }}
46+ GH_REPO: ${{ github.repository }}
47+ run: |
48+ gh run download "$DEPLOYMENT_RUN_ID" \
49+ --repo "$GH_REPO" \
50+ --name "bunny-rollback-$DEPLOYMENT_RUN_ID" \
51+ --dir var/deploy-backup
52+ test -d var/deploy-backup/files
53+ test -f var/deploy-backup/missing
54+
55+ - name: Restore previous mutable files
56+ env:
57+ BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }}
58+ BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
59+ run: |
60+ set -euo pipefail
61+ test -n "$BUNNY_STORAGE_ENDPOINT"
62+ test -n "$BUNNY_STORAGE_PASSWORD"
63+ endpoint="${BUNNY_STORAGE_ENDPOINT%/}"
64+ case "$endpoint" in
65+ https://*) ;;
66+ *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;;
67+ esac
68+
69+ restore_file() {
70+ local file="$1"
71+ local relative_path="${file#var/deploy-backup/files/}"
72+ local mime_type
73+ case "$relative_path" in
74+ *[!A-Za-z0-9._/-]* | /* | *../*)
75+ echo "Refusing unsafe rollback path: $relative_path" >&2
76+ exit 1
77+ ;;
78+ esac
79+ mime_type="$(file --brief --mime-type "$file")"
80+ echo "Restoring $relative_path"
81+ curl --fail --silent --show-error \
82+ --retry 3 --retry-all-errors \
83+ --request PUT \
84+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
85+ --header "Content-Type: $mime_type" \
86+ --upload-file "$file" \
87+ "$endpoint/$relative_path"
88+ }
89+ export endpoint BUNNY_STORAGE_PASSWORD
90+ export -f restore_file
91+ find var/deploy-backup/files -type f -print0 \
92+ | xargs -0 --no-run-if-empty --max-args=1 --max-procs=16 \
93+ bash -c 'restore_file "$1"' _
94+
95+ while IFS= read -r -d '' relative_path; do
96+ case "$relative_path" in
97+ *[!A-Za-z0-9._/-]* | /* | *../*)
98+ echo "Refusing unsafe rollback deletion: $relative_path" >&2
99+ exit 1
100+ ;;
101+ esac
102+ status="$(curl --silent --show-error \
103+ --request DELETE \
104+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
105+ --output /dev/null \
106+ --write-out '%{http_code}' \
107+ "$endpoint/$relative_path")"
108+ case "$status" in
109+ 200 | 201 | 204 | 404) ;;
110+ *)
111+ echo "Could not remove $relative_path during rollback: HTTP $status" >&2
112+ exit 1
113+ ;;
114+ esac
115+ done < var/deploy-backup/missing
116+
117+ - name: Purge Bunny CDN cache
118+ env:
119+ BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }}
120+ BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID }}
121+ run: |
122+ set -euo pipefail
123+ test -n "$BUNNY_API_KEY"
124+ test -n "$BUNNY_PULL_ZONE_ID"
125+ curl --fail --silent --show-error \
126+ --retry 3 --retry-all-errors \
127+ --request POST \
128+ --header "AccessKey: $BUNNY_API_KEY" \
129+ "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache"
130+
131+ - name: Verify restored mutable files
132+ env:
133+ CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }}
134+ run: |
135+ set -euo pipefail
136+ test -n "$CONFIG_SITE_ORIGIN"
137+ origin="${CONFIG_SITE_ORIGIN%/}"
138+ case "$origin" in
139+ https://*) ;;
140+ *) echo "CONFIG_SITE_ORIGIN must use HTTPS" >&2; exit 1 ;;
141+ esac
142+
143+ for attempt in 1 2 3; do
144+ failed=0
145+ while IFS= read -r -d '' file; do
146+ relative_path="${file#var/deploy-backup/files/}"
147+ if ! curl --fail --silent --show-error \
148+ --header "Cache-Control: no-cache" \
149+ "$origin/$relative_path" \
150+ | cmp --silent "$file" -; then
151+ echo "Restored bytes differ for $relative_path" >&2
152+ failed=1
153+ fi
154+ done < <(find var/deploy-backup/files -type f -print0)
155+ while IFS= read -r -d '' relative_path; do
156+ status="$(curl --silent --show-error \
157+ --header "Cache-Control: no-cache" \
158+ --output /dev/null \
159+ --write-out '%{http_code}' \
160+ "$origin/$relative_path")"
161+ if [[ "$status" != 404 ]]; then
162+ echo "New mutable file remains after rollback: $relative_path (HTTP $status)" >&2
163+ failed=1
164+ fi
165+ done < var/deploy-backup/missing
166+ if [[ "$failed" = 0 ]]; then exit 0; fi
167+ if [[ "$attempt" != 3 ]]; then sleep 10; fi
168+ done
169+ exit 1
new file mode 100644
@@ -0,0 +1,169 @@
1+name: Roll back Bunny configuration site
2+
3+on:
4+ workflow_dispatch:
5+ inputs:
6+ deployment_run_id:
7+ description: Successful deployment run whose pre-deployment bundle must be restored
8+ required: true
9+ type: string
10+
11+permissions:
12+ actions: read
13+ contents: read
14+
15+concurrency:
16+ group: deploy-bunny-production
17+ cancel-in-progress: false
18+
19+jobs:
20+ rollback:
21+ name: Restore retained mutable files
22+ runs-on: ubuntu-latest
23+ environment: production
24+ steps:
25+ - name: Validate deployment run
26+ id: source
27+ env:
28+ DEPLOYMENT_RUN_ID: ${{ inputs.deployment_run_id }}
29+ GH_TOKEN: ${{ github.token }}
30+ GH_REPO: ${{ github.repository }}
31+ run: |
32+ set -euo pipefail
33+ [[ "$DEPLOYMENT_RUN_ID" =~ ^[1-9][0-9]*$ ]]
34+ metadata="$(gh api "repos/$GH_REPO/actions/runs/$DEPLOYMENT_RUN_ID" \
35+ --jq '[.status, .path, .event, .head_repository.full_name] | @tsv')"
36+ IFS=$'\t' read -r status workflow_path event head_repository <<< "$metadata"
37+ test "$status" = completed
38+ test "$workflow_path" = ".github/workflows/deploy-bunny.yml"
39+ test "$head_repository" = "$GH_REPO"
40+ [[ "$event" = push || "$event" = workflow_dispatch ]]
41+
42+ - name: Download retained rollback bundle
43+ env:
44+ DEPLOYMENT_RUN_ID: ${{ inputs.deployment_run_id }}
45+ GH_TOKEN: ${{ github.token }}
46+ GH_REPO: ${{ github.repository }}
47+ run: |
48+ gh run download "$DEPLOYMENT_RUN_ID" \
49+ --repo "$GH_REPO" \
50+ --name "bunny-rollback-$DEPLOYMENT_RUN_ID" \
51+ --dir var/deploy-backup
52+ test -d var/deploy-backup/files
53+ test -f var/deploy-backup/missing
54+
55+ - name: Restore previous mutable files
56+ env:
57+ BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }}
58+ BUNNY_STORAGE_PASSWORD: ${{ secrets.BUNNY_STORAGE_PASSWORD }}
59+ run: |
60+ set -euo pipefail
61+ test -n "$BUNNY_STORAGE_ENDPOINT"
62+ test -n "$BUNNY_STORAGE_PASSWORD"
63+ endpoint="${BUNNY_STORAGE_ENDPOINT%/}"
64+ case "$endpoint" in
65+ https://*) ;;
66+ *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;;
67+ esac
68+
69+ restore_file() {
70+ local file="$1"
71+ local relative_path="${file#var/deploy-backup/files/}"
72+ local mime_type
73+ case "$relative_path" in
74+ *[!A-Za-z0-9._/-]* | /* | *../*)
75+ echo "Refusing unsafe rollback path: $relative_path" >&2
76+ exit 1
77+ ;;
78+ esac
79+ mime_type="$(file --brief --mime-type "$file")"
80+ echo "Restoring $relative_path"
81+ curl --fail --silent --show-error \
82+ --retry 3 --retry-all-errors \
83+ --request PUT \
84+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
85+ --header "Content-Type: $mime_type" \
86+ --upload-file "$file" \
87+ "$endpoint/$relative_path"
88+ }
89+ export endpoint BUNNY_STORAGE_PASSWORD
90+ export -f restore_file
91+ find var/deploy-backup/files -type f -print0 \
92+ | xargs -0 --no-run-if-empty --max-args=1 --max-procs=16 \
93+ bash -c 'restore_file "$1"' _
94+
95+ while IFS= read -r -d '' relative_path; do
96+ case "$relative_path" in
97+ *[!A-Za-z0-9._/-]* | /* | *../*)
98+ echo "Refusing unsafe rollback deletion: $relative_path" >&2
99+ exit 1
100+ ;;
101+ esac
102+ status="$(curl --silent --show-error \
103+ --request DELETE \
104+ --header "AccessKey: $BUNNY_STORAGE_PASSWORD" \
105+ --output /dev/null \
106+ --write-out '%{http_code}' \
107+ "$endpoint/$relative_path")"
108+ case "$status" in
109+ 200 | 201 | 204 | 404) ;;
110+ *)
111+ echo "Could not remove $relative_path during rollback: HTTP $status" >&2
112+ exit 1
113+ ;;
114+ esac
115+ done < var/deploy-backup/missing
116+
117+ - name: Purge Bunny CDN cache
118+ env:
119+ BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }}
120+ BUNNY_PULL_ZONE_ID: ${{ vars.BUNNY_PULL_ZONE_ID }}
121+ run: |
122+ set -euo pipefail
123+ test -n "$BUNNY_API_KEY"
124+ test -n "$BUNNY_PULL_ZONE_ID"
125+ curl --fail --silent --show-error \
126+ --retry 3 --retry-all-errors \
127+ --request POST \
128+ --header "AccessKey: $BUNNY_API_KEY" \
129+ "https://api.bunny.net/pullzone/$BUNNY_PULL_ZONE_ID/purgeCache"
130+
131+ - name: Verify restored mutable files
132+ env:
133+ CONFIG_SITE_ORIGIN: ${{ vars.CONFIG_SITE_ORIGIN }}
134+ run: |
135+ set -euo pipefail
136+ test -n "$CONFIG_SITE_ORIGIN"
137+ origin="${CONFIG_SITE_ORIGIN%/}"
138+ case "$origin" in
139+ https://*) ;;
140+ *) echo "CONFIG_SITE_ORIGIN must use HTTPS" >&2; exit 1 ;;
141+ esac
142+
143+ for attempt in 1 2 3; do
144+ failed=0
145+ while IFS= read -r -d '' file; do
146+ relative_path="${file#var/deploy-backup/files/}"
147+ if ! curl --fail --silent --show-error \
148+ --header "Cache-Control: no-cache" \
149+ "$origin/$relative_path" \
150+ | cmp --silent "$file" -; then
151+ echo "Restored bytes differ for $relative_path" >&2
152+ failed=1
153+ fi
154+ done < <(find var/deploy-backup/files -type f -print0)
155+ while IFS= read -r -d '' relative_path; do
156+ status="$(curl --silent --show-error \
157+ --header "Cache-Control: no-cache" \
158+ --output /dev/null \
159+ --write-out '%{http_code}' \
160+ "$origin/$relative_path")"
161+ if [[ "$status" != 404 ]]; then
162+ echo "New mutable file remains after rollback: $relative_path (HTTP $status)" >&2
163+ failed=1
164+ fi
165+ done < var/deploy-backup/missing
166+ if [[ "$failed" = 0 ]]; then exit 0; fi
167+ if [[ "$attempt" != 3 ]]; then sleep 10; fi
168+ done
169+ exit 1
modified .gitignore +1 -0
@@ -4,6 +4,7 @@ node_modules/
44 !.env.example
55 var/
66 packages/watch/build/
7+packages/watch/.lock-waf_*
78 packages/watch/src/pkjs/
89 packages/watch/src/generated/
910 docs/
@@ -4,6 +4,7 @@ node_modules/
4 !.env.example4 !.env.example
5 var/5 var/
6 packages/watch/build/6 packages/watch/build/
7+packages/watch/.lock-waf_*
7 packages/watch/src/pkjs/8 packages/watch/src/pkjs/
8 packages/watch/src/generated/9 packages/watch/src/generated/
9 docs/10 docs/
modified README.md +19 -21
@@ -11,7 +11,8 @@
1111 </p>
1212
1313 <p align="center">
14- <a href="#features">Features</a>
14+ <a href="https://apps.repebble.com/6d6aa01b7ecb4cfea469a183">Get Lapin Futé</a>
15+ · <a href="#features">Features</a>
1516 · <a href="#compatibility">Compatibility</a>
1617 · <a href="#getting-started">Getting started</a>
1718 · <a href="#watch-controls">Watch controls</a>
@@ -23,18 +24,13 @@
2324 <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro">
2425 <img src="https://img.shields.io/badge/Modes-M%C3%A9tro%C2%B7RER%C2%B7Transilien%C2%B7Tram%C2%B7Bus-00A5E0" alt="Métro, RER, Transilien, Tram and Bus">
2526 <img src="https://img.shields.io/badge/Language-EN%20%7C%20FR-00A5E0" alt="English and French">
26- <img src="https://img.shields.io/badge/status-pre--release-F59E0B" alt="Pre-release status">
27+ <a href="https://apps.repebble.com/6d6aa01b7ecb4cfea469a183"><img src="https://img.shields.io/badge/RePebble-v1.0.0-FF4938" alt="Lapin Futé 1.0.0 on RePebble"></a>
2728 </p>
2829
29-> [!IMPORTANT]
30-> Lapin Futé is experimental and not published yet. Trying it currently requires building the watchapp and hosting its settings page. Live departures require a personal [PRIM](https://prim.iledefrance-mobilites.fr/fr/mes-jetons-authentification) access token. Emulator checks do not replace physical-watch validation, which remains pending. The project is not affiliated with RATP or Île-de-France Mobilités.
31-
3230 ## About
3331
3432 Lapin Futé puts Île-de-France public-transport departures on a Pebble watch. The watch shows a list of favorites with the next departures, a departure board per favorite, and the current service messages for its line. A companion on your phone fetches the data from PRIM, Île-de-France Mobilités' open-data platform, and prepares everything the watch displays; the watch itself never goes online.
3533
36-The name nods to Bison Futé, the French road-traffic information service, and to Serge, the rabbit from RATP's safety campaigns. On the icon, the rabbit's outline traces an itinerary that ends at two round stops.
37-
3834 ## Features
3935
4036 - **Six favorites, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors.
@@ -49,30 +45,32 @@ The name nods to Bison Futé, the French road-traffic information service, and t
4945
5046 | Requirement | Details |
5147 |---|---|
52-| Watches | Pebble Time 2 (emery profile, primary target); the gabbro round profile is experimental |
48+| Watches | Pebble Time 2 (emery profile, primary target); the Pebble Round 2 profile is experimental |
5349 | Firmware | 4.32 or newer |
5450 | Phone | Pebble mobile app running the companion; internet access needed for fresh data |
5551 | Building from source | Node.js 24.18.0, Bun, Pebble SDK 4.33.1, pebble-tool 5.0.40 |
5652
5753 ## Getting started
5854
59-There is no published package yet, so first use means one build and one static host:
55+1. Install [Lapin Futé from RePebble](https://apps.repebble.com/6d6aa01b7ecb4cfea469a183).
56+2. In the Pebble mobile app, open Lapin Futé's settings. Under PRIM access, paste a personal access token generated on the [PRIM portal](https://prim.iledefrance-mobilites.fr/fr/mes-jetons-authentification).
57+3. Add up to six favorites by searching the bundled station catalog; searching and adding work without a token. Save, and your settings sync to the watch.
58+4. Open Lapin Futé on the watch. Departures refresh whenever the app opens, you change favorite, or you hold Select; the phone needs to be connected for fresh data.
6059
61-1. Build the watchapp from source (see [Build from source](#build-from-source)); the built package is written under `packages/watch/build/`.
62-2. Generate the station catalog and build the settings page using the commands below. `npm run build:config-site` assembles the site in `var/config-site`. Publish that folder on a static HTTPS host, then build the watchapp with `LAPIN_FUTE_CONFIG_URL=https://your-host/` so the phone opens your settings page.
63-3. Install the built package on your watch with the Pebble tooling, or try it first in the emery or gabbro emulator.
64-4. In the Pebble mobile app, open Lapin Futé's settings. Under PRIM access, paste a personal access token generated on the [PRIM portal](https://prim.iledefrance-mobilites.fr/fr/mes-jetons-authentification).
65-5. Add up to six favorites by searching the bundled station catalog; searching and adding work without a token. Save, and your settings sync to the watch.
66-6. Open Lapin Futé on the watch. Departures refresh whenever the app opens, you change favorite, or you hold Select; the phone needs to be connected for fresh data.
60+Developers can also build and install the app locally using the instructions in [Build from source](#build-from-source).
6761
6862 ## Watch controls
6963
70-| Screen | Buttons |
71-|---|---|
72-| Favorites list | Up/Down move through favorites and the Refresh all row; Select opens the highlighted favorite, or refreshes everything from the Refresh all row |
73-| Departures | Up/Down switch favorite; Select opens the traffic view; holding Select forces a refresh |
74-| Traffic | Up/Down page through messages |
75-| Anywhere | Back steps back to the previous screen |
64+| Screen | Control | Action |
65+|---|---|---|
66+| Favorites | **Up / Down** | Move through favorites and the **Refresh all** row |
67+| Favorites | **Select** | Open the highlighted favorite |
68+| Favorites · Refresh all | **Select** | Refresh every favorite |
69+| Departures | **Up / Down** | Switch favorite |
70+| Departures | **Select** | Open the traffic view |
71+| Departures | **Hold Select** | Force a refresh |
72+| Traffic | **Up / Down** | Page through messages |
73+| Any screen | **Back** | Return to the previous screen |
7674
7775 Countdowns and the clock keep updating on the watch every minute; only the actions above trigger network requests.
7876
@@ -11,7 +11,8 @@
11 </p>11 </p>
12 12
13 <p align="center">13 <p align="center">
14- <a href="#features">Features</a>14+ <a href="https://apps.repebble.com/6d6aa01b7ecb4cfea469a183">Get Lapin Futé</a>
15+ · <a href="#features">Features</a>
15 · <a href="#compatibility">Compatibility</a>16 · <a href="#compatibility">Compatibility</a>
16 · <a href="#getting-started">Getting started</a>17 · <a href="#getting-started">Getting started</a>
17 · <a href="#watch-controls">Watch controls</a>18 · <a href="#watch-controls">Watch controls</a>
@@ -23,18 +24,13 @@
23 <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro">24 <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro">
24 <img src="https://img.shields.io/badge/Modes-M%C3%A9tro%C2%B7RER%C2%B7Transilien%C2%B7Tram%C2%B7Bus-00A5E0" alt="Métro, RER, Transilien, Tram and Bus">25 <img src="https://img.shields.io/badge/Modes-M%C3%A9tro%C2%B7RER%C2%B7Transilien%C2%B7Tram%C2%B7Bus-00A5E0" alt="Métro, RER, Transilien, Tram and Bus">
25 <img src="https://img.shields.io/badge/Language-EN%20%7C%20FR-00A5E0" alt="English and French">26 <img src="https://img.shields.io/badge/Language-EN%20%7C%20FR-00A5E0" alt="English and French">
26- <img src="https://img.shields.io/badge/status-pre--release-F59E0B" alt="Pre-release status">27+ <a href="https://apps.repebble.com/6d6aa01b7ecb4cfea469a183"><img src="https://img.shields.io/badge/RePebble-v1.0.0-FF4938" alt="Lapin Futé 1.0.0 on RePebble"></a>
27 </p>28 </p>
28 29
29-> [!IMPORTANT]
30-> Lapin Futé is experimental and not published yet. Trying it currently requires building the watchapp and hosting its settings page. Live departures require a personal [PRIM](https://prim.iledefrance-mobilites.fr/fr/mes-jetons-authentification) access token. Emulator checks do not replace physical-watch validation, which remains pending. The project is not affiliated with RATP or Île-de-France Mobilités.
31-
32 ## About30 ## About
33 31
34 Lapin Futé puts Île-de-France public-transport departures on a Pebble watch. The watch shows a list of favorites with the next departures, a departure board per favorite, and the current service messages for its line. A companion on your phone fetches the data from PRIM, Île-de-France Mobilités' open-data platform, and prepares everything the watch displays; the watch itself never goes online.32 Lapin Futé puts Île-de-France public-transport departures on a Pebble watch. The watch shows a list of favorites with the next departures, a departure board per favorite, and the current service messages for its line. A companion on your phone fetches the data from PRIM, Île-de-France Mobilités' open-data platform, and prepares everything the watch displays; the watch itself never goes online.
35 33
36-The name nods to Bison Futé, the French road-traffic information service, and to Serge, the rabbit from RATP's safety campaigns. On the icon, the rabbit's outline traces an itinerary that ends at two round stops.
37-
38 ## Features34 ## Features
39 35
40 - **Six favorites, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors.36 - **Six favorites, five transport modes.** Choose any stop, line, and direction across Métro, RER, Transilien, Tram, and Bus. Favorites are renamed and reordered on the phone page, which shows official line badges and colors.
@@ -49,30 +45,32 @@ The name nods to Bison Futé, the French road-traffic information service, and t
49 45
50 | Requirement | Details |46 | Requirement | Details |
51 |---|---|47 |---|---|
52-| Watches | Pebble Time 2 (emery profile, primary target); the gabbro round profile is experimental |48+| Watches | Pebble Time 2 (emery profile, primary target); the Pebble Round 2 profile is experimental |
53 | Firmware | 4.32 or newer |49 | Firmware | 4.32 or newer |
54 | Phone | Pebble mobile app running the companion; internet access needed for fresh data |50 | Phone | Pebble mobile app running the companion; internet access needed for fresh data |
55 | Building from source | Node.js 24.18.0, Bun, Pebble SDK 4.33.1, pebble-tool 5.0.40 |51 | Building from source | Node.js 24.18.0, Bun, Pebble SDK 4.33.1, pebble-tool 5.0.40 |
56 52
57 ## Getting started53 ## Getting started
58 54
59-There is no published package yet, so first use means one build and one static host:55+1. Install [Lapin Futé from RePebble](https://apps.repebble.com/6d6aa01b7ecb4cfea469a183).
56+2. In the Pebble mobile app, open Lapin Futé's settings. Under PRIM access, paste a personal access token generated on the [PRIM portal](https://prim.iledefrance-mobilites.fr/fr/mes-jetons-authentification).
57+3. Add up to six favorites by searching the bundled station catalog; searching and adding work without a token. Save, and your settings sync to the watch.
58+4. Open Lapin Futé on the watch. Departures refresh whenever the app opens, you change favorite, or you hold Select; the phone needs to be connected for fresh data.
60 59
61-1. Build the watchapp from source (see [Build from source](#build-from-source)); the built package is written under `packages/watch/build/`.60+Developers can also build and install the app locally using the instructions in [Build from source](#build-from-source).
62-2. Generate the station catalog and build the settings page using the commands below. `npm run build:config-site` assembles the site in `var/config-site`. Publish that folder on a static HTTPS host, then build the watchapp with `LAPIN_FUTE_CONFIG_URL=https://your-host/` so the phone opens your settings page.
63-3. Install the built package on your watch with the Pebble tooling, or try it first in the emery or gabbro emulator.
64-4. In the Pebble mobile app, open Lapin Futé's settings. Under PRIM access, paste a personal access token generated on the [PRIM portal](https://prim.iledefrance-mobilites.fr/fr/mes-jetons-authentification).
65-5. Add up to six favorites by searching the bundled station catalog; searching and adding work without a token. Save, and your settings sync to the watch.
66-6. Open Lapin Futé on the watch. Departures refresh whenever the app opens, you change favorite, or you hold Select; the phone needs to be connected for fresh data.
67 61
68 ## Watch controls62 ## Watch controls
69 63
70-| Screen | Buttons |64+| Screen | Control | Action |
71-|---|---|65+|---|---|---|
72-| Favorites list | Up/Down move through favorites and the Refresh all row; Select opens the highlighted favorite, or refreshes everything from the Refresh all row |66+| Favorites | **Up / Down** | Move through favorites and the **Refresh all** row |
73-| Departures | Up/Down switch favorite; Select opens the traffic view; holding Select forces a refresh |67+| Favorites | **Select** | Open the highlighted favorite |
74-| Traffic | Up/Down page through messages |68+| Favorites · Refresh all | **Select** | Refresh every favorite |
75-| Anywhere | Back steps back to the previous screen |69+| Departures | **Up / Down** | Switch favorite |
70+| Departures | **Select** | Open the traffic view |
71+| Departures | **Hold Select** | Force a refresh |
72+| Traffic | **Up / Down** | Page through messages |
73+| Any screen | **Back** | Return to the previous screen |
76 74
77 Countdowns and the clock keep updating on the watch every minute; only the actions above trigger network requests.75 Countdowns and the clock keep updating on the watch every minute; only the actions above trigger network requests.
78 76
modified package-lock.json +6 -6
@@ -1,12 +1,12 @@
11 {
22 "name": "lapin-fute",
3- "version": "0.1.0",
3+ "version": "1.0.0",
44 "lockfileVersion": 3,
55 "requires": true,
66 "packages": {
77 "": {
88 "name": "lapin-fute",
9- "version": "0.1.0",
9+ "version": "1.0.0",
1010 "workspaces": [
1111 "packages/*"
1212 ],
@@ -56,7 +56,7 @@
5656 },
5757 "packages/catalog": {
5858 "name": "@lapin-fute/catalog",
59- "version": "0.1.0",
59+ "version": "1.0.0",
6060 "dependencies": {
6161 "csv-parse": "7.0.2",
6262 "yauzl": "3.4.0"
@@ -64,15 +64,15 @@
6464 },
6565 "packages/companion": {
6666 "name": "@lapin-fute/companion",
67- "version": "0.1.0"
67+ "version": "1.0.0"
6868 },
6969 "packages/config-page": {
7070 "name": "@lapin-fute/config-page",
71- "version": "0.1.0"
71+ "version": "1.0.0"
7272 },
7373 "packages/watch": {
7474 "name": "lapin-fute-watch",
75- "version": "0.1.0"
75+ "version": "1.0.0"
7676 }
7777 }
7878 }
@@ -1,12 +1,12 @@
1 {1 {
2 "name": "lapin-fute",2 "name": "lapin-fute",
3- "version": "0.1.0",3+ "version": "1.0.0",
4 "lockfileVersion": 3,4 "lockfileVersion": 3,
5 "requires": true,5 "requires": true,
6 "packages": {6 "packages": {
7 "": {7 "": {
8 "name": "lapin-fute",8 "name": "lapin-fute",
9- "version": "0.1.0",9+ "version": "1.0.0",
10 "workspaces": [10 "workspaces": [
11 "packages/*"11 "packages/*"
12 ],12 ],
@@ -56,7 +56,7 @@
56 },56 },
57 "packages/catalog": {57 "packages/catalog": {
58 "name": "@lapin-fute/catalog",58 "name": "@lapin-fute/catalog",
59- "version": "0.1.0",59+ "version": "1.0.0",
60 "dependencies": {60 "dependencies": {
61 "csv-parse": "7.0.2",61 "csv-parse": "7.0.2",
62 "yauzl": "3.4.0"62 "yauzl": "3.4.0"
@@ -64,15 +64,15 @@
64 },64 },
65 "packages/companion": {65 "packages/companion": {
66 "name": "@lapin-fute/companion",66 "name": "@lapin-fute/companion",
67- "version": "0.1.0"67+ "version": "1.0.0"
68 },68 },
69 "packages/config-page": {69 "packages/config-page": {
70 "name": "@lapin-fute/config-page",70 "name": "@lapin-fute/config-page",
71- "version": "0.1.0"71+ "version": "1.0.0"
72 },72 },
73 "packages/watch": {73 "packages/watch": {
74 "name": "lapin-fute-watch",74 "name": "lapin-fute-watch",
75- "version": "0.1.0"75+ "version": "1.0.0"
76 }76 }
77 }77 }
78 }78 }
modified package.json +3 -1
@@ -1,6 +1,6 @@
11 {
22 "name": "lapin-fute",
3- "version": "0.1.0",
3+ "version": "1.0.0",
44 "private": true,
55 "type": "module",
66 "workspaces": [
@@ -19,6 +19,8 @@
1919 "measure:radio": "node scripts/measure-radio.mjs",
2020 "measure:catalog": "node scripts/measure-catalog.mjs",
2121 "measure:config-page": "node scripts/measure-config-page.mjs",
22+ "plan:catalog-upload": "node scripts/plan-catalog-upload.mjs",
23+ "verify:config-site": "node scripts/verify-config-site.mjs",
2224 "test": "node scripts/test.mjs",
2325 "test:contracts": "node --test packages/contracts/test/contracts.test.ts",
2426 "test:watch": "node scripts/generate-display-copy.mjs && node --test packages/watch/test/*.test.js",
@@ -1,6 +1,6 @@
1 {1 {
2 "name": "lapin-fute",2 "name": "lapin-fute",
3- "version": "0.1.0",3+ "version": "1.0.0",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "workspaces": [6 "workspaces": [
@@ -19,6 +19,8 @@
19 "measure:radio": "node scripts/measure-radio.mjs",19 "measure:radio": "node scripts/measure-radio.mjs",
20 "measure:catalog": "node scripts/measure-catalog.mjs",20 "measure:catalog": "node scripts/measure-catalog.mjs",
21 "measure:config-page": "node scripts/measure-config-page.mjs",21 "measure:config-page": "node scripts/measure-config-page.mjs",
22+ "plan:catalog-upload": "node scripts/plan-catalog-upload.mjs",
23+ "verify:config-site": "node scripts/verify-config-site.mjs",
22 "test": "node scripts/test.mjs",24 "test": "node scripts/test.mjs",
23 "test:contracts": "node --test packages/contracts/test/contracts.test.ts",25 "test:contracts": "node --test packages/contracts/test/contracts.test.ts",
24 "test:watch": "node scripts/generate-display-copy.mjs && node --test packages/watch/test/*.test.js",26 "test:watch": "node scripts/generate-display-copy.mjs && node --test packages/watch/test/*.test.js",
modified packages/catalog/package.json +1 -1
@@ -1,6 +1,6 @@
11 {
22 "name": "@lapin-fute/catalog",
3- "version": "0.1.0",
3+ "version": "1.0.0",
44 "private": true,
55 "type": "module",
66 "dependencies": {
@@ -1,6 +1,6 @@
1 {1 {
2 "name": "@lapin-fute/catalog",2 "name": "@lapin-fute/catalog",
3- "version": "0.1.0",3+ "version": "1.0.0",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "dependencies": {6 "dependencies": {
modified packages/companion/package.json +1 -1
@@ -1,6 +1,6 @@
11 {
22 "name": "@lapin-fute/companion",
3- "version": "0.1.0",
3+ "version": "1.0.0",
44 "private": true,
55 "type": "commonjs",
66 "main": "src/index.js",
@@ -1,6 +1,6 @@
1 {1 {
2 "name": "@lapin-fute/companion",2 "name": "@lapin-fute/companion",
3- "version": "0.1.0",3+ "version": "1.0.0",
4 "private": true,4 "private": true,
5 "type": "commonjs",5 "type": "commonjs",
6 "main": "src/index.js",6 "main": "src/index.js",
added packages/config-page/assets/lapin-fute-store-144.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/lapin-fute-store-144.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/lapin-fute-store-144.png differBinary files /dev/null and b/packages/config-page/assets/lapin-fute-store-144.png differ
added packages/config-page/assets/lapin-fute-store-80.png +0 -0
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/lapin-fute-store-80.png differ
new file mode 100644
Binary files /dev/null and b/packages/config-page/assets/lapin-fute-store-80.png differBinary files /dev/null and b/packages/config-page/assets/lapin-fute-store-80.png differ
modified packages/config-page/index.html +2 -2
@@ -146,7 +146,7 @@
146146 </div>
147147
148148 <div id="about-en" lang="en">
149- <p class="about-version">Version 0.1.0</p>
149+ <p class="about-version">Version 1.0.0</p>
150150 <h3>Publisher and contact</h3>
151151 <p><strong>Author and publisher:</strong> Anthodev, a private individual acting on a non-professional basis.</p>
152152 <p><strong>Publication director:</strong> Anthodev.</p>
@@ -168,7 +168,7 @@
168168 </div>
169169
170170 <div id="about-fr" lang="fr" hidden>
171- <p class="about-version">Version 0.1.0</p>
171+ <p class="about-version">Version 1.0.0</p>
172172 <h3>Édition et contact</h3>
173173 <p><strong>Auteur et éditeur :</strong> Anthodev, personne physique agissant à titre privé et non professionnel.</p>
174174 <p><strong>Directeur de la publication :</strong> Anthodev.</p>
@@ -146,7 +146,7 @@
146 </div>146 </div>
147 147
148 <div id="about-en" lang="en">148 <div id="about-en" lang="en">
149- <p class="about-version">Version 0.1.0</p>149+ <p class="about-version">Version 1.0.0</p>
150 <h3>Publisher and contact</h3>150 <h3>Publisher and contact</h3>
151 <p><strong>Author and publisher:</strong> Anthodev, a private individual acting on a non-professional basis.</p>151 <p><strong>Author and publisher:</strong> Anthodev, a private individual acting on a non-professional basis.</p>
152 <p><strong>Publication director:</strong> Anthodev.</p>152 <p><strong>Publication director:</strong> Anthodev.</p>
@@ -168,7 +168,7 @@
168 </div>168 </div>
169 169
170 <div id="about-fr" lang="fr" hidden>170 <div id="about-fr" lang="fr" hidden>
171- <p class="about-version">Version 0.1.0</p>171+ <p class="about-version">Version 1.0.0</p>
172 <h3>Édition et contact</h3>172 <h3>Édition et contact</h3>
173 <p><strong>Auteur et éditeur :</strong> Anthodev, personne physique agissant à titre privé et non professionnel.</p>173 <p><strong>Auteur et éditeur :</strong> Anthodev, personne physique agissant à titre privé et non professionnel.</p>
174 <p><strong>Directeur de la publication :</strong> Anthodev.</p>174 <p><strong>Directeur de la publication :</strong> Anthodev.</p>
modified packages/config-page/package.json +1 -1
@@ -1,6 +1,6 @@
11 {
22 "name": "@lapin-fute/config-page",
3- "version": "0.1.0",
3+ "version": "1.0.0",
44 "private": true,
55 "type": "module",
66 "scripts": {
@@ -1,6 +1,6 @@
1 {1 {
2 "name": "@lapin-fute/config-page",2 "name": "@lapin-fute/config-page",
3- "version": "0.1.0",3+ "version": "1.0.0",
4 "private": true,4 "private": true,
5 "type": "module",5 "type": "module",
6 "scripts": {6 "scripts": {
modified packages/config-page/src/catalog-client.js +43 -12
@@ -36,7 +36,7 @@ function exactFields(value, fields) {
3636 && fields.every((field) => Object.hasOwn(value, field));
3737 }
3838
39-function isManifest(value) {
39+export function isCatalogManifest(value) {
4040 return exactFields(value, MANIFEST_FIELDS)
4141 && value.schemaVersion === SCHEMA_VERSION
4242 && typeof value.revision === "string" && REVISION.test(value.revision)
@@ -65,6 +65,39 @@ function isPage(value, revision, page, field, placeId) {
6565 && Array.isArray(value[field]) && (value.nextPage === null || value[field].length > 0);
6666 }
6767
68+function isSearchPlace(value) {
69+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
70+ const { searchText, ...place } = value;
71+ return isPlaceSearchItem(place) && PLACE_ID.test(place.placeId)
72+ && typeof searchText === "string" && searchText !== ""
73+ && normalizeCatalogSearchText(searchText) === searchText;
74+}
75+
76+export function isCatalogSearchPage(value, revision, page) {
77+ return isPage(value, revision, page, "places")
78+ && value.places.every(isSearchPlace);
79+}
80+
81+export function isCatalogServicesPage(value, revision, placeId, page) {
82+ if (!PLACE_ID.test(placeId) || !isPage(value, revision, page, "services", placeId)) return false;
83+ const identifiers = new Set();
84+ return value.services.every((service) => {
85+ if (!isServiceOption(service) || !SERVICE_ID.test(service.serviceId)
86+ || identifiers.has(service.serviceId)) return false;
87+ identifiers.add(service.serviceId);
88+ return true;
89+ });
90+}
91+
92+export function isCatalogServiceDocument(value, revision, serviceId) {
93+ return SERVICE_ID.test(serviceId)
94+ && exactFields(value, ["schemaVersion", "revision", "service"])
95+ && value.schemaVersion === SCHEMA_VERSION
96+ && value.revision === revision
97+ && isServiceOption(value.service)
98+ && value.service.serviceId === serviceId;
99+}
100+
68101 function compareMatches(left, right) {
69102 if (left.rank !== right.rank) return left.rank - right.rank;
70103 for (let index = 0; index < left.order.length; index += 1) {
@@ -133,7 +166,7 @@ export function createCatalogClient({
133166 if (signal?.aborted) throw abortError();
134167 if (manifest !== null) return manifest;
135168 const body = await json("manifest.json", signal, "BACKEND_UNAVAILABLE", true);
136- if (!isManifest(body)) throw new CatalogClientError("BACKEND_UNAVAILABLE");
169+ if (!isCatalogManifest(body)) throw new CatalogClientError("BACKEND_UNAVAILABLE");
137170 if (signal?.aborted) throw abortError();
138171 // Concurrent first operations use the first successfully loaded revision.
139172 manifest ??= body;
@@ -165,13 +198,9 @@ export function createCatalogClient({
165198 const body = await json(`${pinned.revision}/search/${bucket}/${page}.json`, signal,
166199 page === 0 ? null : "BACKEND_UNAVAILABLE");
167200 if (body === null) return [];
168- if (!isPage(body, pinned.revision, page, "places")) throw new CatalogClientError("BACKEND_UNAVAILABLE");
201+ if (!isCatalogSearchPage(body, pinned.revision, page)) throw new CatalogClientError("BACKEND_UNAVAILABLE");
169202 for (const entry of body.places) {
170- if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new CatalogClientError("BACKEND_UNAVAILABLE");
171203 const { searchText, ...place } = entry;
172- if (!isPlaceSearchItem(place) || !PLACE_ID.test(place.placeId)
173- || typeof searchText !== "string" || searchText === ""
174- || normalizeCatalogSearchText(searchText) !== searchText) throw new CatalogClientError("BACKEND_UNAVAILABLE");
175204 const words = searchText.split(" ");
176205 if (!tokens.every((token) => words.some((word) => word.startsWith(token)))) continue;
177206 const stop = normalizeCatalogSearchText(place.stopLabel);
@@ -231,9 +260,11 @@ export function createCatalogClient({
231260 while (true) {
232261 const body = await json(`${pinned.revision}/places/${placeId}/${page}.json`, signal,
233262 page === 0 ? "INVALID_SERVICE" : "BACKEND_UNAVAILABLE");
234- if (!isPage(body, pinned.revision, page, "services", placeId)) throw new CatalogClientError("BACKEND_UNAVAILABLE");
263+ if (!isCatalogServicesPage(body, pinned.revision, placeId, page)) {
264+ throw new CatalogClientError("BACKEND_UNAVAILABLE");
265+ }
235266 for (const service of body.services) {
236- if (!isServiceOption(service) || !SERVICE_ID.test(service.serviceId) || seen.has(service.serviceId)) {
267+ if (seen.has(service.serviceId)) {
237268 throw new CatalogClientError("INVALID_SERVICE");
238269 }
239270 seen.add(service.serviceId);
@@ -250,9 +281,9 @@ export function createCatalogClient({
250281 const pinned = await pinnedManifest(signal);
251282 const body = await json(`${pinned.revision}/services/${serviceId}.json`, signal, null);
252283 if (body === null) return null;
253- if (!exactFields(body, ["schemaVersion", "revision", "service"])
254- || body.schemaVersion !== SCHEMA_VERSION || body.revision !== pinned.revision
255- || !isServiceOption(body.service) || body.service.serviceId !== serviceId) throw new CatalogClientError("INVALID_SERVICE");
284+ if (!isCatalogServiceDocument(body, pinned.revision, serviceId)) {
285+ throw new CatalogClientError("INVALID_SERVICE");
286+ }
256287 return body.service;
257288 }
258289
@@ -36,7 +36,7 @@ function exactFields(value, fields) {
36 && fields.every((field) => Object.hasOwn(value, field));36 && fields.every((field) => Object.hasOwn(value, field));
37 }37 }
38 38
39-function isManifest(value) {39+export function isCatalogManifest(value) {
40 return exactFields(value, MANIFEST_FIELDS)40 return exactFields(value, MANIFEST_FIELDS)
41 && value.schemaVersion === SCHEMA_VERSION41 && value.schemaVersion === SCHEMA_VERSION
42 && typeof value.revision === "string" && REVISION.test(value.revision)42 && typeof value.revision === "string" && REVISION.test(value.revision)
@@ -65,6 +65,39 @@ function isPage(value, revision, page, field, placeId) {
65 && Array.isArray(value[field]) && (value.nextPage === null || value[field].length > 0);65 && Array.isArray(value[field]) && (value.nextPage === null || value[field].length > 0);
66 }66 }
67 67
68+function isSearchPlace(value) {
69+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
70+ const { searchText, ...place } = value;
71+ return isPlaceSearchItem(place) && PLACE_ID.test(place.placeId)
72+ && typeof searchText === "string" && searchText !== ""
73+ && normalizeCatalogSearchText(searchText) === searchText;
74+}
75+
76+export function isCatalogSearchPage(value, revision, page) {
77+ return isPage(value, revision, page, "places")
78+ && value.places.every(isSearchPlace);
79+}
80+
81+export function isCatalogServicesPage(value, revision, placeId, page) {
82+ if (!PLACE_ID.test(placeId) || !isPage(value, revision, page, "services", placeId)) return false;
83+ const identifiers = new Set();
84+ return value.services.every((service) => {
85+ if (!isServiceOption(service) || !SERVICE_ID.test(service.serviceId)
86+ || identifiers.has(service.serviceId)) return false;
87+ identifiers.add(service.serviceId);
88+ return true;
89+ });
90+}
91+
92+export function isCatalogServiceDocument(value, revision, serviceId) {
93+ return SERVICE_ID.test(serviceId)
94+ && exactFields(value, ["schemaVersion", "revision", "service"])
95+ && value.schemaVersion === SCHEMA_VERSION
96+ && value.revision === revision
97+ && isServiceOption(value.service)
98+ && value.service.serviceId === serviceId;
99+}
100+
68 function compareMatches(left, right) {101 function compareMatches(left, right) {
69 if (left.rank !== right.rank) return left.rank - right.rank;102 if (left.rank !== right.rank) return left.rank - right.rank;
70 for (let index = 0; index < left.order.length; index += 1) {103 for (let index = 0; index < left.order.length; index += 1) {
@@ -133,7 +166,7 @@ export function createCatalogClient({
133 if (signal?.aborted) throw abortError();166 if (signal?.aborted) throw abortError();
134 if (manifest !== null) return manifest;167 if (manifest !== null) return manifest;
135 const body = await json("manifest.json", signal, "BACKEND_UNAVAILABLE", true);168 const body = await json("manifest.json", signal, "BACKEND_UNAVAILABLE", true);
136- if (!isManifest(body)) throw new CatalogClientError("BACKEND_UNAVAILABLE");169+ if (!isCatalogManifest(body)) throw new CatalogClientError("BACKEND_UNAVAILABLE");
137 if (signal?.aborted) throw abortError();170 if (signal?.aborted) throw abortError();
138 // Concurrent first operations use the first successfully loaded revision.171 // Concurrent first operations use the first successfully loaded revision.
139 manifest ??= body;172 manifest ??= body;
@@ -165,13 +198,9 @@ export function createCatalogClient({
165 const body = await json(`${pinned.revision}/search/${bucket}/${page}.json`, signal,198 const body = await json(`${pinned.revision}/search/${bucket}/${page}.json`, signal,
166 page === 0 ? null : "BACKEND_UNAVAILABLE");199 page === 0 ? null : "BACKEND_UNAVAILABLE");
167 if (body === null) return [];200 if (body === null) return [];
168- if (!isPage(body, pinned.revision, page, "places")) throw new CatalogClientError("BACKEND_UNAVAILABLE");201+ if (!isCatalogSearchPage(body, pinned.revision, page)) throw new CatalogClientError("BACKEND_UNAVAILABLE");
169 for (const entry of body.places) {202 for (const entry of body.places) {
170- if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new CatalogClientError("BACKEND_UNAVAILABLE");
171 const { searchText, ...place } = entry;203 const { searchText, ...place } = entry;
172- if (!isPlaceSearchItem(place) || !PLACE_ID.test(place.placeId)
173- || typeof searchText !== "string" || searchText === ""
174- || normalizeCatalogSearchText(searchText) !== searchText) throw new CatalogClientError("BACKEND_UNAVAILABLE");
175 const words = searchText.split(" ");204 const words = searchText.split(" ");
176 if (!tokens.every((token) => words.some((word) => word.startsWith(token)))) continue;205 if (!tokens.every((token) => words.some((word) => word.startsWith(token)))) continue;
177 const stop = normalizeCatalogSearchText(place.stopLabel);206 const stop = normalizeCatalogSearchText(place.stopLabel);
@@ -231,9 +260,11 @@ export function createCatalogClient({
231 while (true) {260 while (true) {
232 const body = await json(`${pinned.revision}/places/${placeId}/${page}.json`, signal,261 const body = await json(`${pinned.revision}/places/${placeId}/${page}.json`, signal,
233 page === 0 ? "INVALID_SERVICE" : "BACKEND_UNAVAILABLE");262 page === 0 ? "INVALID_SERVICE" : "BACKEND_UNAVAILABLE");
234- if (!isPage(body, pinned.revision, page, "services", placeId)) throw new CatalogClientError("BACKEND_UNAVAILABLE");263+ if (!isCatalogServicesPage(body, pinned.revision, placeId, page)) {
264+ throw new CatalogClientError("BACKEND_UNAVAILABLE");
265+ }
235 for (const service of body.services) {266 for (const service of body.services) {
236- if (!isServiceOption(service) || !SERVICE_ID.test(service.serviceId) || seen.has(service.serviceId)) {267+ if (seen.has(service.serviceId)) {
237 throw new CatalogClientError("INVALID_SERVICE");268 throw new CatalogClientError("INVALID_SERVICE");
238 }269 }
239 seen.add(service.serviceId);270 seen.add(service.serviceId);
@@ -250,9 +281,9 @@ export function createCatalogClient({
250 const pinned = await pinnedManifest(signal);281 const pinned = await pinnedManifest(signal);
251 const body = await json(`${pinned.revision}/services/${serviceId}.json`, signal, null);282 const body = await json(`${pinned.revision}/services/${serviceId}.json`, signal, null);
252 if (body === null) return null;283 if (body === null) return null;
253- if (!exactFields(body, ["schemaVersion", "revision", "service"])284+ if (!isCatalogServiceDocument(body, pinned.revision, serviceId)) {
254- || body.schemaVersion !== SCHEMA_VERSION || body.revision !== pinned.revision285+ throw new CatalogClientError("INVALID_SERVICE");
255- || !isServiceOption(body.service) || body.service.serviceId !== serviceId) throw new CatalogClientError("INVALID_SERVICE");286+ }
256 return body.service;287 return body.service;
257 }288 }
258 289
modified packages/config-page/test/config-page.test.ts +1 -1
@@ -859,7 +859,7 @@ test("bilingual About copy exposes the required legal, privacy, attribution, and
859859 const html = readFileSync(join(here, "../index.html"), "utf8");
860860 const controller = readFileSync(join(here, "../src/config-page.js"), "utf8");
861861 for (const fact of [
862- "Version 0.1.0",
862+ "Version 1.0.0",
863863 "Publication director:",
864864 "Directeur de la publication :",
865865 "Anthodev",
@@ -859,7 +859,7 @@ test("bilingual About copy exposes the required legal, privacy, attribution, and
859 const html = readFileSync(join(here, "../index.html"), "utf8");859 const html = readFileSync(join(here, "../index.html"), "utf8");
860 const controller = readFileSync(join(here, "../src/config-page.js"), "utf8");860 const controller = readFileSync(join(here, "../src/config-page.js"), "utf8");
861 for (const fact of [861 for (const fact of [
862- "Version 0.1.0",862+ "Version 1.0.0",
863 "Publication director:",863 "Publication director:",
864 "Directeur de la publication :",864 "Directeur de la publication :",
865 "Anthodev",865 "Anthodev",
modified packages/config-page/test/config-site-build.test.ts +211 -5
@@ -4,6 +4,8 @@ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { buildConfigSite } from "../../../scripts/build-config-site.mjs";
7+import { planCatalogUpload } from "../../../scripts/plan-catalog-upload.mjs";
8+import { verifyConfigSite } from "../../../scripts/verify-config-site.mjs";
79
810 function fixture() {
911 const root = mkdtempSync(join(tmpdir(), "lapin-fute-config-site-"));
@@ -19,12 +21,26 @@ function fixture() {
1921 return { root, pageSource, staticPath, outputPath };
2022 }
2123
22-test("page-only configuration build neither requires nor publishes a catalog", () => {
24+test("page-only configuration build neither requires nor publishes a catalog", async () => {
2325 const paths = fixture();
2426 try {
2527 buildConfigSite({ ...paths, includeCatalog: false });
2628 assert.equal(readFileSync(join(paths.outputPath, "index.html"), "utf8"), "<h1>Lapin Futé</h1>");
2729 assert.equal(existsSync(join(paths.outputPath, "catalog")), false);
30+ const result = await verifyConfigSite({
31+ sitePath: paths.outputPath,
32+ origin: "https://config.example.test/",
33+ includeCatalog: false,
34+ fetcher: async (url) => {
35+ const path = join(paths.outputPath, decodeURIComponent(url.pathname.slice(1)));
36+ return existsSync(path)
37+ ? new Response(readFileSync(path), { status: 200 })
38+ : new Response(null, { status: 404 });
39+ },
40+ });
41+ assert.equal(result.revision, null);
42+ assert.equal(result.catalogJsonCount, 0);
43+ assert.equal(result.fileCount, 4);
2844 } finally {
2945 rmSync(paths.root, { recursive: true, force: true });
3046 }
@@ -53,27 +69,217 @@ test("full configuration build still requires and copies a published catalog", (
5369 }
5470 });
5571
72+test("catalog upload planning compares source revisions instead of volatile publication revisions", async () => {
73+ const paths = fixture();
74+ try {
75+ const attribution = [{
76+ dataset: "arrets",
77+ url: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/arrets",
78+ retrievedAt: "2026-09-13T00:00:00.000Z",
79+ license: "Licence Ouverte 2.0",
80+ }];
81+ const local = {
82+ schemaVersion: 1,
83+ revision: "a".repeat(64),
84+ sourceRevision: "idfm-v1-same-content",
85+ createdAt: "2026-09-13T00:00:00.000Z",
86+ attribution,
87+ };
88+ const manifestPath = join(paths.root, "manifest.json");
89+ writeFileSync(manifestPath, JSON.stringify(local));
90+
91+ const sameSource = await planCatalogUpload({
92+ manifestPath,
93+ origin: "https://config.example.test/",
94+ fetcher: async () => new Response(JSON.stringify({
95+ ...local,
96+ revision: "b".repeat(64),
97+ createdAt: "2026-09-12T00:00:00.000Z",
98+ attribution: [{ ...attribution[0], retrievedAt: "2026-09-12T00:00:00.000Z" }],
99+ }), { status: 200 }),
100+ });
101+ assert.equal(sameSource.upload, false);
102+
103+ const changedSource = await planCatalogUpload({
104+ manifestPath,
105+ origin: "https://config.example.test/",
106+ fetcher: async () => new Response(JSON.stringify({
107+ ...local,
108+ revision: "b".repeat(64),
109+ sourceRevision: "idfm-v1-new-content",
110+ }), { status: 200 }),
111+ });
112+ assert.equal(changedSource.upload, true);
113+
114+ const missing = await planCatalogUpload({
115+ manifestPath,
116+ origin: "https://config.example.test/",
117+ fetcher: async () => new Response(null, { status: 404 }),
118+ });
119+ assert.equal(missing.upload, true);
120+ } finally {
121+ rmSync(paths.root, { recursive: true, force: true });
122+ }
123+});
124+
125+test("configuration-site verification checks every local and served file on the current catalog revision", async () => {
126+ const paths = fixture();
127+ try {
128+ const revision = "a".repeat(64);
129+ const placeId = `plc_${"a".repeat(43)}`;
130+ const serviceId = `svc_${"b".repeat(43)}`;
131+ const place = {
132+ placeId,
133+ stopLabel: "Gare du Nord",
134+ localityLabel: "Paris",
135+ mode: "BUS",
136+ lines: [{ lineLabel: "42", lineColor: "#e86a10", lineTextColor: "#ffffff" }],
137+ searchText: "gare du nord paris",
138+ };
139+ const service = {
140+ serviceId,
141+ stopLabel: "Gare du Nord",
142+ lineLabel: "42",
143+ destinationLabel: "Hôpital Européen",
144+ lineMode: "BUS",
145+ lineColor: "#e86a10",
146+ lineTextColor: "#ffffff",
147+ routing: {
148+ monitoringRef: "fixture-monitoring",
149+ lineRef: "fixture-line",
150+ destinationRef: "fixture-destination",
151+ },
152+ };
153+ const manifest = {
154+ schemaVersion: 1,
155+ revision,
156+ sourceRevision: "idfm-v1-fixture",
157+ createdAt: "2026-09-13T00:00:00.000Z",
158+ attribution: [{
159+ dataset: "fixture",
160+ url: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/fixture",
161+ retrievedAt: "2026-09-13T00:00:00.000Z",
162+ license: "Licence fixture",
163+ }],
164+ };
165+ const files = new Map([
166+ ["index.html", "<h1>Lapin Futé</h1>"],
167+ ["catalog/manifest.json", JSON.stringify(manifest)],
168+ [`catalog/${revision}/search/a/0.json`, JSON.stringify({
169+ schemaVersion: 1, revision, page: 0, nextPage: null, places: [place],
170+ })],
171+ [`catalog/${revision}/places/${placeId}/0.json`, JSON.stringify({
172+ schemaVersion: 1, revision, placeId, page: 0, nextPage: null, services: [service],
173+ })],
174+ [`catalog/${revision}/services/${serviceId}.json`, JSON.stringify({
175+ schemaVersion: 1, revision, service,
176+ })],
177+ ]);
178+ for (const [relativePath, body] of files) {
179+ const path = join(paths.outputPath, relativePath);
180+ mkdirSync(join(path, ".."), { recursive: true });
181+ writeFileSync(path, body);
182+ }
183+
184+ const requested = [];
185+ const result = await verifyConfigSite({
186+ sitePath: paths.outputPath,
187+ origin: "https://config.example.test/lapin-fute/",
188+ fetcher: async (url) => {
189+ requested.push(url.href);
190+ const relativePath = decodeURIComponent(url.pathname.slice("/lapin-fute/".length));
191+ const body = files.get(relativePath);
192+ return body === undefined
193+ ? new Response(null, { status: 404 })
194+ : new Response(body, { status: 200 });
195+ },
196+ });
197+
198+ assert.equal(result.revision, revision);
199+ assert.equal(result.fileCount, files.size);
200+ assert.equal(result.catalogJsonCount, 3);
201+ assert.deepEqual(requested.sort(), [...files.keys()]
202+ .map((path) => `https://config.example.test/lapin-fute/${path}`)
203+ .sort());
204+
205+ const servicePath = `catalog/${revision}/services/${serviceId}.json`;
206+ writeFileSync(join(paths.outputPath, servicePath), JSON.stringify({
207+ schemaVersion: 1,
208+ revision,
209+ service: {},
210+ }));
211+ await assert.rejects(
212+ verifyConfigSite({
213+ sitePath: paths.outputPath,
214+ origin: "https://config.example.test/lapin-fute/",
215+ fetcher: async () => new Response(null, { status: 404 }),
216+ }),
217+ /does not satisfy the static catalog contract/u,
218+ );
219+ writeFileSync(join(paths.outputPath, servicePath), files.get(servicePath));
220+
221+ files.set(servicePath, "{}");
222+ await assert.rejects(
223+ verifyConfigSite({
224+ sitePath: paths.outputPath,
225+ origin: "https://config.example.test/lapin-fute/",
226+ fetcher: async (url) => new Response(files.get(
227+ decodeURIComponent(url.pathname.slice("/lapin-fute/".length)),
228+ ), { status: 200 }),
229+ }),
230+ /served bytes differ/u,
231+ );
232+ } finally {
233+ rmSync(paths.root, { recursive: true, force: true });
234+ }
235+});
236+
56237 test("Bunny releases deploy production tags while preview tags stop after quality checks", () => {
57238 const workflow = readFileSync(
58239 new URL("../../../.github/workflows/deploy-bunny.yml", import.meta.url),
59240 "utf8",
60241 );
242+ const rollbackWorkflow = readFileSync(
243+ new URL("../../../.github/workflows/rollback-bunny.yml", import.meta.url),
244+ "utf8",
245+ );
61246 assert.doesNotMatch(workflow, /branches:\s*\[develop\]/u);
62247 assert.match(workflow, /tags:\s*\["v\*", "pre-v\*"\]/u);
63248 assert.match(workflow, /startsWith\(github\.ref_name, 'v'\)/u);
64- assert.match(workflow, /publish:\s+name: Publish GitHub release notes[\s\S]+needs: \[test, deploy\]/u);
249+ assert.match(workflow, /package:\s+name: Build release PBW[\s\S]+needs: test/u);
250+ assert.match(workflow, /oven-sh\/setup-bun@[a-f0-9]{40} # v2\.2\.0/u);
251+ assert.match(workflow, /astral-sh\/setup-uv@[a-f0-9]{40} # v6\.8\.0/u);
252+ assert.match(workflow, /pebble-tool==5\.0\.40[\s\S]+pebble sdk install 4\.33\.1/u);
253+ assert.match(workflow, /LAPIN_FUTE_CONFIG_URL: \$\{\{ vars\.CONFIG_SITE_ORIGIN \}\}[\s\S]+npm run build/u);
254+ assert.match(workflow, /sha256sum "lapin-fute-\$RELEASE_TAG\.pbw" > SHA256SUMS/u);
255+ assert.match(workflow, /publish:\s+name: Publish GitHub release notes[\s\S]+needs: \[test, package, deploy\]/u);
65256 assert.match(workflow, /\["log", "--first-parent", "-z", "--format=%s%x00%h"/u);
66257 for (const title of ["Features", "Changes", "Fixes", "CI"]) {
67258 assert.match(workflow, new RegExp(`title: "${title}"`, "u"));
68259 }
69- assert.match(workflow, /gh release create "\$GITHUB_REF_NAME"[\s\S]+--notes-file release-notes\.md[\s\S]+--verify-tag/u);
260+ assert.match(workflow, /gh release create "\$GITHUB_REF_NAME"[\s\S]+--notes-file release-notes\.md[\s\S]+--verify-tag[\s\S]+release-assets\/\*/u);
70261 assert.doesNotMatch(workflow, /--generate-notes/u);
71262 assert.match(workflow, /github\.event_name == 'workflow_dispatch' && inputs\.refresh_catalog/u);
72263 assert.match(workflow, /refresh_catalog:\s+description:[\s\S]+type: boolean/u);
73- assert.match(workflow, /inputs\.refresh_catalog != true[\s\S]+npm run build:config-page/u);
74- assert.match(workflow, /inputs\.refresh_catalog \}\}[\s\S]+npm run catalog:refresh/u);
264+ assert.match(workflow, /force_catalog_upload:\s+description:[\s\S]+type: boolean/u);
265+ assert.match(workflow, /Refresh production catalog[\s\S]+if: \$\{\{ github\.event_name == 'workflow_dispatch' && inputs\.refresh_catalog \}\}[\s\S]+npm run catalog:refresh/u);
266+ assert.match(workflow, /EVENT_NAME[\s\S]+release tags update the configuration page only/u);
267+ assert.match(workflow, /include_catalog="\$\(node scripts\/plan-catalog-upload\.mjs\)"/u);
268+ assert.match(workflow, /INCLUDE_CATALOG[\s\S]+npm run build:config-site[\s\S]+npm run build:config-page/u);
269+ assert.match(workflow, /npm run verify:config-site -- --page-only/u);
75270 assert.match(
76271 workflow,
77272 /if \[\[ -f var\/config-site\/catalog\/manifest\.json \]\]; then\s+upload_file/u,
78273 );
274+ assert.match(workflow, /Back up mutable production files[\s\S]+id: backup/u);
275+ assert.match(workflow, /name: bunny-rollback-\$\{\{ github\.run_id \}\}[\s\S]+retention-days: 30/u);
276+ assert.match(workflow, /Verify served static site[\s\S]+npm run verify:config-site/u);
277+ assert.match(workflow, /failure\(\) && steps\.backup\.outcome == 'success'/u);
278+ assert.match(workflow, /Restoring \$relative_path[\s\S]+Removing newly introduced mutable file/u);
279+ assert.match(rollbackWorkflow, /deployment_run_id:[\s\S]+type: string/u);
280+ assert.match(rollbackWorkflow, /gh run download "\$DEPLOYMENT_RUN_ID"[\s\S]+bunny-rollback-\$DEPLOYMENT_RUN_ID/u);
281+ assert.match(rollbackWorkflow, /test "\$status" = completed[\s\S]+workflow_path" = "\.github\/workflows\/deploy-bunny\.yml"/u);
282+ assert.match(rollbackWorkflow, /head_repository" = "\$GH_REPO"[\s\S]+event" = workflow_dispatch/u);
283+ assert.match(rollbackWorkflow, /Restore previous mutable files[\s\S]+Purge Bunny CDN cache[\s\S]+Verify restored mutable files/u);
284+ assert.match(rollbackWorkflow, /CONFIG_SITE_ORIGIN must use HTTPS/u);
79285 });
@@ -4,6 +4,8 @@ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync
4 import { tmpdir } from "node:os";4 import { tmpdir } from "node:os";
5 import { join } from "node:path";5 import { join } from "node:path";
6 import { buildConfigSite } from "../../../scripts/build-config-site.mjs";6 import { buildConfigSite } from "../../../scripts/build-config-site.mjs";
7+import { planCatalogUpload } from "../../../scripts/plan-catalog-upload.mjs";
8+import { verifyConfigSite } from "../../../scripts/verify-config-site.mjs";
7 9
8 function fixture() {10 function fixture() {
9 const root = mkdtempSync(join(tmpdir(), "lapin-fute-config-site-"));11 const root = mkdtempSync(join(tmpdir(), "lapin-fute-config-site-"));
@@ -19,12 +21,26 @@ function fixture() {
19 return { root, pageSource, staticPath, outputPath };21 return { root, pageSource, staticPath, outputPath };
20 }22 }
21 23
22-test("page-only configuration build neither requires nor publishes a catalog", () => {24+test("page-only configuration build neither requires nor publishes a catalog", async () => {
23 const paths = fixture();25 const paths = fixture();
24 try {26 try {
25 buildConfigSite({ ...paths, includeCatalog: false });27 buildConfigSite({ ...paths, includeCatalog: false });
26 assert.equal(readFileSync(join(paths.outputPath, "index.html"), "utf8"), "<h1>Lapin Futé</h1>");28 assert.equal(readFileSync(join(paths.outputPath, "index.html"), "utf8"), "<h1>Lapin Futé</h1>");
27 assert.equal(existsSync(join(paths.outputPath, "catalog")), false);29 assert.equal(existsSync(join(paths.outputPath, "catalog")), false);
30+ const result = await verifyConfigSite({
31+ sitePath: paths.outputPath,
32+ origin: "https://config.example.test/",
33+ includeCatalog: false,
34+ fetcher: async (url) => {
35+ const path = join(paths.outputPath, decodeURIComponent(url.pathname.slice(1)));
36+ return existsSync(path)
37+ ? new Response(readFileSync(path), { status: 200 })
38+ : new Response(null, { status: 404 });
39+ },
40+ });
41+ assert.equal(result.revision, null);
42+ assert.equal(result.catalogJsonCount, 0);
43+ assert.equal(result.fileCount, 4);
28 } finally {44 } finally {
29 rmSync(paths.root, { recursive: true, force: true });45 rmSync(paths.root, { recursive: true, force: true });
30 }46 }
@@ -53,27 +69,217 @@ test("full configuration build still requires and copies a published catalog", (
53 }69 }
54 });70 });
55 71
72+test("catalog upload planning compares source revisions instead of volatile publication revisions", async () => {
73+ const paths = fixture();
74+ try {
75+ const attribution = [{
76+ dataset: "arrets",
77+ url: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/arrets",
78+ retrievedAt: "2026-09-13T00:00:00.000Z",
79+ license: "Licence Ouverte 2.0",
80+ }];
81+ const local = {
82+ schemaVersion: 1,
83+ revision: "a".repeat(64),
84+ sourceRevision: "idfm-v1-same-content",
85+ createdAt: "2026-09-13T00:00:00.000Z",
86+ attribution,
87+ };
88+ const manifestPath = join(paths.root, "manifest.json");
89+ writeFileSync(manifestPath, JSON.stringify(local));
90+
91+ const sameSource = await planCatalogUpload({
92+ manifestPath,
93+ origin: "https://config.example.test/",
94+ fetcher: async () => new Response(JSON.stringify({
95+ ...local,
96+ revision: "b".repeat(64),
97+ createdAt: "2026-09-12T00:00:00.000Z",
98+ attribution: [{ ...attribution[0], retrievedAt: "2026-09-12T00:00:00.000Z" }],
99+ }), { status: 200 }),
100+ });
101+ assert.equal(sameSource.upload, false);
102+
103+ const changedSource = await planCatalogUpload({
104+ manifestPath,
105+ origin: "https://config.example.test/",
106+ fetcher: async () => new Response(JSON.stringify({
107+ ...local,
108+ revision: "b".repeat(64),
109+ sourceRevision: "idfm-v1-new-content",
110+ }), { status: 200 }),
111+ });
112+ assert.equal(changedSource.upload, true);
113+
114+ const missing = await planCatalogUpload({
115+ manifestPath,
116+ origin: "https://config.example.test/",
117+ fetcher: async () => new Response(null, { status: 404 }),
118+ });
119+ assert.equal(missing.upload, true);
120+ } finally {
121+ rmSync(paths.root, { recursive: true, force: true });
122+ }
123+});
124+
125+test("configuration-site verification checks every local and served file on the current catalog revision", async () => {
126+ const paths = fixture();
127+ try {
128+ const revision = "a".repeat(64);
129+ const placeId = `plc_${"a".repeat(43)}`;
130+ const serviceId = `svc_${"b".repeat(43)}`;
131+ const place = {
132+ placeId,
133+ stopLabel: "Gare du Nord",
134+ localityLabel: "Paris",
135+ mode: "BUS",
136+ lines: [{ lineLabel: "42", lineColor: "#e86a10", lineTextColor: "#ffffff" }],
137+ searchText: "gare du nord paris",
138+ };
139+ const service = {
140+ serviceId,
141+ stopLabel: "Gare du Nord",
142+ lineLabel: "42",
143+ destinationLabel: "Hôpital Européen",
144+ lineMode: "BUS",
145+ lineColor: "#e86a10",
146+ lineTextColor: "#ffffff",
147+ routing: {
148+ monitoringRef: "fixture-monitoring",
149+ lineRef: "fixture-line",
150+ destinationRef: "fixture-destination",
151+ },
152+ };
153+ const manifest = {
154+ schemaVersion: 1,
155+ revision,
156+ sourceRevision: "idfm-v1-fixture",
157+ createdAt: "2026-09-13T00:00:00.000Z",
158+ attribution: [{
159+ dataset: "fixture",
160+ url: "https://data.iledefrance-mobilites.fr/api/explore/v2.1/catalog/datasets/fixture",
161+ retrievedAt: "2026-09-13T00:00:00.000Z",
162+ license: "Licence fixture",
163+ }],
164+ };
165+ const files = new Map([
166+ ["index.html", "<h1>Lapin Futé</h1>"],
167+ ["catalog/manifest.json", JSON.stringify(manifest)],
168+ [`catalog/${revision}/search/a/0.json`, JSON.stringify({
169+ schemaVersion: 1, revision, page: 0, nextPage: null, places: [place],
170+ })],
171+ [`catalog/${revision}/places/${placeId}/0.json`, JSON.stringify({
172+ schemaVersion: 1, revision, placeId, page: 0, nextPage: null, services: [service],
173+ })],
174+ [`catalog/${revision}/services/${serviceId}.json`, JSON.stringify({
175+ schemaVersion: 1, revision, service,
176+ })],
177+ ]);
178+ for (const [relativePath, body] of files) {
179+ const path = join(paths.outputPath, relativePath);
180+ mkdirSync(join(path, ".."), { recursive: true });
181+ writeFileSync(path, body);
182+ }
183+
184+ const requested = [];
185+ const result = await verifyConfigSite({
186+ sitePath: paths.outputPath,
187+ origin: "https://config.example.test/lapin-fute/",
188+ fetcher: async (url) => {
189+ requested.push(url.href);
190+ const relativePath = decodeURIComponent(url.pathname.slice("/lapin-fute/".length));
191+ const body = files.get(relativePath);
192+ return body === undefined
193+ ? new Response(null, { status: 404 })
194+ : new Response(body, { status: 200 });
195+ },
196+ });
197+
198+ assert.equal(result.revision, revision);
199+ assert.equal(result.fileCount, files.size);
200+ assert.equal(result.catalogJsonCount, 3);
201+ assert.deepEqual(requested.sort(), [...files.keys()]
202+ .map((path) => `https://config.example.test/lapin-fute/${path}`)
203+ .sort());
204+
205+ const servicePath = `catalog/${revision}/services/${serviceId}.json`;
206+ writeFileSync(join(paths.outputPath, servicePath), JSON.stringify({
207+ schemaVersion: 1,
208+ revision,
209+ service: {},
210+ }));
211+ await assert.rejects(
212+ verifyConfigSite({
213+ sitePath: paths.outputPath,
214+ origin: "https://config.example.test/lapin-fute/",
215+ fetcher: async () => new Response(null, { status: 404 }),
216+ }),
217+ /does not satisfy the static catalog contract/u,
218+ );
219+ writeFileSync(join(paths.outputPath, servicePath), files.get(servicePath));
220+
221+ files.set(servicePath, "{}");
222+ await assert.rejects(
223+ verifyConfigSite({
224+ sitePath: paths.outputPath,
225+ origin: "https://config.example.test/lapin-fute/",
226+ fetcher: async (url) => new Response(files.get(
227+ decodeURIComponent(url.pathname.slice("/lapin-fute/".length)),
228+ ), { status: 200 }),
229+ }),
230+ /served bytes differ/u,
231+ );
232+ } finally {
233+ rmSync(paths.root, { recursive: true, force: true });
234+ }
235+});
236+
56 test("Bunny releases deploy production tags while preview tags stop after quality checks", () => {237 test("Bunny releases deploy production tags while preview tags stop after quality checks", () => {
57 const workflow = readFileSync(238 const workflow = readFileSync(
58 new URL("../../../.github/workflows/deploy-bunny.yml", import.meta.url),239 new URL("../../../.github/workflows/deploy-bunny.yml", import.meta.url),
59 "utf8",240 "utf8",
60 );241 );
242+ const rollbackWorkflow = readFileSync(
243+ new URL("../../../.github/workflows/rollback-bunny.yml", import.meta.url),
244+ "utf8",
245+ );
61 assert.doesNotMatch(workflow, /branches:\s*\[develop\]/u);246 assert.doesNotMatch(workflow, /branches:\s*\[develop\]/u);
62 assert.match(workflow, /tags:\s*\["v\*", "pre-v\*"\]/u);247 assert.match(workflow, /tags:\s*\["v\*", "pre-v\*"\]/u);
63 assert.match(workflow, /startsWith\(github\.ref_name, 'v'\)/u);248 assert.match(workflow, /startsWith\(github\.ref_name, 'v'\)/u);
64- assert.match(workflow, /publish:\s+name: Publish GitHub release notes[\s\S]+needs: \[test, deploy\]/u);249+ assert.match(workflow, /package:\s+name: Build release PBW[\s\S]+needs: test/u);
250+ assert.match(workflow, /oven-sh\/setup-bun@[a-f0-9]{40} # v2\.2\.0/u);
251+ assert.match(workflow, /astral-sh\/setup-uv@[a-f0-9]{40} # v6\.8\.0/u);
252+ assert.match(workflow, /pebble-tool==5\.0\.40[\s\S]+pebble sdk install 4\.33\.1/u);
253+ assert.match(workflow, /LAPIN_FUTE_CONFIG_URL: \$\{\{ vars\.CONFIG_SITE_ORIGIN \}\}[\s\S]+npm run build/u);
254+ assert.match(workflow, /sha256sum "lapin-fute-\$RELEASE_TAG\.pbw" > SHA256SUMS/u);
255+ assert.match(workflow, /publish:\s+name: Publish GitHub release notes[\s\S]+needs: \[test, package, deploy\]/u);
65 assert.match(workflow, /\["log", "--first-parent", "-z", "--format=%s%x00%h"/u);256 assert.match(workflow, /\["log", "--first-parent", "-z", "--format=%s%x00%h"/u);
66 for (const title of ["Features", "Changes", "Fixes", "CI"]) {257 for (const title of ["Features", "Changes", "Fixes", "CI"]) {
67 assert.match(workflow, new RegExp(`title: "${title}"`, "u"));258 assert.match(workflow, new RegExp(`title: "${title}"`, "u"));
68 }259 }
69- assert.match(workflow, /gh release create "\$GITHUB_REF_NAME"[\s\S]+--notes-file release-notes\.md[\s\S]+--verify-tag/u);260+ assert.match(workflow, /gh release create "\$GITHUB_REF_NAME"[\s\S]+--notes-file release-notes\.md[\s\S]+--verify-tag[\s\S]+release-assets\/\*/u);
70 assert.doesNotMatch(workflow, /--generate-notes/u);261 assert.doesNotMatch(workflow, /--generate-notes/u);
71 assert.match(workflow, /github\.event_name == 'workflow_dispatch' && inputs\.refresh_catalog/u);262 assert.match(workflow, /github\.event_name == 'workflow_dispatch' && inputs\.refresh_catalog/u);
72 assert.match(workflow, /refresh_catalog:\s+description:[\s\S]+type: boolean/u);263 assert.match(workflow, /refresh_catalog:\s+description:[\s\S]+type: boolean/u);
73- assert.match(workflow, /inputs\.refresh_catalog != true[\s\S]+npm run build:config-page/u);264+ assert.match(workflow, /force_catalog_upload:\s+description:[\s\S]+type: boolean/u);
74- assert.match(workflow, /inputs\.refresh_catalog \}\}[\s\S]+npm run catalog:refresh/u);265+ assert.match(workflow, /Refresh production catalog[\s\S]+if: \$\{\{ github\.event_name == 'workflow_dispatch' && inputs\.refresh_catalog \}\}[\s\S]+npm run catalog:refresh/u);
266+ assert.match(workflow, /EVENT_NAME[\s\S]+release tags update the configuration page only/u);
267+ assert.match(workflow, /include_catalog="\$\(node scripts\/plan-catalog-upload\.mjs\)"/u);
268+ assert.match(workflow, /INCLUDE_CATALOG[\s\S]+npm run build:config-site[\s\S]+npm run build:config-page/u);
269+ assert.match(workflow, /npm run verify:config-site -- --page-only/u);
75 assert.match(270 assert.match(
76 workflow,271 workflow,
77 /if \[\[ -f var\/config-site\/catalog\/manifest\.json \]\]; then\s+upload_file/u,272 /if \[\[ -f var\/config-site\/catalog\/manifest\.json \]\]; then\s+upload_file/u,
78 );273 );
274+ assert.match(workflow, /Back up mutable production files[\s\S]+id: backup/u);
275+ assert.match(workflow, /name: bunny-rollback-\$\{\{ github\.run_id \}\}[\s\S]+retention-days: 30/u);
276+ assert.match(workflow, /Verify served static site[\s\S]+npm run verify:config-site/u);
277+ assert.match(workflow, /failure\(\) && steps\.backup\.outcome == 'success'/u);
278+ assert.match(workflow, /Restoring \$relative_path[\s\S]+Removing newly introduced mutable file/u);
279+ assert.match(rollbackWorkflow, /deployment_run_id:[\s\S]+type: string/u);
280+ assert.match(rollbackWorkflow, /gh run download "\$DEPLOYMENT_RUN_ID"[\s\S]+bunny-rollback-\$DEPLOYMENT_RUN_ID/u);
281+ assert.match(rollbackWorkflow, /test "\$status" = completed[\s\S]+workflow_path" = "\.github\/workflows\/deploy-bunny\.yml"/u);
282+ assert.match(rollbackWorkflow, /head_repository" = "\$GH_REPO"[\s\S]+event" = workflow_dispatch/u);
283+ assert.match(rollbackWorkflow, /Restore previous mutable files[\s\S]+Purge Bunny CDN cache[\s\S]+Verify restored mutable files/u);
284+ assert.match(rollbackWorkflow, /CONFIG_SITE_ORIGIN must use HTTPS/u);
79 });285 });
modified packages/watch/package.json +1 -1
@@ -1,7 +1,7 @@
11 {
22 "name": "lapin-fute-watch",
33 "author": "Anthodev",
4- "version": "0.1.0",
4+ "version": "1.0.0",
55 "keywords": [
66 "pebble-app"
77 ],
@@ -1,7 +1,7 @@
1 {1 {
2 "name": "lapin-fute-watch",2 "name": "lapin-fute-watch",
3 "author": "Anthodev",3 "author": "Anthodev",
4- "version": "0.1.0",4+ "version": "1.0.0",
5 "keywords": [5 "keywords": [
6 "pebble-app"6 "pebble-app"
7 ],7 ],
added scripts/plan-catalog-upload.mjs +111 -0
new file mode 100644
@@ -0,0 +1,111 @@
1+import { readFileSync } from "node:fs";
2+import { dirname, join, resolve } from "node:path";
3+import { fileURLToPath } from "node:url";
4+import { isCatalogManifest } from "../packages/config-page/src/catalog-client.js";
5+
6+const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
7+const DEFAULT_MANIFEST_PATH = join(PROJECT_ROOT, "var/catalog/static/manifest.json");
8+const MANIFEST_LIMIT_BYTES = 64 * 1024;
9+const REQUEST_TIMEOUT_MS = 30_000;
10+
11+function fail(message) {
12+ throw new Error(`plan:catalog-upload failed: ${message}`);
13+}
14+
15+function parseManifest(body, label) {
16+ let manifest;
17+ try {
18+ manifest = JSON.parse(body);
19+ } catch {
20+ fail(`${label} is not valid JSON`);
21+ }
22+ if (!isCatalogManifest(manifest)) fail(`${label} does not satisfy the static catalog contract`);
23+ return manifest;
24+}
25+
26+function configurationOrigin(value) {
27+ let origin;
28+ try {
29+ origin = new URL(value);
30+ } catch {
31+ fail("CONFIG_SITE_ORIGIN must be an absolute HTTPS URL");
32+ }
33+ if (origin.protocol !== "https:"
34+ || origin.username !== ""
35+ || origin.password !== ""
36+ || origin.search !== ""
37+ || origin.hash !== "") {
38+ fail("CONFIG_SITE_ORIGIN must be an absolute HTTPS URL without credentials, query, or fragment");
39+ }
40+ if (!origin.pathname.endsWith("/")) origin.pathname += "/";
41+ return origin;
42+}
43+
44+async function remoteManifest(origin, fetcher) {
45+ const url = new URL("catalog/manifest.json", origin);
46+ const response = await fetcher(url, {
47+ headers: { Accept: "application/json", "Cache-Control": "no-cache" },
48+ redirect: "error",
49+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
50+ });
51+ if (response.status === 404) return null;
52+ if (!response.ok) fail(`${url.href} returned HTTP ${response.status}`);
53+ const declaredLength = Number(response.headers.get("content-length"));
54+ if (Number.isFinite(declaredLength) && declaredLength > MANIFEST_LIMIT_BYTES) {
55+ fail("hosted catalog manifest exceeds the response limit");
56+ }
57+ if (response.body === null) fail("hosted catalog manifest has no body");
58+ const reader = response.body.getReader();
59+ const chunks = [];
60+ let bytes = 0;
61+ let finished = false;
62+ try {
63+ while (true) {
64+ const { done, value } = await reader.read();
65+ if (done) {
66+ finished = true;
67+ break;
68+ }
69+ bytes += value.byteLength;
70+ if (bytes > MANIFEST_LIMIT_BYTES) fail("hosted catalog manifest exceeds the response limit");
71+ chunks.push(Buffer.from(value));
72+ }
73+ } finally {
74+ if (!finished) await reader.cancel();
75+ reader.releaseLock();
76+ }
77+ return parseManifest(Buffer.concat(chunks, bytes).toString("utf8"), "hosted catalog manifest");
78+}
79+
80+export async function planCatalogUpload({
81+ manifestPath = DEFAULT_MANIFEST_PATH,
82+ origin,
83+ fetcher = globalThis.fetch,
84+} = {}) {
85+ const local = parseManifest(readFileSync(manifestPath, "utf8"), "generated catalog manifest");
86+ const remote = await remoteManifest(configurationOrigin(origin), fetcher);
87+ if (remote === null) {
88+ return { upload: true, reason: "hosted catalog manifest is missing", local, remote };
89+ }
90+ if (local.schemaVersion !== remote.schemaVersion) {
91+ return { upload: true, reason: "catalog schema changed", local, remote };
92+ }
93+ if (local.sourceRevision !== remote.sourceRevision) {
94+ return { upload: true, reason: "IDFM source revision changed", local, remote };
95+ }
96+ return { upload: false, reason: "IDFM source revision is already hosted", local, remote };
97+}
98+
99+if (process.argv[1] === fileURLToPath(import.meta.url)) {
100+ try {
101+ const plan = await planCatalogUpload({
102+ manifestPath: process.argv[2] ?? DEFAULT_MANIFEST_PATH,
103+ origin: process.argv[3] ?? process.env.CONFIG_SITE_ORIGIN,
104+ });
105+ console.error(`${plan.reason}: ${plan.local.sourceRevision}`);
106+ console.log(plan.upload ? "true" : "false");
107+ } catch (error) {
108+ console.error(error instanceof Error ? error.message : error);
109+ process.exitCode = 1;
110+ }
111+}
new file mode 100644
@@ -0,0 +1,111 @@
1+import { readFileSync } from "node:fs";
2+import { dirname, join, resolve } from "node:path";
3+import { fileURLToPath } from "node:url";
4+import { isCatalogManifest } from "../packages/config-page/src/catalog-client.js";
5+
6+const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
7+const DEFAULT_MANIFEST_PATH = join(PROJECT_ROOT, "var/catalog/static/manifest.json");
8+const MANIFEST_LIMIT_BYTES = 64 * 1024;
9+const REQUEST_TIMEOUT_MS = 30_000;
10+
11+function fail(message) {
12+ throw new Error(`plan:catalog-upload failed: ${message}`);
13+}
14+
15+function parseManifest(body, label) {
16+ let manifest;
17+ try {
18+ manifest = JSON.parse(body);
19+ } catch {
20+ fail(`${label} is not valid JSON`);
21+ }
22+ if (!isCatalogManifest(manifest)) fail(`${label} does not satisfy the static catalog contract`);
23+ return manifest;
24+}
25+
26+function configurationOrigin(value) {
27+ let origin;
28+ try {
29+ origin = new URL(value);
30+ } catch {
31+ fail("CONFIG_SITE_ORIGIN must be an absolute HTTPS URL");
32+ }
33+ if (origin.protocol !== "https:"
34+ || origin.username !== ""
35+ || origin.password !== ""
36+ || origin.search !== ""
37+ || origin.hash !== "") {
38+ fail("CONFIG_SITE_ORIGIN must be an absolute HTTPS URL without credentials, query, or fragment");
39+ }
40+ if (!origin.pathname.endsWith("/")) origin.pathname += "/";
41+ return origin;
42+}
43+
44+async function remoteManifest(origin, fetcher) {
45+ const url = new URL("catalog/manifest.json", origin);
46+ const response = await fetcher(url, {
47+ headers: { Accept: "application/json", "Cache-Control": "no-cache" },
48+ redirect: "error",
49+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
50+ });
51+ if (response.status === 404) return null;
52+ if (!response.ok) fail(`${url.href} returned HTTP ${response.status}`);
53+ const declaredLength = Number(response.headers.get("content-length"));
54+ if (Number.isFinite(declaredLength) && declaredLength > MANIFEST_LIMIT_BYTES) {
55+ fail("hosted catalog manifest exceeds the response limit");
56+ }
57+ if (response.body === null) fail("hosted catalog manifest has no body");
58+ const reader = response.body.getReader();
59+ const chunks = [];
60+ let bytes = 0;
61+ let finished = false;
62+ try {
63+ while (true) {
64+ const { done, value } = await reader.read();
65+ if (done) {
66+ finished = true;
67+ break;
68+ }
69+ bytes += value.byteLength;
70+ if (bytes > MANIFEST_LIMIT_BYTES) fail("hosted catalog manifest exceeds the response limit");
71+ chunks.push(Buffer.from(value));
72+ }
73+ } finally {
74+ if (!finished) await reader.cancel();
75+ reader.releaseLock();
76+ }
77+ return parseManifest(Buffer.concat(chunks, bytes).toString("utf8"), "hosted catalog manifest");
78+}
79+
80+export async function planCatalogUpload({
81+ manifestPath = DEFAULT_MANIFEST_PATH,
82+ origin,
83+ fetcher = globalThis.fetch,
84+} = {}) {
85+ const local = parseManifest(readFileSync(manifestPath, "utf8"), "generated catalog manifest");
86+ const remote = await remoteManifest(configurationOrigin(origin), fetcher);
87+ if (remote === null) {
88+ return { upload: true, reason: "hosted catalog manifest is missing", local, remote };
89+ }
90+ if (local.schemaVersion !== remote.schemaVersion) {
91+ return { upload: true, reason: "catalog schema changed", local, remote };
92+ }
93+ if (local.sourceRevision !== remote.sourceRevision) {
94+ return { upload: true, reason: "IDFM source revision changed", local, remote };
95+ }
96+ return { upload: false, reason: "IDFM source revision is already hosted", local, remote };
97+}
98+
99+if (process.argv[1] === fileURLToPath(import.meta.url)) {
100+ try {
101+ const plan = await planCatalogUpload({
102+ manifestPath: process.argv[2] ?? DEFAULT_MANIFEST_PATH,
103+ origin: process.argv[3] ?? process.env.CONFIG_SITE_ORIGIN,
104+ });
105+ console.error(`${plan.reason}: ${plan.local.sourceRevision}`);
106+ console.log(plan.upload ? "true" : "false");
107+ } catch (error) {
108+ console.error(error instanceof Error ? error.message : error);
109+ process.exitCode = 1;
110+ }
111+}
added scripts/verify-config-site.mjs +221 -0
new file mode 100644
@@ -0,0 +1,221 @@
1+import { readdirSync, readFileSync, statSync } from "node:fs";
2+import { dirname, join, relative, resolve, sep } from "node:path";
3+import { fileURLToPath } from "node:url";
4+import {
5+ isCatalogManifest,
6+ isCatalogSearchPage,
7+ isCatalogServiceDocument,
8+ isCatalogServicesPage,
9+} from "../packages/config-page/src/catalog-client.js";
10+
11+const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
12+const DEFAULT_SITE_PATH = join(PROJECT_ROOT, "var/config-site");
13+const REQUIRED_CATALOG_GROUPS = ["search", "places", "services"];
14+const REMOTE_CONCURRENCY = 16;
15+const REMOTE_TIMEOUT_MS = 30_000;
16+
17+function fail(message) {
18+ throw new Error(`verify:config-site failed: ${message}`);
19+}
20+
21+function siteOrigin(value) {
22+ let parsed;
23+ try {
24+ parsed = new URL(value);
25+ } catch {
26+ fail("origin must be an absolute HTTPS URL");
27+ }
28+ if (parsed.protocol !== "https:"
29+ || parsed.username !== ""
30+ || parsed.password !== ""
31+ || parsed.search !== ""
32+ || parsed.hash !== "") {
33+ fail("origin must be an absolute HTTPS URL without credentials, query, or fragment");
34+ }
35+ if (!parsed.pathname.endsWith("/")) parsed.pathname += "/";
36+ return parsed;
37+}
38+
39+function filesUnder(root) {
40+ const files = [];
41+ function visit(directory) {
42+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
43+ const path = join(directory, entry.name);
44+ if (entry.isDirectory()) visit(path);
45+ else if (entry.isFile()) files.push(path);
46+ else fail(`unsupported site entry: ${relative(root, path)}`);
47+ }
48+ }
49+ visit(root);
50+ return files.sort();
51+}
52+
53+function parseJson(path, label) {
54+ try {
55+ return JSON.parse(readFileSync(path, "utf8"));
56+ } catch {
57+ fail(`${label} is not valid JSON`);
58+ }
59+}
60+
61+function verifyLocalSite(sitePath, includeCatalog) {
62+ const root = resolve(sitePath);
63+ if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
64+ fail(`site directory does not exist: ${root}`);
65+ }
66+ const indexPath = join(root, "index.html");
67+ if (!statSync(indexPath, { throwIfNoEntry: false })?.isFile()) fail("index.html is missing");
68+ if (!includeCatalog) {
69+ return {
70+ root,
71+ manifest: null,
72+ files: filesUnder(root),
73+ catalogJsonCount: 0,
74+ };
75+ }
76+
77+ const manifestPath = join(root, "catalog", "manifest.json");
78+ if (!statSync(manifestPath, { throwIfNoEntry: false })?.isFile()) fail("catalog/manifest.json is missing");
79+
80+ const manifest = parseJson(manifestPath, "catalog manifest");
81+ if (!isCatalogManifest(manifest)) fail("catalog manifest does not satisfy the static catalog contract");
82+ const revisionRoot = join(root, "catalog", manifest.revision);
83+ if (!statSync(revisionRoot, { throwIfNoEntry: false })?.isDirectory()) {
84+ fail(`catalog revision directory is missing: ${manifest.revision}`);
85+ }
86+
87+ let catalogJsonCount = 0;
88+ for (const group of REQUIRED_CATALOG_GROUPS) {
89+ const groupRoot = join(revisionRoot, group);
90+ if (!statSync(groupRoot, { throwIfNoEntry: false })?.isDirectory()) {
91+ fail(`current catalog has no ${group} directory`);
92+ }
93+ const groupFiles = filesUnder(groupRoot).filter((path) => path.endsWith(".json"));
94+ if (groupFiles.length === 0) fail(`current catalog has no ${group} JSON`);
95+ for (const path of groupFiles) {
96+ const document = parseJson(path, relative(root, path));
97+ const catalogPath = relative(revisionRoot, path).split(sep);
98+ const page = catalogPath.at(-1)?.match(/^([0-9]+)\.json$/u);
99+ const valid = group === "search"
100+ ? catalogPath.length === 3 && page !== undefined && page !== null
101+ && isCatalogSearchPage(document, manifest.revision, Number(page[1]))
102+ : group === "places"
103+ ? catalogPath.length === 3 && page !== undefined && page !== null
104+ && isCatalogServicesPage(document, manifest.revision, catalogPath[1], Number(page[1]))
105+ : catalogPath.length === 2
106+ && isCatalogServiceDocument(
107+ document,
108+ manifest.revision,
109+ catalogPath[1].replace(/\.json$/u, ""),
110+ );
111+ if (!valid) fail(`${relative(root, path)} does not satisfy the static catalog contract`);
112+ catalogJsonCount += 1;
113+ }
114+ }
115+
116+ return {
117+ root,
118+ manifest,
119+ files: filesUnder(root),
120+ catalogJsonCount,
121+ };
122+}
123+
124+function remoteUrl(origin, root, path) {
125+ const parts = relative(root, path).split(sep);
126+ if (parts.some((part) => part === "" || part === "." || part === "..")) {
127+ fail(`unsafe site path: ${relative(root, path)}`);
128+ }
129+ return new URL(parts.map(encodeURIComponent).join("/"), origin);
130+}
131+
132+async function verifyServedFiles(local, origin, fetcher) {
133+ let next = 0;
134+ async function worker() {
135+ while (next < local.files.length) {
136+ const path = local.files[next];
137+ next += 1;
138+ const url = remoteUrl(origin, local.root, path);
139+ const expected = readFileSync(path);
140+ try {
141+ const response = await fetcher(url, {
142+ headers: { Accept: "*/*", "Cache-Control": "no-cache" },
143+ redirect: "error",
144+ signal: AbortSignal.timeout(REMOTE_TIMEOUT_MS),
145+ });
146+ if (!response.ok) fail(`${url.href} returned HTTP ${response.status}`);
147+ const declaredLength = Number(response.headers.get("content-length"));
148+ if (Number.isFinite(declaredLength) && declaredLength > expected.length) {
149+ fail(`served response is too large for ${url.href}`);
150+ }
151+ if (response.body === null) fail(`served response has no body for ${url.href}`);
152+ const reader = response.body.getReader();
153+ const chunks = [];
154+ let bytes = 0;
155+ let finished = false;
156+ try {
157+ while (true) {
158+ const { done, value } = await reader.read();
159+ if (done) {
160+ finished = true;
161+ break;
162+ }
163+ bytes += value.byteLength;
164+ if (bytes > expected.length) fail(`served response is too large for ${url.href}`);
165+ chunks.push(value);
166+ }
167+ } finally {
168+ if (!finished) await reader.cancel();
169+ reader.releaseLock();
170+ }
171+ const actual = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), bytes);
172+ if (!actual.equals(expected)) fail(`served bytes differ for ${url.href}`);
173+ } catch (error) {
174+ if (error instanceof Error && error.message.startsWith("verify:config-site failed:")) throw error;
175+ fail(`could not fetch or validate ${url.href}`);
176+ }
177+ }
178+ }
179+ await Promise.all(Array.from(
180+ { length: Math.min(REMOTE_CONCURRENCY, local.files.length) },
181+ () => worker(),
182+ ));
183+}
184+
185+export async function verifyConfigSite({
186+ sitePath = DEFAULT_SITE_PATH,
187+ origin,
188+ fetcher = globalThis.fetch,
189+ includeCatalog = true,
190+} = {}) {
191+ const local = verifyLocalSite(sitePath, includeCatalog);
192+ const parsedOrigin = siteOrigin(origin);
193+ await verifyServedFiles(local, parsedOrigin, fetcher);
194+ return {
195+ origin: parsedOrigin.href,
196+ revision: local.manifest?.revision ?? null,
197+ sourceRevision: local.manifest?.sourceRevision ?? null,
198+ fileCount: local.files.length,
199+ catalogJsonCount: local.catalogJsonCount,
200+ };
201+}
202+
203+if (process.argv[1] === fileURLToPath(import.meta.url)) {
204+ try {
205+ const arguments_ = process.argv.slice(2);
206+ 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]");
210+ }
211+ const result = await verifyConfigSite({
212+ sitePath: positional[0] ?? DEFAULT_SITE_PATH,
213+ origin: positional[1] ?? process.env.CONFIG_SITE_ORIGIN,
214+ includeCatalog: !pageOnly,
215+ });
216+ console.log(JSON.stringify(result, null, 2));
217+ } catch (error) {
218+ console.error(error instanceof Error ? error.message : error);
219+ process.exitCode = 1;
220+ }
221+}
new file mode 100644
@@ -0,0 +1,221 @@
1+import { readdirSync, readFileSync, statSync } from "node:fs";
2+import { dirname, join, relative, resolve, sep } from "node:path";
3+import { fileURLToPath } from "node:url";
4+import {
5+ isCatalogManifest,
6+ isCatalogSearchPage,
7+ isCatalogServiceDocument,
8+ isCatalogServicesPage,
9+} from "../packages/config-page/src/catalog-client.js";
10+
11+const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
12+const DEFAULT_SITE_PATH = join(PROJECT_ROOT, "var/config-site");
13+const REQUIRED_CATALOG_GROUPS = ["search", "places", "services"];
14+const REMOTE_CONCURRENCY = 16;
15+const REMOTE_TIMEOUT_MS = 30_000;
16+
17+function fail(message) {
18+ throw new Error(`verify:config-site failed: ${message}`);
19+}
20+
21+function siteOrigin(value) {
22+ let parsed;
23+ try {
24+ parsed = new URL(value);
25+ } catch {
26+ fail("origin must be an absolute HTTPS URL");
27+ }
28+ if (parsed.protocol !== "https:"
29+ || parsed.username !== ""
30+ || parsed.password !== ""
31+ || parsed.search !== ""
32+ || parsed.hash !== "") {
33+ fail("origin must be an absolute HTTPS URL without credentials, query, or fragment");
34+ }
35+ if (!parsed.pathname.endsWith("/")) parsed.pathname += "/";
36+ return parsed;
37+}
38+
39+function filesUnder(root) {
40+ const files = [];
41+ function visit(directory) {
42+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
43+ const path = join(directory, entry.name);
44+ if (entry.isDirectory()) visit(path);
45+ else if (entry.isFile()) files.push(path);
46+ else fail(`unsupported site entry: ${relative(root, path)}`);
47+ }
48+ }
49+ visit(root);
50+ return files.sort();
51+}
52+
53+function parseJson(path, label) {
54+ try {
55+ return JSON.parse(readFileSync(path, "utf8"));
56+ } catch {
57+ fail(`${label} is not valid JSON`);
58+ }
59+}
60+
61+function verifyLocalSite(sitePath, includeCatalog) {
62+ const root = resolve(sitePath);
63+ if (!statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
64+ fail(`site directory does not exist: ${root}`);
65+ }
66+ const indexPath = join(root, "index.html");
67+ if (!statSync(indexPath, { throwIfNoEntry: false })?.isFile()) fail("index.html is missing");
68+ if (!includeCatalog) {
69+ return {
70+ root,
71+ manifest: null,
72+ files: filesUnder(root),
73+ catalogJsonCount: 0,
74+ };
75+ }
76+
77+ const manifestPath = join(root, "catalog", "manifest.json");
78+ if (!statSync(manifestPath, { throwIfNoEntry: false })?.isFile()) fail("catalog/manifest.json is missing");
79+
80+ const manifest = parseJson(manifestPath, "catalog manifest");
81+ if (!isCatalogManifest(manifest)) fail("catalog manifest does not satisfy the static catalog contract");
82+ const revisionRoot = join(root, "catalog", manifest.revision);
83+ if (!statSync(revisionRoot, { throwIfNoEntry: false })?.isDirectory()) {
84+ fail(`catalog revision directory is missing: ${manifest.revision}`);
85+ }
86+
87+ let catalogJsonCount = 0;
88+ for (const group of REQUIRED_CATALOG_GROUPS) {
89+ const groupRoot = join(revisionRoot, group);
90+ if (!statSync(groupRoot, { throwIfNoEntry: false })?.isDirectory()) {
91+ fail(`current catalog has no ${group} directory`);
92+ }
93+ const groupFiles = filesUnder(groupRoot).filter((path) => path.endsWith(".json"));
94+ if (groupFiles.length === 0) fail(`current catalog has no ${group} JSON`);
95+ for (const path of groupFiles) {
96+ const document = parseJson(path, relative(root, path));
97+ const catalogPath = relative(revisionRoot, path).split(sep);
98+ const page = catalogPath.at(-1)?.match(/^([0-9]+)\.json$/u);
99+ const valid = group === "search"
100+ ? catalogPath.length === 3 && page !== undefined && page !== null
101+ && isCatalogSearchPage(document, manifest.revision, Number(page[1]))
102+ : group === "places"
103+ ? catalogPath.length === 3 && page !== undefined && page !== null
104+ && isCatalogServicesPage(document, manifest.revision, catalogPath[1], Number(page[1]))
105+ : catalogPath.length === 2
106+ && isCatalogServiceDocument(
107+ document,
108+ manifest.revision,
109+ catalogPath[1].replace(/\.json$/u, ""),
110+ );
111+ if (!valid) fail(`${relative(root, path)} does not satisfy the static catalog contract`);
112+ catalogJsonCount += 1;
113+ }
114+ }
115+
116+ return {
117+ root,
118+ manifest,
119+ files: filesUnder(root),
120+ catalogJsonCount,
121+ };
122+}
123+
124+function remoteUrl(origin, root, path) {
125+ const parts = relative(root, path).split(sep);
126+ if (parts.some((part) => part === "" || part === "." || part === "..")) {
127+ fail(`unsafe site path: ${relative(root, path)}`);
128+ }
129+ return new URL(parts.map(encodeURIComponent).join("/"), origin);
130+}
131+
132+async function verifyServedFiles(local, origin, fetcher) {
133+ let next = 0;
134+ async function worker() {
135+ while (next < local.files.length) {
136+ const path = local.files[next];
137+ next += 1;
138+ const url = remoteUrl(origin, local.root, path);
139+ const expected = readFileSync(path);
140+ try {
141+ const response = await fetcher(url, {
142+ headers: { Accept: "*/*", "Cache-Control": "no-cache" },
143+ redirect: "error",
144+ signal: AbortSignal.timeout(REMOTE_TIMEOUT_MS),
145+ });
146+ if (!response.ok) fail(`${url.href} returned HTTP ${response.status}`);
147+ const declaredLength = Number(response.headers.get("content-length"));
148+ if (Number.isFinite(declaredLength) && declaredLength > expected.length) {
149+ fail(`served response is too large for ${url.href}`);
150+ }
151+ if (response.body === null) fail(`served response has no body for ${url.href}`);
152+ const reader = response.body.getReader();
153+ const chunks = [];
154+ let bytes = 0;
155+ let finished = false;
156+ try {
157+ while (true) {
158+ const { done, value } = await reader.read();
159+ if (done) {
160+ finished = true;
161+ break;
162+ }
163+ bytes += value.byteLength;
164+ if (bytes > expected.length) fail(`served response is too large for ${url.href}`);
165+ chunks.push(value);
166+ }
167+ } finally {
168+ if (!finished) await reader.cancel();
169+ reader.releaseLock();
170+ }
171+ const actual = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), bytes);
172+ if (!actual.equals(expected)) fail(`served bytes differ for ${url.href}`);
173+ } catch (error) {
174+ if (error instanceof Error && error.message.startsWith("verify:config-site failed:")) throw error;
175+ fail(`could not fetch or validate ${url.href}`);
176+ }
177+ }
178+ }
179+ await Promise.all(Array.from(
180+ { length: Math.min(REMOTE_CONCURRENCY, local.files.length) },
181+ () => worker(),
182+ ));
183+}
184+
185+export async function verifyConfigSite({
186+ sitePath = DEFAULT_SITE_PATH,
187+ origin,
188+ fetcher = globalThis.fetch,
189+ includeCatalog = true,
190+} = {}) {
191+ const local = verifyLocalSite(sitePath, includeCatalog);
192+ const parsedOrigin = siteOrigin(origin);
193+ await verifyServedFiles(local, parsedOrigin, fetcher);
194+ return {
195+ origin: parsedOrigin.href,
196+ revision: local.manifest?.revision ?? null,
197+ sourceRevision: local.manifest?.sourceRevision ?? null,
198+ fileCount: local.files.length,
199+ catalogJsonCount: local.catalogJsonCount,
200+ };
201+}
202+
203+if (process.argv[1] === fileURLToPath(import.meta.url)) {
204+ try {
205+ const arguments_ = process.argv.slice(2);
206+ 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]");
210+ }
211+ const result = await verifyConfigSite({
212+ sitePath: positional[0] ?? DEFAULT_SITE_PATH,
213+ origin: positional[1] ?? process.env.CONFIG_SITE_ORIGIN,
214+ includeCatalog: !pageOnly,
215+ });
216+ console.log(JSON.stringify(result, null, 2));
217+ } catch (error) {
218+ console.error(error instanceof Error ? error.message : error);
219+ process.exitCode = 1;
220+ }
221+}