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
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 differ | Binary 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 differ | Binary 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 differ | Binary 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 differ | Binary 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 differ | Binary 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 differ | Binary 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 differ | Binary 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: | ||
| 10 | 10 | required: true |
| 11 | 11 | default: false |
| 12 | 12 | type: boolean |
| 13 | + force_catalog_upload: | |
| 14 | + description: Upload a rebuilt catalog even when its IDFM source revision is already hosted | |
| 15 | + required: true | |
| 16 | + default: false | |
| 17 | + type: boolean | |
| 13 | 18 | |
| 14 | 19 | permissions: |
| 15 | 20 | contents: read |
| @@ -45,6 +50,62 @@ jobs: | ||
| 45 | 50 | |
| 46 | 51 | - 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 | 109 | deploy: |
| 49 | 110 | name: Build and deploy configuration site |
| 50 | 111 | needs: test |
| @@ -67,15 +128,98 @@ jobs: | ||
| 67 | 128 | IDFM_DATASET_TOKEN: ${{ secrets.IDFM_DATASET_TOKEN }} |
| 68 | 129 | 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 | 152 | - 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 | |
| 73 | 180 | |
| 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 | |
| 77 | 220 | |
| 78 | 221 | - name: Upload static site to Bunny Storage |
| 222 | + id: upload | |
| 79 | 223 | shell: bash |
| 80 | 224 | env: |
| 81 | 225 | BUNNY_STORAGE_ENDPOINT: ${{ vars.BUNNY_STORAGE_ENDPOINT }} |
| @@ -137,9 +281,84 @@ jobs: | ||
| 137 | 281 | --header "AccessKey: $BUNNY_API_KEY" \ |
| 138 | 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 | 359 | publish: |
| 141 | 360 | name: Publish GitHub release notes |
| 142 | - needs: [test, deploy] | |
| 361 | + needs: [test, package, deploy] | |
| 143 | 362 | if: ${{ startsWith(github.ref_name, 'v') }} |
| 144 | 363 | runs-on: ubuntu-latest |
| 145 | 364 | permissions: |
| @@ -152,6 +371,12 @@ jobs: | ||
| 152 | 371 | fetch-depth: 0 |
| 153 | 372 | 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 | 380 | - name: Generate categorized release notes |
| 156 | 381 | shell: bash |
| 157 | 382 | run: | |
| @@ -217,4 +442,5 @@ jobs: | ||
| 217 | 442 | --repo "$GITHUB_REPOSITORY" \ |
| 218 | 443 | --title "Lapin Futé $GITHUB_REF_NAME" \ |
| 219 | 444 | --notes-file release-notes.md \ |
| 220 | - --verify-tag | |
| 445 | + --verify-tag \ | |
| 446 | + release-assets/* | |
| @@ -10,6 +10,11 @@ on: | |||
| 10 | required: true | 10 | required: true |
| 11 | default: false | 11 | default: false |
| 12 | type: boolean | 12 | type: boolean |
| 13 | + force_catalog_upload: | ||
| 14 | + description: Upload a rebuilt catalog even when its IDFM source revision is already hosted | ||
| 15 | + required: true | ||
| 16 | + default: false | ||
| 17 | + type: boolean | ||
| 13 | 18 | ||
| 14 | permissions: | 19 | permissions: |
| 15 | contents: read | 20 | contents: read |
| @@ -45,6 +50,62 @@ jobs: | |||
| 45 | 50 | ||
| 46 | - run: npm test | 51 | - 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 site | 110 | name: Build and deploy configuration site |
| 50 | needs: test | 111 | 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:refresh | 129 | 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 site | 152 | - name: Build static configuration site |
| 71 | - if: ${{ github.event_name == 'workflow_dispatch' && inputs.refresh_catalog }} | 153 | + env: |
| 72 | - run: npm run build:config-site | 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 | ||
| 73 | 180 | ||
| 74 | - - name: Build configuration page | 181 | + 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-page | 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 | ||
| 77 | 220 | ||
| 78 | - name: Upload static site to Bunny Storage | 221 | - name: Upload static site to Bunny Storage |
| 222 | + id: upload | ||
| 79 | shell: bash | 223 | 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 notes | 360 | 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-latest | 363 | runs-on: ubuntu-latest |
| 145 | permissions: | 364 | permissions: |
| @@ -152,6 +371,12 @@ jobs: | |||
| 152 | fetch-depth: 0 | 371 | fetch-depth: 0 |
| 153 | persist-credentials: false | 372 | 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 notes | 380 | - name: Generate categorized release notes |
| 156 | shell: bash | 381 | 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-tag | 445 | + --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/ | ||
| 4 | 4 | !.env.example |
| 5 | 5 | var/ |
| 6 | 6 | packages/watch/build/ |
| 7 | +packages/watch/.lock-waf_* | |
| 7 | 8 | packages/watch/src/pkjs/ |
| 8 | 9 | packages/watch/src/generated/ |
| 9 | 10 | docs/ |
| @@ -4,6 +4,7 @@ node_modules/ | |||
| 4 | !.env.example | 4 | !.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 @@ | ||
| 11 | 11 | </p> |
| 12 | 12 | |
| 13 | 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 | 16 | · <a href="#compatibility">Compatibility</a> |
| 16 | 17 | · <a href="#getting-started">Getting started</a> |
| 17 | 18 | · <a href="#watch-controls">Watch controls</a> |
| @@ -23,18 +24,13 @@ | ||
| 23 | 24 | <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro"> |
| 24 | 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 | 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 | 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 | 30 | ## About |
| 33 | 31 | |
| 34 | 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 | 34 | ## Features |
| 39 | 35 | |
| 40 | 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 | 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 | 49 | | Firmware | 4.32 or newer | |
| 54 | 50 | | Phone | Pebble mobile app running the companion; internet access needed for fresh data | |
| 55 | 51 | | Building from source | Node.js 24.18.0, Bun, Pebble SDK 4.33.1, pebble-tool 5.0.40 | |
| 56 | 52 | |
| 57 | 53 | ## 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/`. | |
| 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). | |
| 67 | 61 | |
| 68 | 62 | ## Watch controls |
| 69 | 63 | |
| 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 | | |
| 76 | 74 | |
| 77 | 75 | Countdowns and the clock keep updating on the watch every minute; only the actions above trigger network requests. |
| 78 | 76 | |
| @@ -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 | ## About | 30 | ## 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 | ## Features | 34 | ## 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 started | 53 | ## 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 controls | 62 | ## 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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "lapin-fute", |
| 3 | - "version": "0.1.0", | |
| 3 | + "version": "1.0.0", | |
| 4 | 4 | "lockfileVersion": 3, |
| 5 | 5 | "requires": true, |
| 6 | 6 | "packages": { |
| 7 | 7 | "": { |
| 8 | 8 | "name": "lapin-fute", |
| 9 | - "version": "0.1.0", | |
| 9 | + "version": "1.0.0", | |
| 10 | 10 | "workspaces": [ |
| 11 | 11 | "packages/*" |
| 12 | 12 | ], |
| @@ -56,7 +56,7 @@ | ||
| 56 | 56 | }, |
| 57 | 57 | "packages/catalog": { |
| 58 | 58 | "name": "@lapin-fute/catalog", |
| 59 | - "version": "0.1.0", | |
| 59 | + "version": "1.0.0", | |
| 60 | 60 | "dependencies": { |
| 61 | 61 | "csv-parse": "7.0.2", |
| 62 | 62 | "yauzl": "3.4.0" |
| @@ -64,15 +64,15 @@ | ||
| 64 | 64 | }, |
| 65 | 65 | "packages/companion": { |
| 66 | 66 | "name": "@lapin-fute/companion", |
| 67 | - "version": "0.1.0" | |
| 67 | + "version": "1.0.0" | |
| 68 | 68 | }, |
| 69 | 69 | "packages/config-page": { |
| 70 | 70 | "name": "@lapin-fute/config-page", |
| 71 | - "version": "0.1.0" | |
| 71 | + "version": "1.0.0" | |
| 72 | 72 | }, |
| 73 | 73 | "packages/watch": { |
| 74 | 74 | "name": "lapin-fute-watch", |
| 75 | - "version": "0.1.0" | |
| 75 | + "version": "1.0.0" | |
| 76 | 76 | } |
| 77 | 77 | } |
| 78 | 78 | } |
| @@ -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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "lapin-fute", |
| 3 | - "version": "0.1.0", | |
| 3 | + "version": "1.0.0", | |
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | 6 | "workspaces": [ |
| @@ -19,6 +19,8 @@ | ||
| 19 | 19 | "measure:radio": "node scripts/measure-radio.mjs", |
| 20 | 20 | "measure:catalog": "node scripts/measure-catalog.mjs", |
| 21 | 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 | 24 | "test": "node scripts/test.mjs", |
| 23 | 25 | "test:contracts": "node --test packages/contracts/test/contracts.test.ts", |
| 24 | 26 | "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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@lapin-fute/catalog", |
| 3 | - "version": "0.1.0", | |
| 3 | + "version": "1.0.0", | |
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | 6 | "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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@lapin-fute/companion", |
| 3 | - "version": "0.1.0", | |
| 3 | + "version": "1.0.0", | |
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "commonjs", |
| 6 | 6 | "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 differ | Binary 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 differ | Binary 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 @@ | ||
| 146 | 146 | </div> |
| 147 | 147 | |
| 148 | 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 | 150 | <h3>Publisher and contact</h3> |
| 151 | 151 | <p><strong>Author and publisher:</strong> Anthodev, a private individual acting on a non-professional basis.</p> |
| 152 | 152 | <p><strong>Publication director:</strong> Anthodev.</p> |
| @@ -168,7 +168,7 @@ | ||
| 168 | 168 | </div> |
| 169 | 169 | |
| 170 | 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 | 172 | <h3>Édition et contact</h3> |
| 173 | 173 | <p><strong>Auteur et éditeur :</strong> Anthodev, personne physique agissant à titre privé et non professionnel.</p> |
| 174 | 174 | <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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@lapin-fute/config-page", |
| 3 | - "version": "0.1.0", | |
| 3 | + "version": "1.0.0", | |
| 4 | 4 | "private": true, |
| 5 | 5 | "type": "module", |
| 6 | 6 | "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) { | ||
| 36 | 36 | && fields.every((field) => Object.hasOwn(value, field)); |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | -function isManifest(value) { | |
| 39 | +export function isCatalogManifest(value) { | |
| 40 | 40 | return exactFields(value, MANIFEST_FIELDS) |
| 41 | 41 | && value.schemaVersion === SCHEMA_VERSION |
| 42 | 42 | && typeof value.revision === "string" && REVISION.test(value.revision) |
| @@ -65,6 +65,39 @@ function isPage(value, revision, page, field, placeId) { | ||
| 65 | 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 | 101 | function compareMatches(left, right) { |
| 69 | 102 | if (left.rank !== right.rank) return left.rank - right.rank; |
| 70 | 103 | for (let index = 0; index < left.order.length; index += 1) { |
| @@ -133,7 +166,7 @@ export function createCatalogClient({ | ||
| 133 | 166 | if (signal?.aborted) throw abortError(); |
| 134 | 167 | if (manifest !== null) return manifest; |
| 135 | 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 | 170 | if (signal?.aborted) throw abortError(); |
| 138 | 171 | // Concurrent first operations use the first successfully loaded revision. |
| 139 | 172 | manifest ??= body; |
| @@ -165,13 +198,9 @@ export function createCatalogClient({ | ||
| 165 | 198 | const body = await json(`${pinned.revision}/search/${bucket}/${page}.json`, signal, |
| 166 | 199 | page === 0 ? null : "BACKEND_UNAVAILABLE"); |
| 167 | 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 | 202 | for (const entry of body.places) { |
| 170 | - if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new CatalogClientError("BACKEND_UNAVAILABLE"); | |
| 171 | 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 | 204 | const words = searchText.split(" "); |
| 176 | 205 | if (!tokens.every((token) => words.some((word) => word.startsWith(token)))) continue; |
| 177 | 206 | const stop = normalizeCatalogSearchText(place.stopLabel); |
| @@ -231,9 +260,11 @@ export function createCatalogClient({ | ||
| 231 | 260 | while (true) { |
| 232 | 261 | const body = await json(`${pinned.revision}/places/${placeId}/${page}.json`, signal, |
| 233 | 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 | 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 | 268 | throw new CatalogClientError("INVALID_SERVICE"); |
| 238 | 269 | } |
| 239 | 270 | seen.add(service.serviceId); |
| @@ -250,9 +281,9 @@ export function createCatalogClient({ | ||
| 250 | 281 | const pinned = await pinnedManifest(signal); |
| 251 | 282 | const body = await json(`${pinned.revision}/services/${serviceId}.json`, signal, null); |
| 252 | 283 | 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 | + } | |
| 256 | 287 | return body.service; |
| 257 | 288 | } |
| 258 | 289 | |
| @@ -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_VERSION | 41 | && 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.revision | 285 | + 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 | ||
| 859 | 859 | const html = readFileSync(join(here, "../index.html"), "utf8"); |
| 860 | 860 | const controller = readFileSync(join(here, "../src/config-page.js"), "utf8"); |
| 861 | 861 | for (const fact of [ |
| 862 | - "Version 0.1.0", | |
| 862 | + "Version 1.0.0", | |
| 863 | 863 | "Publication director:", |
| 864 | 864 | "Directeur de la publication :", |
| 865 | 865 | "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 | ||
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | 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 | 10 | function fixture() { |
| 9 | 11 | const root = mkdtempSync(join(tmpdir(), "lapin-fute-config-site-")); |
| @@ -19,12 +21,26 @@ function fixture() { | ||
| 19 | 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 | 25 | const paths = fixture(); |
| 24 | 26 | try { |
| 25 | 27 | buildConfigSite({ ...paths, includeCatalog: false }); |
| 26 | 28 | assert.equal(readFileSync(join(paths.outputPath, "index.html"), "utf8"), "<h1>Lapin Futé</h1>"); |
| 27 | 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 | 44 | } finally { |
| 29 | 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 | 237 | test("Bunny releases deploy production tags while preview tags stop after quality checks", () => { |
| 57 | 238 | const workflow = readFileSync( |
| 58 | 239 | new URL("../../../.github/workflows/deploy-bunny.yml", import.meta.url), |
| 59 | 240 | "utf8", |
| 60 | 241 | ); |
| 242 | + const rollbackWorkflow = readFileSync( | |
| 243 | + new URL("../../../.github/workflows/rollback-bunny.yml", import.meta.url), | |
| 244 | + "utf8", | |
| 245 | + ); | |
| 61 | 246 | assert.doesNotMatch(workflow, /branches:\s*\[develop\]/u); |
| 62 | 247 | assert.match(workflow, /tags:\s*\["v\*", "pre-v\*"\]/u); |
| 63 | 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 | 256 | assert.match(workflow, /\["log", "--first-parent", "-z", "--format=%s%x00%h"/u); |
| 66 | 257 | for (const title of ["Features", "Changes", "Fixes", "CI"]) { |
| 67 | 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 | 261 | assert.doesNotMatch(workflow, /--generate-notes/u); |
| 71 | 262 | assert.match(workflow, /github\.event_name == 'workflow_dispatch' && inputs\.refresh_catalog/u); |
| 72 | 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); | |
| 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); | |
| 75 | 270 | assert.match( |
| 76 | 271 | workflow, |
| 77 | 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 | }); |
| @@ -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 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "lapin-fute-watch", |
| 3 | 3 | "author": "Anthodev", |
| 4 | - "version": "0.1.0", | |
| 4 | + "version": "1.0.0", | |
| 5 | 5 | "keywords": [ |
| 6 | 6 | "pebble-app" |
| 7 | 7 | ], |
| @@ -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 | +} | ||