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

Merged@github-anthodev wants to merge feature/ship-version-1/release/9 into develop
Merged as b6ec13dcb41d.
26 files changed
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
Large diff collapsed to keep the page fast — load it when you need it.
modified packages/watch/package.json +1 -1
Large diff collapsed to keep the page fast — load it when you need it.
added scripts/plan-catalog-upload.mjs +111 -0
Large diff collapsed to keep the page fast — load it when you need it.
added scripts/verify-config-site.mjs +221 -0
Large diff collapsed to keep the page fast — load it when you need it.