fix: refine watch detail layouts and automate RePebble publishing (#23)Unverified
* fix(watch): balance detail screen spacing on Emery and Gabbro * ci(release): use version tags as release titles * docs: refresh departure screenshots and simplify README badges * ci(release): automate RePebble publishing and bump version to 1.0.1
932a713 parent: b6ec13d modified
.github/store-assets/emery_02-departures.png +0 -0 | Binary files a/.github/store-assets/emery_02-departures.png and b/.github/store-assets/emery_02-departures.png differ |
| Binary files a/.github/store-assets/emery_02-departures.png and b/.github/store-assets/emery_02-departures.png differ | Binary files a/.github/store-assets/emery_02-departures.png and b/.github/store-assets/emery_02-departures.png differ |
modified
.github/store-assets/gabbro_02-departures.png +0 -0 | Binary files a/.github/store-assets/gabbro_02-departures.png and b/.github/store-assets/gabbro_02-departures.png differ |
| Binary files a/.github/store-assets/gabbro_02-departures.png and b/.github/store-assets/gabbro_02-departures.png differ | Binary files a/.github/store-assets/gabbro_02-departures.png and b/.github/store-assets/gabbro_02-departures.png differ |
modified
.github/workflows/deploy-bunny.yml +53 -1 | @@ -440,7 +440,59 @@ jobs: | ||
| 440 | 440 | set -euo pipefail |
| 441 | 441 | gh release create "$GITHUB_REF_NAME" \ |
| 442 | 442 | --repo "$GITHUB_REPOSITORY" \ |
| 443 | - --title "Lapin Futé $GITHUB_REF_NAME" \ | |
| 443 | + --title "$GITHUB_REF_NAME" \ | |
| 444 | 444 | --notes-file release-notes.md \ |
| 445 | 445 | --verify-tag \ |
| 446 | 446 | release-assets/* |
| 447 | + | |
| 448 | + store: | |
| 449 | + name: Publish release to the RePebble store | |
| 450 | + needs: publish | |
| 451 | + if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} | |
| 452 | + runs-on: ubuntu-latest | |
| 453 | + environment: pebble-store | |
| 454 | + permissions: | |
| 455 | + contents: read | |
| 456 | + timeout-minutes: 15 | |
| 457 | + steps: | |
| 458 | + - name: Check out tagged commit | |
| 459 | + uses: actions/checkout@v4 | |
| 460 | + with: | |
| 461 | + ref: ${{ github.sha }} | |
| 462 | + persist-credentials: false | |
| 463 | + | |
| 464 | + - uses: actions/setup-node@v4 | |
| 465 | + with: | |
| 466 | + node-version-file: .node-version | |
| 467 | + cache: npm | |
| 468 | + | |
| 469 | + - run: npm ci --ignore-scripts | |
| 470 | + | |
| 471 | + - name: Download release artifacts | |
| 472 | + uses: actions/download-artifact@v4 | |
| 473 | + with: | |
| 474 | + name: lapin-fute-${{ github.ref_name }} | |
| 475 | + path: release-assets | |
| 476 | + | |
| 477 | + - name: Fetch published release notes | |
| 478 | + env: | |
| 479 | + GH_TOKEN: ${{ github.token }} | |
| 480 | + RELEASE_TAG: ${{ github.ref_name }} | |
| 481 | + shell: bash | |
| 482 | + run: | | |
| 483 | + set -euo pipefail | |
| 484 | + gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json body --jq .body \ | |
| 485 | + > "$RUNNER_TEMP/store-release-notes.md" | |
| 486 | + | |
| 487 | + - name: Publish to the RePebble store | |
| 488 | + env: | |
| 489 | + PEBBLE_FIREBASE_REFRESH_TOKEN: ${{ secrets.PEBBLE_FIREBASE_REFRESH_TOKEN }} | |
| 490 | + RELEASE_TAG: ${{ github.ref_name }} | |
| 491 | + shell: bash | |
| 492 | + run: | | |
| 493 | + set -euo pipefail | |
| 494 | + node scripts/publish-pebble-store.mjs \ | |
| 495 | + --pbw "release-assets/lapin-fute-$RELEASE_TAG.pbw" \ | |
| 496 | + --checksums "release-assets/SHA256SUMS" \ | |
| 497 | + --tag "$RELEASE_TAG" \ | |
| 498 | + --notes-file "$RUNNER_TEMP/store-release-notes.md" | |
| @@ -440,7 +440,59 @@ jobs: | |||
| 440 | set -euo pipefail | 440 | set -euo pipefail |
| 441 | gh release create "$GITHUB_REF_NAME" \ | 441 | gh release create "$GITHUB_REF_NAME" \ |
| 442 | --repo "$GITHUB_REPOSITORY" \ | 442 | --repo "$GITHUB_REPOSITORY" \ |
| 443 | - --title "Lapin Futé $GITHUB_REF_NAME" \ | 443 | + --title "$GITHUB_REF_NAME" \ |
| 444 | --notes-file release-notes.md \ | 444 | --notes-file release-notes.md \ |
| 445 | --verify-tag \ | 445 | --verify-tag \ |
| 446 | release-assets/* | 446 | release-assets/* |
| 447 | + | ||
| 448 | + store: | ||
| 449 | + name: Publish release to the RePebble store | ||
| 450 | + needs: publish | ||
| 451 | + if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} | ||
| 452 | + runs-on: ubuntu-latest | ||
| 453 | + environment: pebble-store | ||
| 454 | + permissions: | ||
| 455 | + contents: read | ||
| 456 | + timeout-minutes: 15 | ||
| 457 | + steps: | ||
| 458 | + - name: Check out tagged commit | ||
| 459 | + uses: actions/checkout@v4 | ||
| 460 | + with: | ||
| 461 | + ref: ${{ github.sha }} | ||
| 462 | + persist-credentials: false | ||
| 463 | + | ||
| 464 | + - uses: actions/setup-node@v4 | ||
| 465 | + with: | ||
| 466 | + node-version-file: .node-version | ||
| 467 | + cache: npm | ||
| 468 | + | ||
| 469 | + - run: npm ci --ignore-scripts | ||
| 470 | + | ||
| 471 | + - name: Download release artifacts | ||
| 472 | + uses: actions/download-artifact@v4 | ||
| 473 | + with: | ||
| 474 | + name: lapin-fute-${{ github.ref_name }} | ||
| 475 | + path: release-assets | ||
| 476 | + | ||
| 477 | + - name: Fetch published release notes | ||
| 478 | + env: | ||
| 479 | + GH_TOKEN: ${{ github.token }} | ||
| 480 | + RELEASE_TAG: ${{ github.ref_name }} | ||
| 481 | + shell: bash | ||
| 482 | + run: | | ||
| 483 | + set -euo pipefail | ||
| 484 | + gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json body --jq .body \ | ||
| 485 | + > "$RUNNER_TEMP/store-release-notes.md" | ||
| 486 | + | ||
| 487 | + - name: Publish to the RePebble store | ||
| 488 | + env: | ||
| 489 | + PEBBLE_FIREBASE_REFRESH_TOKEN: ${{ secrets.PEBBLE_FIREBASE_REFRESH_TOKEN }} | ||
| 490 | + RELEASE_TAG: ${{ github.ref_name }} | ||
| 491 | + shell: bash | ||
| 492 | + run: | | ||
| 493 | + set -euo pipefail | ||
| 494 | + node scripts/publish-pebble-store.mjs \ | ||
| 495 | + --pbw "release-assets/lapin-fute-$RELEASE_TAG.pbw" \ | ||
| 496 | + --checksums "release-assets/SHA256SUMS" \ | ||
| 497 | + --tag "$RELEASE_TAG" \ | ||
| 498 | + --notes-file "$RUNNER_TEMP/store-release-notes.md" | ||
added
LICENSE +21 -0 | new file mode 100644 | ||
| @@ -0,0 +1,21 @@ | ||
| 1 | +MIT License | |
| 2 | + | |
| 3 | +Copyright (c) 2026 Anthodev | |
| 4 | + | |
| 5 | +Permission is hereby granted, free of charge, to any person obtaining a copy | |
| 6 | +of this software and associated documentation files (the "Software"), to deal | |
| 7 | +in the Software without restriction, including without limitation the rights | |
| 8 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| 9 | +copies of the Software, and to permit persons to whom the Software is | |
| 10 | +furnished to do so, subject to the following conditions: | |
| 11 | + | |
| 12 | +The above copyright notice and this permission notice shall be included in all | |
| 13 | +copies or substantial portions of the Software. | |
| 14 | + | |
| 15 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| 16 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| 17 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| 18 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| 19 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| 20 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| 21 | +SOFTWARE. | |
| new file mode 100644 | |||
| @@ -0,0 +1,21 @@ | |||
| 1 | +MIT License | ||
| 2 | + | ||
| 3 | +Copyright (c) 2026 Anthodev | ||
| 4 | + | ||
| 5 | +Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| 6 | +of this software and associated documentation files (the "Software"), to deal | ||
| 7 | +in the Software without restriction, including without limitation the rights | ||
| 8 | +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| 9 | +copies of the Software, and to permit persons to whom the Software is | ||
| 10 | +furnished to do so, subject to the following conditions: | ||
| 11 | + | ||
| 12 | +The above copyright notice and this permission notice shall be included in all | ||
| 13 | +copies or substantial portions of the Software. | ||
| 14 | + | ||
| 15 | +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| 16 | +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| 17 | +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| 18 | +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| 19 | +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| 20 | +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| 21 | +SOFTWARE. | ||
modified
README.md +14 -2 | @@ -18,13 +18,13 @@ | ||
| 18 | 18 | · <a href="#watch-controls">Watch controls</a> |
| 19 | 19 | · <a href="#privacy">Privacy</a> |
| 20 | 20 | · <a href="#build-from-source">Build from source</a> |
| 21 | + · <a href="LICENSE">MIT License</a> | |
| 21 | 22 | </p> |
| 22 | 23 | |
| 23 | 24 | <p align="center"> |
| 24 | 25 | <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro"> |
| 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"> | |
| 26 | 26 | <img src="https://img.shields.io/badge/Language-EN%20%7C%20FR-00A5E0" alt="English and French"> |
| 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 | + <a href="https://apps.repebble.com/6d6aa01b7ecb4cfea469a183"><img src="https://img.shields.io/badge/RePebble-v1.0.1-FF4938" alt="Lapin Futé 1.0.1 on RePebble"></a> | |
| 28 | 28 | </p> |
| 29 | 29 | |
| 30 | 30 | ## About |
| @@ -102,6 +102,18 @@ npm run build:config-site # assemble var/config-site; f | ||
| 102 | 102 | |
| 103 | 103 | `node --env-file=.env scripts/probe-catalog.mjs` runs a small live check against PRIM, one request per transport mode, and needs a `PRIM_API_KEY`. Operator tokens live only in `.env` and are never printed or embedded in build output. |
| 104 | 104 | |
| 105 | +## Store publishing | |
| 106 | + | |
| 107 | +Pushing a stable version tag (`v1.2.3`) publishes the GitHub release first; once it succeeds, `scripts/publish-pebble-store.mjs` uploads the exact validated PBW to the RePebble store. Preview tags (`pre-v`) and manual dispatches never reach the store job, which runs in the `pebble-store` environment with read-only repository access. | |
| 108 | + | |
| 109 | +One-time setup in the repository settings: | |
| 110 | + | |
| 111 | +- **Create the `pebble-store` environment with real protections.** Add required reviewers and a tag rule limited to `v*` under Settings, Environments. Referencing the environment in the workflow alone protects nothing; the reviewers and the tag rule are what gate a release. | |
| 112 | +- **Add `PEBBLE_FIREBASE_REFRESH_TOKEN` as an environment secret.** Run `pebble login` yourself and take the refresh token from the tool's local credential file (`firebase_oauth_storage.json` in the pebble-tool configuration directory). Never paste credentials into issues, pull requests, or chats. | |
| 113 | +- **Plan for rotation.** A refresh token can be revoked or rotated, and a rotated token returned during a run never updates the GitHub secret. If a run fails authentication, run `pebble login` again and update the secret. | |
| 114 | + | |
| 115 | +An approved run performs one authenticated upload of the already checksum-verified archive bytes. Nothing is rebuilt, screenshots are left unchanged, and publication is immediate. The job refuses versions that are not newer than the published version on either platform, but hidden drafts are invisible to that preflight; before rerunning an uncertain upload, inspect the developer dashboard, since the job offers no overwrite guarantee there. | |
| 116 | + | |
| 105 | 117 | ## Icon |
| 106 | 118 | |
| 107 | 119 | The icon is a rounded red ticket (`#fa4a36`) whose cut-out route traces the rabbit down to two round stops. All files live under `packages/config-page/assets/`: |
| @@ -18,13 +18,13 @@ | |||
| 18 | · <a href="#watch-controls">Watch controls</a> | 18 | · <a href="#watch-controls">Watch controls</a> |
| 19 | · <a href="#privacy">Privacy</a> | 19 | · <a href="#privacy">Privacy</a> |
| 20 | · <a href="#build-from-source">Build from source</a> | 20 | · <a href="#build-from-source">Build from source</a> |
| 21 | + · <a href="LICENSE">MIT License</a> | ||
| 21 | </p> | 22 | </p> |
| 22 | 23 | ||
| 23 | <p align="center"> | 24 | <p align="center"> |
| 24 | <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro"> | 25 | <img src="https://img.shields.io/badge/Pebble-emery%20%7C%20gabbro-00A5E0" alt="Pebble emery and gabbro"> |
| 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"> | ||
| 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/Language-EN%20%7C%20FR-00A5E0" alt="English and French"> |
| 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 | + <a href="https://apps.repebble.com/6d6aa01b7ecb4cfea469a183"><img src="https://img.shields.io/badge/RePebble-v1.0.1-FF4938" alt="Lapin Futé 1.0.1 on RePebble"></a> |
| 28 | </p> | 28 | </p> |
| 29 | 29 | ||
| 30 | ## About | 30 | ## About |
| @@ -102,6 +102,18 @@ npm run build:config-site # assemble var/config-site; f | |||
| 102 | 102 | ||
| 103 | `node --env-file=.env scripts/probe-catalog.mjs` runs a small live check against PRIM, one request per transport mode, and needs a `PRIM_API_KEY`. Operator tokens live only in `.env` and are never printed or embedded in build output. | 103 | `node --env-file=.env scripts/probe-catalog.mjs` runs a small live check against PRIM, one request per transport mode, and needs a `PRIM_API_KEY`. Operator tokens live only in `.env` and are never printed or embedded in build output. |
| 104 | 104 | ||
| 105 | +## Store publishing | ||
| 106 | + | ||
| 107 | +Pushing a stable version tag (`v1.2.3`) publishes the GitHub release first; once it succeeds, `scripts/publish-pebble-store.mjs` uploads the exact validated PBW to the RePebble store. Preview tags (`pre-v`) and manual dispatches never reach the store job, which runs in the `pebble-store` environment with read-only repository access. | ||
| 108 | + | ||
| 109 | +One-time setup in the repository settings: | ||
| 110 | + | ||
| 111 | +- **Create the `pebble-store` environment with real protections.** Add required reviewers and a tag rule limited to `v*` under Settings, Environments. Referencing the environment in the workflow alone protects nothing; the reviewers and the tag rule are what gate a release. | ||
| 112 | +- **Add `PEBBLE_FIREBASE_REFRESH_TOKEN` as an environment secret.** Run `pebble login` yourself and take the refresh token from the tool's local credential file (`firebase_oauth_storage.json` in the pebble-tool configuration directory). Never paste credentials into issues, pull requests, or chats. | ||
| 113 | +- **Plan for rotation.** A refresh token can be revoked or rotated, and a rotated token returned during a run never updates the GitHub secret. If a run fails authentication, run `pebble login` again and update the secret. | ||
| 114 | + | ||
| 115 | +An approved run performs one authenticated upload of the already checksum-verified archive bytes. Nothing is rebuilt, screenshots are left unchanged, and publication is immediate. The job refuses versions that are not newer than the published version on either platform, but hidden drafts are invisible to that preflight; before rerunning an uncertain upload, inspect the developer dashboard, since the job offers no overwrite guarantee there. | ||
| 116 | + | ||
| 105 | ## Icon | 117 | ## Icon |
| 106 | 118 | ||
| 107 | The icon is a rounded red ticket (`#fa4a36`) whose cut-out route traces the rabbit down to two round stops. All files live under `packages/config-page/assets/`: | 119 | The icon is a rounded red ticket (`#fa4a36`) whose cut-out route traces the rabbit down to two round stops. All files live under `packages/config-page/assets/`: |
modified
package-lock.json +11 -6 | @@ -1,12 +1,13 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "lapin-fute", |
| 3 | - "version": "1.0.0", | |
| 3 | + "version": "1.0.1", | |
| 4 | 4 | "lockfileVersion": 3, |
| 5 | 5 | "requires": true, |
| 6 | 6 | "packages": { |
| 7 | 7 | "": { |
| 8 | 8 | "name": "lapin-fute", |
| 9 | - "version": "1.0.0", | |
| 9 | + "version": "1.0.1", | |
| 10 | + "license": "MIT", | |
| 10 | 11 | "workspaces": [ |
| 11 | 12 | "packages/*" |
| 12 | 13 | ], |
| @@ -56,7 +57,8 @@ | ||
| 56 | 57 | }, |
| 57 | 58 | "packages/catalog": { |
| 58 | 59 | "name": "@lapin-fute/catalog", |
| 59 | - "version": "1.0.0", | |
| 60 | + "version": "1.0.1", | |
| 61 | + "license": "MIT", | |
| 60 | 62 | "dependencies": { |
| 61 | 63 | "csv-parse": "7.0.2", |
| 62 | 64 | "yauzl": "3.4.0" |
| @@ -64,15 +66,18 @@ | ||
| 64 | 66 | }, |
| 65 | 67 | "packages/companion": { |
| 66 | 68 | "name": "@lapin-fute/companion", |
| 67 | - "version": "1.0.0" | |
| 69 | + "version": "1.0.1", | |
| 70 | + "license": "MIT" | |
| 68 | 71 | }, |
| 69 | 72 | "packages/config-page": { |
| 70 | 73 | "name": "@lapin-fute/config-page", |
| 71 | - "version": "1.0.0" | |
| 74 | + "version": "1.0.1", | |
| 75 | + "license": "MIT" | |
| 72 | 76 | }, |
| 73 | 77 | "packages/watch": { |
| 74 | 78 | "name": "lapin-fute-watch", |
| 75 | - "version": "1.0.0" | |
| 79 | + "version": "1.0.1", | |
| 80 | + "license": "MIT" | |
| 76 | 81 | } |
| 77 | 82 | } |
| 78 | 83 | } |
| @@ -1,12 +1,13 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "lapin-fute", | 2 | "name": "lapin-fute", |
| 3 | - "version": "1.0.0", | 3 | + "version": "1.0.1", |
| 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": "1.0.0", | 9 | + "version": "1.0.1", |
| 10 | + "license": "MIT", | ||
| 10 | "workspaces": [ | 11 | "workspaces": [ |
| 11 | "packages/*" | 12 | "packages/*" |
| 12 | ], | 13 | ], |
| @@ -56,7 +57,8 @@ | |||
| 56 | }, | 57 | }, |
| 57 | "packages/catalog": { | 58 | "packages/catalog": { |
| 58 | "name": "@lapin-fute/catalog", | 59 | "name": "@lapin-fute/catalog", |
| 59 | - "version": "1.0.0", | 60 | + "version": "1.0.1", |
| 61 | + "license": "MIT", | ||
| 60 | "dependencies": { | 62 | "dependencies": { |
| 61 | "csv-parse": "7.0.2", | 63 | "csv-parse": "7.0.2", |
| 62 | "yauzl": "3.4.0" | 64 | "yauzl": "3.4.0" |
| @@ -64,15 +66,18 @@ | |||
| 64 | }, | 66 | }, |
| 65 | "packages/companion": { | 67 | "packages/companion": { |
| 66 | "name": "@lapin-fute/companion", | 68 | "name": "@lapin-fute/companion", |
| 67 | - "version": "1.0.0" | 69 | + "version": "1.0.1", |
| 70 | + "license": "MIT" | ||
| 68 | }, | 71 | }, |
| 69 | "packages/config-page": { | 72 | "packages/config-page": { |
| 70 | "name": "@lapin-fute/config-page", | 73 | "name": "@lapin-fute/config-page", |
| 71 | - "version": "1.0.0" | 74 | + "version": "1.0.1", |
| 75 | + "license": "MIT" | ||
| 72 | }, | 76 | }, |
| 73 | "packages/watch": { | 77 | "packages/watch": { |
| 74 | "name": "lapin-fute-watch", | 78 | "name": "lapin-fute-watch", |
| 75 | - "version": "1.0.0" | 79 | + "version": "1.0.1", |
| 80 | + "license": "MIT" | ||
| 76 | } | 81 | } |
| 77 | } | 82 | } |
| 78 | } | 83 | } |
modified
package.json +2 -1 | @@ -1,6 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "lapin-fute", |
| 3 | - "version": "1.0.0", | |
| 3 | + "version": "1.0.1", | |
| 4 | + "license": "MIT", | |
| 4 | 5 | "private": true, |
| 5 | 6 | "type": "module", |
| 6 | 7 | "workspaces": [ |
| @@ -1,6 +1,7 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "lapin-fute", | 2 | "name": "lapin-fute", |
| 3 | - "version": "1.0.0", | 3 | + "version": "1.0.1", |
| 4 | + "license": "MIT", | ||
| 4 | "private": true, | 5 | "private": true, |
| 5 | "type": "module", | 6 | "type": "module", |
| 6 | "workspaces": [ | 7 | "workspaces": [ |
modified
packages/catalog/package.json +2 -1 | @@ -1,6 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@lapin-fute/catalog", |
| 3 | - "version": "1.0.0", | |
| 3 | + "version": "1.0.1", | |
| 4 | + "license": "MIT", | |
| 4 | 5 | "private": true, |
| 5 | 6 | "type": "module", |
| 6 | 7 | "dependencies": { |
| @@ -1,6 +1,7 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@lapin-fute/catalog", | 2 | "name": "@lapin-fute/catalog", |
| 3 | - "version": "1.0.0", | 3 | + "version": "1.0.1", |
| 4 | + "license": "MIT", | ||
| 4 | "private": true, | 5 | "private": true, |
| 5 | "type": "module", | 6 | "type": "module", |
| 6 | "dependencies": { | 7 | "dependencies": { |
modified
packages/companion/package.json +2 -1 | @@ -1,6 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@lapin-fute/companion", |
| 3 | - "version": "1.0.0", | |
| 3 | + "version": "1.0.1", | |
| 4 | + "license": "MIT", | |
| 4 | 5 | "private": true, |
| 5 | 6 | "type": "commonjs", |
| 6 | 7 | "main": "src/index.js", |
| @@ -1,6 +1,7 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@lapin-fute/companion", | 2 | "name": "@lapin-fute/companion", |
| 3 | - "version": "1.0.0", | 3 | + "version": "1.0.1", |
| 4 | + "license": "MIT", | ||
| 4 | "private": true, | 5 | "private": true, |
| 5 | "type": "commonjs", | 6 | "type": "commonjs", |
| 6 | "main": "src/index.js", | 7 | "main": "src/index.js", |
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 1.0.0</p> | |
| 149 | + <p class="about-version">Version 1.0.1</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 1.0.0</p> | |
| 171 | + <p class="about-version">Version 1.0.1</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 1.0.0</p> | 149 | + <p class="about-version">Version 1.0.1</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 1.0.0</p> | 171 | + <p class="about-version">Version 1.0.1</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 +2 -1 | @@ -1,6 +1,7 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "@lapin-fute/config-page", |
| 3 | - "version": "1.0.0", | |
| 3 | + "version": "1.0.1", | |
| 4 | + "license": "MIT", | |
| 4 | 5 | "private": true, |
| 5 | 6 | "type": "module", |
| 6 | 7 | "scripts": { |
| @@ -1,6 +1,7 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "@lapin-fute/config-page", | 2 | "name": "@lapin-fute/config-page", |
| 3 | - "version": "1.0.0", | 3 | + "version": "1.0.1", |
| 4 | + "license": "MIT", | ||
| 4 | "private": true, | 5 | "private": true, |
| 5 | "type": "module", | 6 | "type": "module", |
| 6 | "scripts": { | 7 | "scripts": { |
modified
packages/config-page/test/config-page.test.ts +0 -1 | @@ -859,7 +859,6 @@ 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 1.0.0", | |
| 863 | 862 | "Publication director:", |
| 864 | 863 | "Directeur de la publication :", |
| 865 | 864 | "Anthodev", |
| @@ -859,7 +859,6 @@ 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 1.0.0", | ||
| 863 | "Publication director:", | 862 | "Publication director:", |
| 864 | "Directeur de la publication :", | 863 | "Directeur de la publication :", |
| 865 | "Anthodev", | 864 | "Anthodev", |
modified
packages/config-page/test/config-site-build.test.ts +0 -49 | @@ -234,52 +234,3 @@ test("configuration-site verification checks every local and served file on the | ||
| 234 | 234 | } |
| 235 | 235 | }); |
| 236 | 236 | |
| 237 | -test("Bunny releases deploy production tags while preview tags stop after quality checks", () => { | |
| 238 | - const workflow = readFileSync( | |
| 239 | - new URL("../../../.github/workflows/deploy-bunny.yml", import.meta.url), | |
| 240 | - "utf8", | |
| 241 | - ); | |
| 242 | - const rollbackWorkflow = readFileSync( | |
| 243 | - new URL("../../../.github/workflows/rollback-bunny.yml", import.meta.url), | |
| 244 | - "utf8", | |
| 245 | - ); | |
| 246 | - assert.doesNotMatch(workflow, /branches:\s*\[develop\]/u); | |
| 247 | - assert.match(workflow, /tags:\s*\["v\*", "pre-v\*"\]/u); | |
| 248 | - assert.match(workflow, /startsWith\(github\.ref_name, 'v'\)/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); | |
| 256 | - assert.match(workflow, /\["log", "--first-parent", "-z", "--format=%s%x00%h"/u); | |
| 257 | - for (const title of ["Features", "Changes", "Fixes", "CI"]) { | |
| 258 | - assert.match(workflow, new RegExp(`title: "${title}"`, "u")); | |
| 259 | - } | |
| 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); | |
| 261 | - assert.doesNotMatch(workflow, /--generate-notes/u); | |
| 262 | - assert.match(workflow, /github\.event_name == 'workflow_dispatch' && inputs\.refresh_catalog/u); | |
| 263 | - assert.match(workflow, /refresh_catalog:\s+description:[\s\S]+type: boolean/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); | |
| 270 | - assert.match( | |
| 271 | - workflow, | |
| 272 | - /if \[\[ -f var\/config-site\/catalog\/manifest\.json \]\]; then\s+upload_file/u, | |
| 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); | |
| 285 | -}); | |
| @@ -234,52 +234,3 @@ test("configuration-site verification checks every local and served file on the | |||
| 234 | } | 234 | } |
| 235 | }); | 235 | }); |
| 236 | 236 | ||
| 237 | -test("Bunny releases deploy production tags while preview tags stop after quality checks", () => { | ||
| 238 | - const workflow = readFileSync( | ||
| 239 | - new URL("../../../.github/workflows/deploy-bunny.yml", import.meta.url), | ||
| 240 | - "utf8", | ||
| 241 | - ); | ||
| 242 | - const rollbackWorkflow = readFileSync( | ||
| 243 | - new URL("../../../.github/workflows/rollback-bunny.yml", import.meta.url), | ||
| 244 | - "utf8", | ||
| 245 | - ); | ||
| 246 | - assert.doesNotMatch(workflow, /branches:\s*\[develop\]/u); | ||
| 247 | - assert.match(workflow, /tags:\s*\["v\*", "pre-v\*"\]/u); | ||
| 248 | - assert.match(workflow, /startsWith\(github\.ref_name, 'v'\)/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); | ||
| 256 | - assert.match(workflow, /\["log", "--first-parent", "-z", "--format=%s%x00%h"/u); | ||
| 257 | - for (const title of ["Features", "Changes", "Fixes", "CI"]) { | ||
| 258 | - assert.match(workflow, new RegExp(`title: "${title}"`, "u")); | ||
| 259 | - } | ||
| 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); | ||
| 261 | - assert.doesNotMatch(workflow, /--generate-notes/u); | ||
| 262 | - assert.match(workflow, /github\.event_name == 'workflow_dispatch' && inputs\.refresh_catalog/u); | ||
| 263 | - assert.match(workflow, /refresh_catalog:\s+description:[\s\S]+type: boolean/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); | ||
| 270 | - assert.match( | ||
| 271 | - workflow, | ||
| 272 | - /if \[\[ -f var\/config-site\/catalog\/manifest\.json \]\]; then\s+upload_file/u, | ||
| 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); | ||
| 285 | -}); | ||
added
packages/config-page/test/publish-pebble-store.test.js +200 -0 | new file mode 100644 | ||
| @@ -0,0 +1,200 @@ | ||
| 1 | +import test from "node:test"; | |
| 2 | +import assert from "node:assert/strict"; | |
| 3 | +import { createHash } from "node:crypto"; | |
| 4 | +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; | |
| 5 | +import { tmpdir } from "node:os"; | |
| 6 | +import { join } from "node:path"; | |
| 7 | +import { crc32 } from "node:zlib"; | |
| 8 | +import { publishPebbleStore } from "../../../scripts/publish-pebble-store.mjs"; | |
| 9 | + | |
| 10 | +const UUID = "f1e58d7b-8e42-4b21-a9bf-67c0f4b59d02"; | |
| 11 | +const APP_ID = "6d6aa01b7ecb4cfea469a183"; | |
| 12 | +const REFRESH = "private-refresh-fixture"; | |
| 13 | +const ID_TOKEN = "private.id.fixture"; | |
| 14 | +const STORE = "https://appstore-api.repebble.com"; | |
| 15 | + | |
| 16 | +// Stored ZIP entries keep these fixtures independent of external commands and build artifacts. | |
| 17 | +function archive(entries) { | |
| 18 | + const local = []; | |
| 19 | + const central = []; | |
| 20 | + let offset = 0; | |
| 21 | + for (const [name, content] of entries) { | |
| 22 | + const filename = Buffer.from(name); | |
| 23 | + const bytes = Buffer.from(content); | |
| 24 | + const header = Buffer.alloc(30); | |
| 25 | + header.writeUInt32LE(0x04034b50, 0); | |
| 26 | + header.writeUInt16LE(20, 4); | |
| 27 | + header.writeUInt32LE(crc32(bytes), 14); | |
| 28 | + header.writeUInt32LE(bytes.length, 18); | |
| 29 | + header.writeUInt32LE(bytes.length, 22); | |
| 30 | + header.writeUInt16LE(filename.length, 26); | |
| 31 | + const directory = Buffer.alloc(46); | |
| 32 | + directory.writeUInt32LE(0x02014b50, 0); | |
| 33 | + directory.writeUInt16LE(20, 4); | |
| 34 | + header.copy(directory, 6, 4, 30); | |
| 35 | + directory.writeUInt32LE(offset, 42); | |
| 36 | + local.push(header, filename, bytes); | |
| 37 | + central.push(directory, filename); | |
| 38 | + offset += header.length + filename.length + bytes.length; | |
| 39 | + } | |
| 40 | + const directory = Buffer.concat(central); | |
| 41 | + const end = Buffer.alloc(22); | |
| 42 | + end.writeUInt32LE(0x06054b50, 0); | |
| 43 | + end.writeUInt16LE(entries.length, 8); | |
| 44 | + end.writeUInt16LE(entries.length, 10); | |
| 45 | + end.writeUInt32LE(directory.length, 12); | |
| 46 | + end.writeUInt32LE(offset, 16); | |
| 47 | + return Buffer.concat([...local, directory, end]); | |
| 48 | +} | |
| 49 | + | |
| 50 | +function fixture(t, { uuid = UUID, version = "1.0.1", entries, tag = "v1.0.1", checksum } = {}) { | |
| 51 | + const root = mkdtempSync(join(tmpdir(), "lapin-fute-store-")); | |
| 52 | + t.after(() => rmSync(root, { recursive: true, force: true })); | |
| 53 | + const members = entries ?? [ | |
| 54 | + ["appinfo.json", JSON.stringify({ uuid, versionLabel: version })], | |
| 55 | + ["emery/manifest.json", "{}"], ["emery/pebble-app.bin", "emery binary"], | |
| 56 | + ["gabbro/manifest.json", "{}"], ["gabbro/pebble-app.bin", "gabbro binary"], | |
| 57 | + ]; | |
| 58 | + const bytes = archive(members); | |
| 59 | + const filename = `lapin-fute-${tag}.pbw`; | |
| 60 | + const pbwPath = join(root, filename); | |
| 61 | + const checksumsPath = join(root, "SHA256SUMS"); | |
| 62 | + const notesPath = join(root, "notes.txt"); | |
| 63 | + const notes = "# Corrections\n\nDéparts & horaires améliorés.\n"; | |
| 64 | + writeFileSync(pbwPath, bytes); | |
| 65 | + writeFileSync(checksumsPath, `${checksum ?? createHash("sha256").update(bytes).digest("hex")} ${filename}\n`); | |
| 66 | + writeFileSync(notesPath, notes); | |
| 67 | + const logs = []; | |
| 68 | + return { pbwPath, checksumsPath, notesPath, tag, refreshToken: REFRESH, bytes, notes, logs, log: (line) => logs.push(line) }; | |
| 69 | +} | |
| 70 | + | |
| 71 | +function remote(overrides = {}) { | |
| 72 | + const calls = []; | |
| 73 | + const fetcher = async (url, options) => { | |
| 74 | + calls.push({ url, ...options }); | |
| 75 | + const index = calls.length - 1; | |
| 76 | + assert.equal(options.redirect, "error"); | |
| 77 | + assert.ok(options.signal instanceof AbortSignal); | |
| 78 | + if (Object.hasOwn(overrides, index)) return overrides[index](url, options); | |
| 79 | + if (index === 0) return Response.json({ id_token: ID_TOKEN, refresh_token: REFRESH }); | |
| 80 | + if (index === 1) return Response.json({ app_lookup: { by_app_uuid: { [UUID]: APP_ID } } }); | |
| 81 | + if (index === 2 || index === 3) return Response.json({ data: [{ id: APP_ID, uuid: UUID, latest_release: { version: "1.0.0" } }] }); | |
| 82 | + if (index === 4) return Response.json({}); | |
| 83 | + assert.fail("Unexpected retry or extra request"); | |
| 84 | + }; | |
| 85 | + return { fetcher, calls }; | |
| 86 | +} | |
| 87 | + | |
| 88 | +async function rejectsSafely(options, server, expectedRequests) { | |
| 89 | + await assert.rejects(publishPebbleStore({ ...options, fetcher: server.fetcher }), (error) => { | |
| 90 | + assert.equal(error.cause, undefined); | |
| 91 | + assert.doesNotMatch(error.message, /private-refresh-fixture|private\.id\.fixture|remote-secret/u); | |
| 92 | + return true; | |
| 93 | + }); | |
| 94 | + assert.equal(server.calls.length, expectedRequests); | |
| 95 | + assert.doesNotMatch(options.logs.join("\n"), /private-refresh-fixture|private\.id\.fixture|remote-secret/u); | |
| 96 | +} | |
| 97 | + | |
| 98 | +test("local checksum, identity and tag validation fails before authentication", async (t) => { | |
| 99 | + for (const [name, changes] of [ | |
| 100 | + ["wrong checksum", { checksum: "0".repeat(64) }], | |
| 101 | + ["wrong UUID", { uuid: "another-application" }], | |
| 102 | + ["wrong archive version", { version: "1.0.2" }], | |
| 103 | + ["prerelease tag", { tag: "v1.0.1-rc.1" }], | |
| 104 | + ]) { | |
| 105 | + await t.test(name, async (t) => rejectsSafely(fixture(t, changes), remote(), 0)); | |
| 106 | + } | |
| 107 | +}); | |
| 108 | + | |
| 109 | +test("archive targets, duplicate metadata and oversized JSON fail before authentication", async (t) => { | |
| 110 | + const metadata = ["appinfo.json", JSON.stringify({ uuid: UUID, versionLabel: "1.0.1" })]; | |
| 111 | + for (const [name, entries] of [ | |
| 112 | + ["missing target", [metadata, ["emery/manifest.json", "{}"], ["emery/pebble-app.bin", "binary"]]], | |
| 113 | + ["duplicate metadata", [metadata, metadata]], | |
| 114 | + ["oversized metadata", [["appinfo.json", " ".repeat(1024 * 1024 + 1)]]], | |
| 115 | + ]) { | |
| 116 | + await t.test(name, async (t) => rejectsSafely(fixture(t, { entries }), remote(), 0)); | |
| 117 | + } | |
| 118 | +}); | |
| 119 | + | |
| 120 | +test("ambiguous checksum records fail before authentication", async (t) => { | |
| 121 | + const options = fixture(t); | |
| 122 | + const line = `${createHash("sha256").update(options.bytes).digest("hex")} lapin-fute-v1.0.1.pbw\n`; | |
| 123 | + writeFileSync(options.checksumsPath, line + line); | |
| 124 | + await rejectsSafely(options, remote(), 0); | |
| 125 | +}); | |
| 126 | + | |
| 127 | +test("an authenticated account without the exact app mapping cannot upload", async (t) => { | |
| 128 | + await rejectsSafely(fixture(t), remote({ | |
| 129 | + 1: () => Response.json({ app_lookup: { by_app_uuid: { [UUID]: "different-app" } } }), | |
| 130 | + }), 2); | |
| 131 | +}); | |
| 132 | + | |
| 133 | +test("ownership lookup rejects conflicting UUID keys after normalization", async (t) => { | |
| 134 | + await rejectsSafely(fixture(t), remote({ | |
| 135 | + 1: () => Response.json({ app_lookup: { by_app_uuid: { | |
| 136 | + [UUID]: APP_ID, | |
| 137 | + [` ${UUID.toUpperCase()} `]: "different-app", | |
| 138 | + } } }), | |
| 139 | + }), 2); | |
| 140 | +}); | |
| 141 | + | |
| 142 | +test("either hardware's published version blocks equal or older candidates", async (t) => { | |
| 143 | + for (const [name, index, version] of [["equal Emery", 2, "1.0.1"], ["newer Gabbro", 3, "1.1.0"]]) { | |
| 144 | + await t.test(name, async (t) => rejectsSafely(fixture(t), remote({ | |
| 145 | + [index]: () => Response.json({ data: [{ id: APP_ID, uuid: UUID, latest_release: { version } }] }), | |
| 146 | + }), index + 1)); | |
| 147 | + } | |
| 148 | +}); | |
| 149 | + | |
| 150 | +test("publication uploads the exact checked bytes and notes once, even if the file changes after validation", async (t) => { | |
| 151 | + const options = fixture(t, { version: "1.0.10", tag: "v1.0.10" }); | |
| 152 | + const server = remote({ | |
| 153 | + 0: () => { | |
| 154 | + writeFileSync(options.pbwPath, "replaced after local validation"); | |
| 155 | + return Response.json({ id_token: ID_TOKEN, refresh_token: "rotated-private-token" }); | |
| 156 | + }, | |
| 157 | + 1: () => Response.json({ app_lookup: { by_app_uuid: { [` ${UUID.toUpperCase()} `]: APP_ID } } }), | |
| 158 | + 2: () => Response.json({ data: [{ id: APP_ID, uuid: UUID, latest_release: { version: "1.0.9" } }] }), | |
| 159 | + }); | |
| 160 | + await publishPebbleStore({ ...options, fetcher: server.fetcher }); | |
| 161 | + assert.deepEqual(server.calls.map((call) => [call.method, call.url]), [ | |
| 162 | + ["POST", "https://securetoken.googleapis.com/v1/token?key=AIzaSyBZ9Cdvwwv9At2lPmc8TxyyEqSXGXejGvc"], | |
| 163 | + ["GET", `${STORE}/api/v1/developer/me`], | |
| 164 | + ["GET", `${STORE}/api/v1/apps/id/${APP_ID}?hardware=emery`], | |
| 165 | + ["GET", `${STORE}/api/v1/apps/id/${APP_ID}?hardware=gabbro`], | |
| 166 | + ["POST", `${STORE}/api/dashboard/apps/${APP_ID}/releases`], | |
| 167 | + ]); | |
| 168 | + assert.deepEqual([...server.calls[0].body], [["grant_type", "refresh_token"], ["refresh_token", REFRESH]]); | |
| 169 | + assert.equal(server.calls[1].headers.Authorization, `Bearer ${ID_TOKEN}`); | |
| 170 | + assert.equal(server.calls[2].headers, undefined); | |
| 171 | + assert.equal(server.calls[3].headers, undefined); | |
| 172 | + const upload = server.calls[4]; | |
| 173 | + assert.equal(upload.headers.Authorization, `Bearer ${ID_TOKEN}`); | |
| 174 | + assert.deepEqual([...upload.body.keys()].sort(), ["isPublished", "pbwFile", "releaseNotes", "replaceScreenshots", "version"]); | |
| 175 | + const pbw = upload.body.get("pbwFile"); | |
| 176 | + assert.equal(pbw.name, "lapin-fute-v1.0.10.pbw"); | |
| 177 | + assert.deepEqual(Buffer.from(await pbw.arrayBuffer()), options.bytes); | |
| 178 | + assert.equal(upload.body.get("releaseNotes"), options.notes); | |
| 179 | + assert.equal(upload.body.get("version"), "1.0.10"); | |
| 180 | + assert.equal(upload.body.get("isPublished"), "true"); | |
| 181 | + assert.equal(upload.body.get("replaceScreenshots"), "false"); | |
| 182 | + assert.ok(options.logs.some((line) => line.includes("secret was not updated"))); | |
| 183 | + assert.doesNotMatch(options.logs.join("\n"), /private-refresh-fixture|private\.id\.fixture|rotated-private-token/u); | |
| 184 | +}); | |
| 185 | + | |
| 186 | +test("remote failure, redirect and malformed data never expose remote content or retry", async (t) => { | |
| 187 | + for (const [name, index, response] of [ | |
| 188 | + ["revoked session", 0, () => new Response(`remote-secret ${REFRESH}`, { status: 400 })], | |
| 189 | + ["invalid token", 0, () => Response.json({ id_token: "remote-secret\r\nInjected: true" })], | |
| 190 | + ["malformed JSON", 1, () => new Response(`remote-secret ${ID_TOKEN}`)], | |
| 191 | + ["oversized JSON", 1, () => Response.json({ ignored: "x".repeat(1024 * 1024) })], | |
| 192 | + ["redirect", 1, () => new Response(null, { status: 302, headers: { location: "https://untrusted.example/" } })], | |
| 193 | + ["wrong public identity", 2, () => Response.json({ data: [{ id: APP_ID, uuid: "wrong", latest_release: { version: "1.0.0" } }] })], | |
| 194 | + ["unknown published version", 3, () => Response.json({ data: [{ id: APP_ID, uuid: UUID, latest_release: { version: "unknown" } }] })], | |
| 195 | + ["upload HTTP error", 4, () => new Response(`remote-secret ${ID_TOKEN}`, { status: 500 })], | |
| 196 | + ["uncertain upload timeout", 4, () => { throw new Error(`remote-secret ${REFRESH} ${ID_TOKEN}`); }], | |
| 197 | + ]) { | |
| 198 | + await t.test(name, async (t) => rejectsSafely(fixture(t), remote({ [index]: response }), index + 1)); | |
| 199 | + } | |
| 200 | +}); | |
| new file mode 100644 | |||
| @@ -0,0 +1,200 @@ | |||
| 1 | +import test from "node:test"; | ||
| 2 | +import assert from "node:assert/strict"; | ||
| 3 | +import { createHash } from "node:crypto"; | ||
| 4 | +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; | ||
| 5 | +import { tmpdir } from "node:os"; | ||
| 6 | +import { join } from "node:path"; | ||
| 7 | +import { crc32 } from "node:zlib"; | ||
| 8 | +import { publishPebbleStore } from "../../../scripts/publish-pebble-store.mjs"; | ||
| 9 | + | ||
| 10 | +const UUID = "f1e58d7b-8e42-4b21-a9bf-67c0f4b59d02"; | ||
| 11 | +const APP_ID = "6d6aa01b7ecb4cfea469a183"; | ||
| 12 | +const REFRESH = "private-refresh-fixture"; | ||
| 13 | +const ID_TOKEN = "private.id.fixture"; | ||
| 14 | +const STORE = "https://appstore-api.repebble.com"; | ||
| 15 | + | ||
| 16 | +// Stored ZIP entries keep these fixtures independent of external commands and build artifacts. | ||
| 17 | +function archive(entries) { | ||
| 18 | + const local = []; | ||
| 19 | + const central = []; | ||
| 20 | + let offset = 0; | ||
| 21 | + for (const [name, content] of entries) { | ||
| 22 | + const filename = Buffer.from(name); | ||
| 23 | + const bytes = Buffer.from(content); | ||
| 24 | + const header = Buffer.alloc(30); | ||
| 25 | + header.writeUInt32LE(0x04034b50, 0); | ||
| 26 | + header.writeUInt16LE(20, 4); | ||
| 27 | + header.writeUInt32LE(crc32(bytes), 14); | ||
| 28 | + header.writeUInt32LE(bytes.length, 18); | ||
| 29 | + header.writeUInt32LE(bytes.length, 22); | ||
| 30 | + header.writeUInt16LE(filename.length, 26); | ||
| 31 | + const directory = Buffer.alloc(46); | ||
| 32 | + directory.writeUInt32LE(0x02014b50, 0); | ||
| 33 | + directory.writeUInt16LE(20, 4); | ||
| 34 | + header.copy(directory, 6, 4, 30); | ||
| 35 | + directory.writeUInt32LE(offset, 42); | ||
| 36 | + local.push(header, filename, bytes); | ||
| 37 | + central.push(directory, filename); | ||
| 38 | + offset += header.length + filename.length + bytes.length; | ||
| 39 | + } | ||
| 40 | + const directory = Buffer.concat(central); | ||
| 41 | + const end = Buffer.alloc(22); | ||
| 42 | + end.writeUInt32LE(0x06054b50, 0); | ||
| 43 | + end.writeUInt16LE(entries.length, 8); | ||
| 44 | + end.writeUInt16LE(entries.length, 10); | ||
| 45 | + end.writeUInt32LE(directory.length, 12); | ||
| 46 | + end.writeUInt32LE(offset, 16); | ||
| 47 | + return Buffer.concat([...local, directory, end]); | ||
| 48 | +} | ||
| 49 | + | ||
| 50 | +function fixture(t, { uuid = UUID, version = "1.0.1", entries, tag = "v1.0.1", checksum } = {}) { | ||
| 51 | + const root = mkdtempSync(join(tmpdir(), "lapin-fute-store-")); | ||
| 52 | + t.after(() => rmSync(root, { recursive: true, force: true })); | ||
| 53 | + const members = entries ?? [ | ||
| 54 | + ["appinfo.json", JSON.stringify({ uuid, versionLabel: version })], | ||
| 55 | + ["emery/manifest.json", "{}"], ["emery/pebble-app.bin", "emery binary"], | ||
| 56 | + ["gabbro/manifest.json", "{}"], ["gabbro/pebble-app.bin", "gabbro binary"], | ||
| 57 | + ]; | ||
| 58 | + const bytes = archive(members); | ||
| 59 | + const filename = `lapin-fute-${tag}.pbw`; | ||
| 60 | + const pbwPath = join(root, filename); | ||
| 61 | + const checksumsPath = join(root, "SHA256SUMS"); | ||
| 62 | + const notesPath = join(root, "notes.txt"); | ||
| 63 | + const notes = "# Corrections\n\nDéparts & horaires améliorés.\n"; | ||
| 64 | + writeFileSync(pbwPath, bytes); | ||
| 65 | + writeFileSync(checksumsPath, `${checksum ?? createHash("sha256").update(bytes).digest("hex")} ${filename}\n`); | ||
| 66 | + writeFileSync(notesPath, notes); | ||
| 67 | + const logs = []; | ||
| 68 | + return { pbwPath, checksumsPath, notesPath, tag, refreshToken: REFRESH, bytes, notes, logs, log: (line) => logs.push(line) }; | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +function remote(overrides = {}) { | ||
| 72 | + const calls = []; | ||
| 73 | + const fetcher = async (url, options) => { | ||
| 74 | + calls.push({ url, ...options }); | ||
| 75 | + const index = calls.length - 1; | ||
| 76 | + assert.equal(options.redirect, "error"); | ||
| 77 | + assert.ok(options.signal instanceof AbortSignal); | ||
| 78 | + if (Object.hasOwn(overrides, index)) return overrides[index](url, options); | ||
| 79 | + if (index === 0) return Response.json({ id_token: ID_TOKEN, refresh_token: REFRESH }); | ||
| 80 | + if (index === 1) return Response.json({ app_lookup: { by_app_uuid: { [UUID]: APP_ID } } }); | ||
| 81 | + if (index === 2 || index === 3) return Response.json({ data: [{ id: APP_ID, uuid: UUID, latest_release: { version: "1.0.0" } }] }); | ||
| 82 | + if (index === 4) return Response.json({}); | ||
| 83 | + assert.fail("Unexpected retry or extra request"); | ||
| 84 | + }; | ||
| 85 | + return { fetcher, calls }; | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +async function rejectsSafely(options, server, expectedRequests) { | ||
| 89 | + await assert.rejects(publishPebbleStore({ ...options, fetcher: server.fetcher }), (error) => { | ||
| 90 | + assert.equal(error.cause, undefined); | ||
| 91 | + assert.doesNotMatch(error.message, /private-refresh-fixture|private\.id\.fixture|remote-secret/u); | ||
| 92 | + return true; | ||
| 93 | + }); | ||
| 94 | + assert.equal(server.calls.length, expectedRequests); | ||
| 95 | + assert.doesNotMatch(options.logs.join("\n"), /private-refresh-fixture|private\.id\.fixture|remote-secret/u); | ||
| 96 | +} | ||
| 97 | + | ||
| 98 | +test("local checksum, identity and tag validation fails before authentication", async (t) => { | ||
| 99 | + for (const [name, changes] of [ | ||
| 100 | + ["wrong checksum", { checksum: "0".repeat(64) }], | ||
| 101 | + ["wrong UUID", { uuid: "another-application" }], | ||
| 102 | + ["wrong archive version", { version: "1.0.2" }], | ||
| 103 | + ["prerelease tag", { tag: "v1.0.1-rc.1" }], | ||
| 104 | + ]) { | ||
| 105 | + await t.test(name, async (t) => rejectsSafely(fixture(t, changes), remote(), 0)); | ||
| 106 | + } | ||
| 107 | +}); | ||
| 108 | + | ||
| 109 | +test("archive targets, duplicate metadata and oversized JSON fail before authentication", async (t) => { | ||
| 110 | + const metadata = ["appinfo.json", JSON.stringify({ uuid: UUID, versionLabel: "1.0.1" })]; | ||
| 111 | + for (const [name, entries] of [ | ||
| 112 | + ["missing target", [metadata, ["emery/manifest.json", "{}"], ["emery/pebble-app.bin", "binary"]]], | ||
| 113 | + ["duplicate metadata", [metadata, metadata]], | ||
| 114 | + ["oversized metadata", [["appinfo.json", " ".repeat(1024 * 1024 + 1)]]], | ||
| 115 | + ]) { | ||
| 116 | + await t.test(name, async (t) => rejectsSafely(fixture(t, { entries }), remote(), 0)); | ||
| 117 | + } | ||
| 118 | +}); | ||
| 119 | + | ||
| 120 | +test("ambiguous checksum records fail before authentication", async (t) => { | ||
| 121 | + const options = fixture(t); | ||
| 122 | + const line = `${createHash("sha256").update(options.bytes).digest("hex")} lapin-fute-v1.0.1.pbw\n`; | ||
| 123 | + writeFileSync(options.checksumsPath, line + line); | ||
| 124 | + await rejectsSafely(options, remote(), 0); | ||
| 125 | +}); | ||
| 126 | + | ||
| 127 | +test("an authenticated account without the exact app mapping cannot upload", async (t) => { | ||
| 128 | + await rejectsSafely(fixture(t), remote({ | ||
| 129 | + 1: () => Response.json({ app_lookup: { by_app_uuid: { [UUID]: "different-app" } } }), | ||
| 130 | + }), 2); | ||
| 131 | +}); | ||
| 132 | + | ||
| 133 | +test("ownership lookup rejects conflicting UUID keys after normalization", async (t) => { | ||
| 134 | + await rejectsSafely(fixture(t), remote({ | ||
| 135 | + 1: () => Response.json({ app_lookup: { by_app_uuid: { | ||
| 136 | + [UUID]: APP_ID, | ||
| 137 | + [` ${UUID.toUpperCase()} `]: "different-app", | ||
| 138 | + } } }), | ||
| 139 | + }), 2); | ||
| 140 | +}); | ||
| 141 | + | ||
| 142 | +test("either hardware's published version blocks equal or older candidates", async (t) => { | ||
| 143 | + for (const [name, index, version] of [["equal Emery", 2, "1.0.1"], ["newer Gabbro", 3, "1.1.0"]]) { | ||
| 144 | + await t.test(name, async (t) => rejectsSafely(fixture(t), remote({ | ||
| 145 | + [index]: () => Response.json({ data: [{ id: APP_ID, uuid: UUID, latest_release: { version } }] }), | ||
| 146 | + }), index + 1)); | ||
| 147 | + } | ||
| 148 | +}); | ||
| 149 | + | ||
| 150 | +test("publication uploads the exact checked bytes and notes once, even if the file changes after validation", async (t) => { | ||
| 151 | + const options = fixture(t, { version: "1.0.10", tag: "v1.0.10" }); | ||
| 152 | + const server = remote({ | ||
| 153 | + 0: () => { | ||
| 154 | + writeFileSync(options.pbwPath, "replaced after local validation"); | ||
| 155 | + return Response.json({ id_token: ID_TOKEN, refresh_token: "rotated-private-token" }); | ||
| 156 | + }, | ||
| 157 | + 1: () => Response.json({ app_lookup: { by_app_uuid: { [` ${UUID.toUpperCase()} `]: APP_ID } } }), | ||
| 158 | + 2: () => Response.json({ data: [{ id: APP_ID, uuid: UUID, latest_release: { version: "1.0.9" } }] }), | ||
| 159 | + }); | ||
| 160 | + await publishPebbleStore({ ...options, fetcher: server.fetcher }); | ||
| 161 | + assert.deepEqual(server.calls.map((call) => [call.method, call.url]), [ | ||
| 162 | + ["POST", "https://securetoken.googleapis.com/v1/token?key=AIzaSyBZ9Cdvwwv9At2lPmc8TxyyEqSXGXejGvc"], | ||
| 163 | + ["GET", `${STORE}/api/v1/developer/me`], | ||
| 164 | + ["GET", `${STORE}/api/v1/apps/id/${APP_ID}?hardware=emery`], | ||
| 165 | + ["GET", `${STORE}/api/v1/apps/id/${APP_ID}?hardware=gabbro`], | ||
| 166 | + ["POST", `${STORE}/api/dashboard/apps/${APP_ID}/releases`], | ||
| 167 | + ]); | ||
| 168 | + assert.deepEqual([...server.calls[0].body], [["grant_type", "refresh_token"], ["refresh_token", REFRESH]]); | ||
| 169 | + assert.equal(server.calls[1].headers.Authorization, `Bearer ${ID_TOKEN}`); | ||
| 170 | + assert.equal(server.calls[2].headers, undefined); | ||
| 171 | + assert.equal(server.calls[3].headers, undefined); | ||
| 172 | + const upload = server.calls[4]; | ||
| 173 | + assert.equal(upload.headers.Authorization, `Bearer ${ID_TOKEN}`); | ||
| 174 | + assert.deepEqual([...upload.body.keys()].sort(), ["isPublished", "pbwFile", "releaseNotes", "replaceScreenshots", "version"]); | ||
| 175 | + const pbw = upload.body.get("pbwFile"); | ||
| 176 | + assert.equal(pbw.name, "lapin-fute-v1.0.10.pbw"); | ||
| 177 | + assert.deepEqual(Buffer.from(await pbw.arrayBuffer()), options.bytes); | ||
| 178 | + assert.equal(upload.body.get("releaseNotes"), options.notes); | ||
| 179 | + assert.equal(upload.body.get("version"), "1.0.10"); | ||
| 180 | + assert.equal(upload.body.get("isPublished"), "true"); | ||
| 181 | + assert.equal(upload.body.get("replaceScreenshots"), "false"); | ||
| 182 | + assert.ok(options.logs.some((line) => line.includes("secret was not updated"))); | ||
| 183 | + assert.doesNotMatch(options.logs.join("\n"), /private-refresh-fixture|private\.id\.fixture|rotated-private-token/u); | ||
| 184 | +}); | ||
| 185 | + | ||
| 186 | +test("remote failure, redirect and malformed data never expose remote content or retry", async (t) => { | ||
| 187 | + for (const [name, index, response] of [ | ||
| 188 | + ["revoked session", 0, () => new Response(`remote-secret ${REFRESH}`, { status: 400 })], | ||
| 189 | + ["invalid token", 0, () => Response.json({ id_token: "remote-secret\r\nInjected: true" })], | ||
| 190 | + ["malformed JSON", 1, () => new Response(`remote-secret ${ID_TOKEN}`)], | ||
| 191 | + ["oversized JSON", 1, () => Response.json({ ignored: "x".repeat(1024 * 1024) })], | ||
| 192 | + ["redirect", 1, () => new Response(null, { status: 302, headers: { location: "https://untrusted.example/" } })], | ||
| 193 | + ["wrong public identity", 2, () => Response.json({ data: [{ id: APP_ID, uuid: "wrong", latest_release: { version: "1.0.0" } }] })], | ||
| 194 | + ["unknown published version", 3, () => Response.json({ data: [{ id: APP_ID, uuid: UUID, latest_release: { version: "unknown" } }] })], | ||
| 195 | + ["upload HTTP error", 4, () => new Response(`remote-secret ${ID_TOKEN}`, { status: 500 })], | ||
| 196 | + ["uncertain upload timeout", 4, () => { throw new Error(`remote-secret ${REFRESH} ${ID_TOKEN}`); }], | ||
| 197 | + ]) { | ||
| 198 | + await t.test(name, async (t) => rejectsSafely(fixture(t), remote({ [index]: response }), index + 1)); | ||
| 199 | + } | ||
| 200 | +}); | ||
modified
packages/watch/package.json +2 -1 | @@ -1,7 +1,8 @@ | ||
| 1 | 1 | { |
| 2 | 2 | "name": "lapin-fute-watch", |
| 3 | 3 | "author": "Anthodev", |
| 4 | - "version": "1.0.0", | |
| 4 | + "version": "1.0.1", | |
| 5 | + "license": "MIT", | |
| 5 | 6 | "keywords": [ |
| 6 | 7 | "pebble-app" |
| 7 | 8 | ], |
| @@ -1,7 +1,8 @@ | |||
| 1 | { | 1 | { |
| 2 | "name": "lapin-fute-watch", | 2 | "name": "lapin-fute-watch", |
| 3 | "author": "Anthodev", | 3 | "author": "Anthodev", |
| 4 | - "version": "1.0.0", | 4 | + "version": "1.0.1", |
| 5 | + "license": "MIT", | ||
| 5 | "keywords": [ | 6 | "keywords": [ |
| 6 | "pebble-app" | 7 | "pebble-app" |
| 7 | ], | 8 | ], |
modified
packages/watch/src/embeddedjs/ui.js +3 -3 | @@ -143,12 +143,12 @@ function overview(port,r,now) { | ||
| 143 | 143 | } |
| 144 | 144 | function departures(port,r,now) { |
| 145 | 145 | const round=r.profile===1, record=r.records[r.active], data=source(r), count=data?hex(data,22,1):0; |
| 146 | - let x=round?46:8,y=round?52:34,width=round?168:184,height=round?40:35; | |
| 146 | + let x=round?46:8,y=round?52:31,width=round?168:184,height=round?40:44; | |
| 147 | 147 | chip(port,record,2,x,y+6,44,27); |
| 148 | 148 | draw(port,clipped(record,8),1,INK,x+52,y,width-52,20,-1); |
| 149 | 149 | draw(port,">",1,MUTED,x+52,y+20,10,18,-1); |
| 150 | 150 | draw(port,clipped(record,11),1,MUTED,x+62,y+20,width-62,18,-1); |
| 151 | - port.fillColor(RULE,x,y+height-1,width,1); | |
| 151 | + port.fillColor(RULE,round?46:0,y+height-1,round?168:200,1); | |
| 152 | 152 | x=round?42:8;y=round?92:70;width=round?176:184;height=round?68:66; |
| 153 | 153 | if (!count) { |
| 154 | 154 | const text=copy(r.profile,r.language,11,5); |
| @@ -157,7 +157,7 @@ function departures(port,r,now) { | ||
| 157 | 157 | const status=hex(data,31,1), value=countdown(r,data,0,now,true), unit=status!==2 && Math.ceil((hex(data,23,8)*1000-now)/60000)>0; |
| 158 | 158 | const style=value==="-"?2:3, unitWidth=unit?Math.ceil(port.measureString("min",fonts[2]).width):0; |
| 159 | 159 | const gap=unit?4:0, valueWidth=Math.min(width-unitWidth-gap,Math.ceil(port.measureString(value,fonts[style]).width)); |
| 160 | - const left=x+Math.floor((width-valueWidth-unitWidth-gap)/2), bottom=y+height-(round?17:14); | |
| 160 | + const left=x+Math.floor((width-valueWidth-unitWidth-gap)/2), bottom=y+height-(status?(round?17:15):(round?10:5)); | |
| 161 | 161 | drawBottom(port,value,style,INK,left,bottom,valueWidth); |
| 162 | 162 | if(unit)drawBottom(port,"min",2,INK,left+valueWidth+gap,bottom,unitWidth); |
| 163 | 163 | if(status)draw(port,copy(r.profile,r.language,13+status),1,INK,x,y+height-19,width,18); |
| @@ -143,12 +143,12 @@ function overview(port,r,now) { | |||
| 143 | } | 143 | } |
| 144 | function departures(port,r,now) { | 144 | function departures(port,r,now) { |
| 145 | const round=r.profile===1, record=r.records[r.active], data=source(r), count=data?hex(data,22,1):0; | 145 | const round=r.profile===1, record=r.records[r.active], data=source(r), count=data?hex(data,22,1):0; |
| 146 | - let x=round?46:8,y=round?52:34,width=round?168:184,height=round?40:35; | 146 | + let x=round?46:8,y=round?52:31,width=round?168:184,height=round?40:44; |
| 147 | chip(port,record,2,x,y+6,44,27); | 147 | chip(port,record,2,x,y+6,44,27); |
| 148 | draw(port,clipped(record,8),1,INK,x+52,y,width-52,20,-1); | 148 | draw(port,clipped(record,8),1,INK,x+52,y,width-52,20,-1); |
| 149 | draw(port,">",1,MUTED,x+52,y+20,10,18,-1); | 149 | draw(port,">",1,MUTED,x+52,y+20,10,18,-1); |
| 150 | draw(port,clipped(record,11),1,MUTED,x+62,y+20,width-62,18,-1); | 150 | draw(port,clipped(record,11),1,MUTED,x+62,y+20,width-62,18,-1); |
| 151 | - port.fillColor(RULE,x,y+height-1,width,1); | 151 | + port.fillColor(RULE,round?46:0,y+height-1,round?168:200,1); |
| 152 | x=round?42:8;y=round?92:70;width=round?176:184;height=round?68:66; | 152 | x=round?42:8;y=round?92:70;width=round?176:184;height=round?68:66; |
| 153 | if (!count) { | 153 | if (!count) { |
| 154 | const text=copy(r.profile,r.language,11,5); | 154 | const text=copy(r.profile,r.language,11,5); |
| @@ -157,7 +157,7 @@ function departures(port,r,now) { | |||
| 157 | const status=hex(data,31,1), value=countdown(r,data,0,now,true), unit=status!==2 && Math.ceil((hex(data,23,8)*1000-now)/60000)>0; | 157 | const status=hex(data,31,1), value=countdown(r,data,0,now,true), unit=status!==2 && Math.ceil((hex(data,23,8)*1000-now)/60000)>0; |
| 158 | const style=value==="-"?2:3, unitWidth=unit?Math.ceil(port.measureString("min",fonts[2]).width):0; | 158 | const style=value==="-"?2:3, unitWidth=unit?Math.ceil(port.measureString("min",fonts[2]).width):0; |
| 159 | const gap=unit?4:0, valueWidth=Math.min(width-unitWidth-gap,Math.ceil(port.measureString(value,fonts[style]).width)); | 159 | const gap=unit?4:0, valueWidth=Math.min(width-unitWidth-gap,Math.ceil(port.measureString(value,fonts[style]).width)); |
| 160 | - const left=x+Math.floor((width-valueWidth-unitWidth-gap)/2), bottom=y+height-(round?17:14); | 160 | + const left=x+Math.floor((width-valueWidth-unitWidth-gap)/2), bottom=y+height-(status?(round?17:15):(round?10:5)); |
| 161 | drawBottom(port,value,style,INK,left,bottom,valueWidth); | 161 | drawBottom(port,value,style,INK,left,bottom,valueWidth); |
| 162 | if(unit)drawBottom(port,"min",2,INK,left+valueWidth+gap,bottom,unitWidth); | 162 | if(unit)drawBottom(port,"min",2,INK,left+valueWidth+gap,bottom,unitWidth); |
| 163 | if(status)draw(port,copy(r.profile,r.language,13+status),1,INK,x,y+height-19,width,18); | 163 | if(status)draw(port,copy(r.profile,r.language,13+status),1,INK,x,y+height-19,width,18); |
modified
packages/watch/test/ui.test.js +0 -1 | @@ -125,7 +125,6 @@ for (const profile of [0, 1]) { | ||
| 125 | 125 | assert.equal(value.font, "bold 36px Gothic"); |
| 126 | 126 | assert.equal(unit.font, "bold 18px Gothic"); |
| 127 | 127 | assert.equal(value.y, unit.y); |
| 128 | - assert.equal(value.y, profile === 1 ? 129 : 108); | |
| 129 | 128 | }); |
| 130 | 129 | } |
| 131 | 130 | |
| @@ -125,7 +125,6 @@ for (const profile of [0, 1]) { | |||
| 125 | assert.equal(value.font, "bold 36px Gothic"); | 125 | assert.equal(value.font, "bold 36px Gothic"); |
| 126 | assert.equal(unit.font, "bold 18px Gothic"); | 126 | assert.equal(unit.font, "bold 18px Gothic"); |
| 127 | assert.equal(value.y, unit.y); | 127 | assert.equal(value.y, unit.y); |
| 128 | - assert.equal(value.y, profile === 1 ? 129 : 108); | ||
| 129 | }); | 128 | }); |
| 130 | } | 129 | } |
| 131 | 130 | ||
added
scripts/publish-pebble-store.mjs +197 -0 | new file mode 100644 | ||
| @@ -0,0 +1,197 @@ | ||
| 1 | +import { createHash } from "node:crypto"; | |
| 2 | +import { createReadStream } from "node:fs"; | |
| 3 | +import { basename } from "node:path"; | |
| 4 | +import { fileURLToPath } from "node:url"; | |
| 5 | +import { parseArgs } from "node:util"; | |
| 6 | +import yauzl from "yauzl"; | |
| 7 | + | |
| 8 | +// Dashboard upload contract follows pebble-tool 5.0.40; it is not a public API guarantee. | |
| 9 | +// https://github.com/coredevices/pebble-tool/blob/fa7a5c4ba102e27194e6c76e36e0ceeadab0280b/pebble_tool/commands/publish.py | |
| 10 | +const APP_ID = "6d6aa01b7ecb4cfea469a183"; | |
| 11 | +const APP_UUID = "f1e58d7b-8e42-4b21-a9bf-67c0f4b59d02"; | |
| 12 | +const STORE = "https://appstore-api.repebble.com"; | |
| 13 | +// Public Firebase project configuration from pebble-tool 5.0.40, not a credential. | |
| 14 | +const TOKEN_URL = "https://securetoken.googleapis.com/v1/token?key=AIzaSyBZ9Cdvwwv9At2lPmc8TxyyEqSXGXejGvc"; | |
| 15 | +const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u; | |
| 16 | +const MAX_JSON = 1024 * 1024; | |
| 17 | + | |
| 18 | +async function readBounded(stream, limit) { | |
| 19 | + const chunks = []; | |
| 20 | + let size = 0; | |
| 21 | + for await (const chunk of stream) { | |
| 22 | + size += chunk.length; | |
| 23 | + if (size > limit) throw new Error("Size limit exceeded"); | |
| 24 | + chunks.push(chunk); | |
| 25 | + } | |
| 26 | + return Buffer.concat(chunks, size); | |
| 27 | +} | |
| 28 | + | |
| 29 | +function versionParts(value) { | |
| 30 | + if (typeof value !== "string" || value.length > 64 || value.trim() !== value || !VERSION.test(value)) { | |
| 31 | + throw new Error("Invalid version"); | |
| 32 | + } | |
| 33 | + return value.split(".").map(BigInt); | |
| 34 | +} | |
| 35 | + | |
| 36 | +function newerThan(candidate, published) { | |
| 37 | + const previous = versionParts(published); | |
| 38 | + for (let index = 0; index < 3; index += 1) { | |
| 39 | + if (candidate[index] !== previous[index]) return candidate[index] > previous[index]; | |
| 40 | + } | |
| 41 | + return false; | |
| 42 | +} | |
| 43 | + | |
| 44 | +async function readAppInfo(bytes) { | |
| 45 | + const zip = await new Promise((resolve, reject) => { | |
| 46 | + yauzl.fromBuffer(bytes, { lazyEntries: true, strictFileNames: true }, (error, archive) => { | |
| 47 | + if (error) reject(error); | |
| 48 | + else resolve(archive); | |
| 49 | + }); | |
| 50 | + }); | |
| 51 | + return new Promise((resolve, reject) => { | |
| 52 | + const members = new Map(); | |
| 53 | + let appInfo; | |
| 54 | + const fail = (error) => { | |
| 55 | + zip.close(); | |
| 56 | + reject(error); | |
| 57 | + }; | |
| 58 | + zip.on("error", fail); | |
| 59 | + zip.on("entry", async (entry) => { | |
| 60 | + try { | |
| 61 | + if (members.has(entry.fileName) || members.size >= 4096) throw new Error("Invalid archive entries"); | |
| 62 | + members.set(entry.fileName, entry.uncompressedSize); | |
| 63 | + if (entry.fileName === "appinfo.json" || /^(emery|gabbro)\/manifest\.json$/u.test(entry.fileName)) { | |
| 64 | + if (entry.uncompressedSize > MAX_JSON) throw new Error("Archive metadata too large"); | |
| 65 | + const stream = await new Promise((resolveStream, rejectStream) => { | |
| 66 | + zip.openReadStream(entry, (error, value) => error ? rejectStream(error) : resolveStream(value)); | |
| 67 | + }); | |
| 68 | + const metadata = JSON.parse((await readBounded(stream, MAX_JSON)).toString("utf8")); | |
| 69 | + if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) { | |
| 70 | + throw new Error("Invalid archive metadata"); | |
| 71 | + } | |
| 72 | + if (entry.fileName === "appinfo.json") appInfo = metadata; | |
| 73 | + } | |
| 74 | + zip.readEntry(); | |
| 75 | + } catch (error) { | |
| 76 | + fail(error); | |
| 77 | + } | |
| 78 | + }); | |
| 79 | + zip.on("end", () => { | |
| 80 | + zip.close(); | |
| 81 | + if (!appInfo || ["emery", "gabbro"].some((hardware) => | |
| 82 | + !members.get(`${hardware}/manifest.json`) || !members.get(`${hardware}/pebble-app.bin`))) { | |
| 83 | + reject(new Error("Missing archive metadata or target")); | |
| 84 | + } else resolve(appInfo); | |
| 85 | + }); | |
| 86 | + zip.readEntry(); | |
| 87 | + }); | |
| 88 | +} | |
| 89 | + | |
| 90 | +// All diagnostics are fixed strings. Never attach remote exceptions or response bodies. | |
| 91 | +export async function publishPebbleStore({ | |
| 92 | + pbwPath, checksumsPath, tag, notesPath, refreshToken, fetcher = fetch, log = console.log, | |
| 93 | +}) { | |
| 94 | + let stage = "Local release validation failed; no Store request was made."; | |
| 95 | + try { | |
| 96 | + if (typeof tag !== "string" || !tag.startsWith("v")) throw new Error("Invalid tag"); | |
| 97 | + const version = tag.slice(1); | |
| 98 | + const candidate = versionParts(version); | |
| 99 | + const filename = `lapin-fute-${tag}.pbw`; | |
| 100 | + if (basename(pbwPath) !== filename) throw new Error("Unexpected artifact name"); | |
| 101 | + const bytes = await readBounded(createReadStream(pbwPath), 32 * 1024 * 1024); | |
| 102 | + const checksums = (await readBounded(createReadStream(checksumsPath), MAX_JSON)).toString("utf8"); | |
| 103 | + const notes = (await readBounded(createReadStream(notesPath), MAX_JSON)).toString("utf8"); | |
| 104 | + const matching = checksums.split(/\r?\n/u).filter((line) => line.endsWith(` ${filename}`) || line.endsWith(` *${filename}`)); | |
| 105 | + if (matching.length !== 1 || !/^[a-fA-F0-9]{64} [ *]/u.test(matching[0]) || | |
| 106 | + matching[0].slice(66) !== filename || | |
| 107 | + matching[0].slice(0, 64).toLowerCase() !== createHash("sha256").update(bytes).digest("hex")) { | |
| 108 | + throw new Error("Checksum mismatch"); | |
| 109 | + } | |
| 110 | + const appInfo = await readAppInfo(bytes); | |
| 111 | + if (appInfo.uuid !== APP_UUID || appInfo.versionLabel !== version) throw new Error("Archive identity mismatch"); | |
| 112 | + if (typeof refreshToken !== "string" || refreshToken.length === 0 || refreshToken.length > 16384 || /\s/u.test(refreshToken)) { | |
| 113 | + stage = "PEBBLE_FIREBASE_REFRESH_TOKEN is missing or invalid; no Store request was made."; | |
| 114 | + throw new Error("Invalid refresh token"); | |
| 115 | + } | |
| 116 | + | |
| 117 | + // The timeout remains active while reading the response body. No redirects or retries. | |
| 118 | + async function request(url, options, timeout, json = true) { | |
| 119 | + const response = await fetcher(url, { ...options, redirect: "error", signal: AbortSignal.timeout(timeout) }); | |
| 120 | + if (response.redirected || response.status < 200 || response.status >= 300) { | |
| 121 | + await response.body?.cancel(); | |
| 122 | + throw new Error("Remote request failed"); | |
| 123 | + } | |
| 124 | + if (!json) { | |
| 125 | + await response.body?.cancel(); | |
| 126 | + return; | |
| 127 | + } | |
| 128 | + const payload = JSON.parse((await readBounded(response.body, MAX_JSON)).toString("utf8")); | |
| 129 | + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) throw new Error("Invalid response"); | |
| 130 | + return payload; | |
| 131 | + } | |
| 132 | + | |
| 133 | + stage = "Firebase session refresh failed. Renew the pebble login session and replace the environment secret if revoked."; | |
| 134 | + const token = await request(TOKEN_URL, { | |
| 135 | + method: "POST", | |
| 136 | + body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken }), | |
| 137 | + }, 20000); | |
| 138 | + if (typeof token.id_token !== "string" || token.id_token.length === 0 || token.id_token.length > 16384 || | |
| 139 | + !/^[A-Za-z0-9._~-]+$/u.test(token.id_token)) throw new Error("Invalid ID token"); | |
| 140 | + if (token.refresh_token !== undefined && (typeof token.refresh_token !== "string" || !token.refresh_token)) { | |
| 141 | + throw new Error("Invalid refreshed session"); | |
| 142 | + } | |
| 143 | + if (token.refresh_token !== undefined && token.refresh_token !== refreshToken) { | |
| 144 | + log("Firebase returned a changed refresh token. The environment secret was not updated; renew pebble login and replace that secret before the next publication."); | |
| 145 | + } | |
| 146 | + const headers = { Authorization: `Bearer ${token.id_token}` }; | |
| 147 | + stage = "Store account verification failed; no release upload was attempted."; | |
| 148 | + const owner = await request(`${STORE}/api/v1/developer/me`, { method: "GET", headers }, 60000); | |
| 149 | + const lookup = owner.app_lookup?.by_app_uuid; | |
| 150 | + if (lookup === null || typeof lookup !== "object" || Array.isArray(lookup)) throw new Error("Invalid app lookup"); | |
| 151 | + const matches = Object.entries(lookup).filter(([uuid]) => uuid.trim().toLowerCase() === APP_UUID); | |
| 152 | + if (matches.length !== 1 || matches[0][1] !== APP_ID) throw new Error("Application not owned unambiguously"); | |
| 153 | + | |
| 154 | + stage = "Store version verification failed or this version is already published; no release upload was attempted."; | |
| 155 | + for (const hardware of ["emery", "gabbro"]) { | |
| 156 | + const listing = await request(`${STORE}/api/v1/apps/id/${APP_ID}?hardware=${hardware}`, { method: "GET" }, 60000); | |
| 157 | + if (!Array.isArray(listing.data) || listing.data.length !== 1) throw new Error("Invalid app listing"); | |
| 158 | + const app = listing.data[0]; | |
| 159 | + if (app?.id !== APP_ID || app.uuid !== APP_UUID || !newerThan(candidate, app.latest_release?.version)) { | |
| 160 | + throw new Error("Published version or identity mismatch"); | |
| 161 | + } | |
| 162 | + } | |
| 163 | + | |
| 164 | + const form = new FormData(); | |
| 165 | + form.set("pbwFile", new Blob([bytes], { type: "application/octet-stream" }), filename); | |
| 166 | + form.set("version", version); | |
| 167 | + form.set("releaseNotes", notes); | |
| 168 | + form.set("isPublished", "true"); | |
| 169 | + form.set("replaceScreenshots", "false"); | |
| 170 | + stage = "Store upload failed or its outcome is uncertain. Inspect the developer dashboard before rerunning; do not assume no release was created."; | |
| 171 | + await request(`${STORE}/api/dashboard/apps/${APP_ID}/releases`, { method: "POST", headers, body: form }, 300000, false); | |
| 172 | + log("Store accepted the release upload from the validated GitHub release artifact."); | |
| 173 | + } catch { | |
| 174 | + throw new Error(stage); | |
| 175 | + } | |
| 176 | +} | |
| 177 | + | |
| 178 | +if (process.argv[1] === fileURLToPath(import.meta.url)) { | |
| 179 | + try { | |
| 180 | + const { values } = parseArgs({ options: { | |
| 181 | + pbw: { type: "string" }, checksums: { type: "string" }, tag: { type: "string" }, "notes-file": { type: "string" }, | |
| 182 | + } }); | |
| 183 | + if (Object.values(values).length !== 4 || Object.values(values).some((value) => !value)) { | |
| 184 | + throw new Error("Missing arguments"); | |
| 185 | + } | |
| 186 | + await publishPebbleStore({ | |
| 187 | + pbwPath: values.pbw, checksumsPath: values.checksums, tag: values.tag, notesPath: values["notes-file"], | |
| 188 | + refreshToken: process.env.PEBBLE_FIREBASE_REFRESH_TOKEN, | |
| 189 | + }).catch((error) => { | |
| 190 | + console.error(error.message); | |
| 191 | + process.exitCode = 1; | |
| 192 | + }); | |
| 193 | + } catch { | |
| 194 | + console.error("Usage: node scripts/publish-pebble-store.mjs --pbw PATH --checksums PATH --tag vX.Y.Z --notes-file PATH"); | |
| 195 | + process.exitCode = 1; | |
| 196 | + } | |
| 197 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,197 @@ | |||
| 1 | +import { createHash } from "node:crypto"; | ||
| 2 | +import { createReadStream } from "node:fs"; | ||
| 3 | +import { basename } from "node:path"; | ||
| 4 | +import { fileURLToPath } from "node:url"; | ||
| 5 | +import { parseArgs } from "node:util"; | ||
| 6 | +import yauzl from "yauzl"; | ||
| 7 | + | ||
| 8 | +// Dashboard upload contract follows pebble-tool 5.0.40; it is not a public API guarantee. | ||
| 9 | +// https://github.com/coredevices/pebble-tool/blob/fa7a5c4ba102e27194e6c76e36e0ceeadab0280b/pebble_tool/commands/publish.py | ||
| 10 | +const APP_ID = "6d6aa01b7ecb4cfea469a183"; | ||
| 11 | +const APP_UUID = "f1e58d7b-8e42-4b21-a9bf-67c0f4b59d02"; | ||
| 12 | +const STORE = "https://appstore-api.repebble.com"; | ||
| 13 | +// Public Firebase project configuration from pebble-tool 5.0.40, not a credential. | ||
| 14 | +const TOKEN_URL = "https://securetoken.googleapis.com/v1/token?key=AIzaSyBZ9Cdvwwv9At2lPmc8TxyyEqSXGXejGvc"; | ||
| 15 | +const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u; | ||
| 16 | +const MAX_JSON = 1024 * 1024; | ||
| 17 | + | ||
| 18 | +async function readBounded(stream, limit) { | ||
| 19 | + const chunks = []; | ||
| 20 | + let size = 0; | ||
| 21 | + for await (const chunk of stream) { | ||
| 22 | + size += chunk.length; | ||
| 23 | + if (size > limit) throw new Error("Size limit exceeded"); | ||
| 24 | + chunks.push(chunk); | ||
| 25 | + } | ||
| 26 | + return Buffer.concat(chunks, size); | ||
| 27 | +} | ||
| 28 | + | ||
| 29 | +function versionParts(value) { | ||
| 30 | + if (typeof value !== "string" || value.length > 64 || value.trim() !== value || !VERSION.test(value)) { | ||
| 31 | + throw new Error("Invalid version"); | ||
| 32 | + } | ||
| 33 | + return value.split(".").map(BigInt); | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +function newerThan(candidate, published) { | ||
| 37 | + const previous = versionParts(published); | ||
| 38 | + for (let index = 0; index < 3; index += 1) { | ||
| 39 | + if (candidate[index] !== previous[index]) return candidate[index] > previous[index]; | ||
| 40 | + } | ||
| 41 | + return false; | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +async function readAppInfo(bytes) { | ||
| 45 | + const zip = await new Promise((resolve, reject) => { | ||
| 46 | + yauzl.fromBuffer(bytes, { lazyEntries: true, strictFileNames: true }, (error, archive) => { | ||
| 47 | + if (error) reject(error); | ||
| 48 | + else resolve(archive); | ||
| 49 | + }); | ||
| 50 | + }); | ||
| 51 | + return new Promise((resolve, reject) => { | ||
| 52 | + const members = new Map(); | ||
| 53 | + let appInfo; | ||
| 54 | + const fail = (error) => { | ||
| 55 | + zip.close(); | ||
| 56 | + reject(error); | ||
| 57 | + }; | ||
| 58 | + zip.on("error", fail); | ||
| 59 | + zip.on("entry", async (entry) => { | ||
| 60 | + try { | ||
| 61 | + if (members.has(entry.fileName) || members.size >= 4096) throw new Error("Invalid archive entries"); | ||
| 62 | + members.set(entry.fileName, entry.uncompressedSize); | ||
| 63 | + if (entry.fileName === "appinfo.json" || /^(emery|gabbro)\/manifest\.json$/u.test(entry.fileName)) { | ||
| 64 | + if (entry.uncompressedSize > MAX_JSON) throw new Error("Archive metadata too large"); | ||
| 65 | + const stream = await new Promise((resolveStream, rejectStream) => { | ||
| 66 | + zip.openReadStream(entry, (error, value) => error ? rejectStream(error) : resolveStream(value)); | ||
| 67 | + }); | ||
| 68 | + const metadata = JSON.parse((await readBounded(stream, MAX_JSON)).toString("utf8")); | ||
| 69 | + if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) { | ||
| 70 | + throw new Error("Invalid archive metadata"); | ||
| 71 | + } | ||
| 72 | + if (entry.fileName === "appinfo.json") appInfo = metadata; | ||
| 73 | + } | ||
| 74 | + zip.readEntry(); | ||
| 75 | + } catch (error) { | ||
| 76 | + fail(error); | ||
| 77 | + } | ||
| 78 | + }); | ||
| 79 | + zip.on("end", () => { | ||
| 80 | + zip.close(); | ||
| 81 | + if (!appInfo || ["emery", "gabbro"].some((hardware) => | ||
| 82 | + !members.get(`${hardware}/manifest.json`) || !members.get(`${hardware}/pebble-app.bin`))) { | ||
| 83 | + reject(new Error("Missing archive metadata or target")); | ||
| 84 | + } else resolve(appInfo); | ||
| 85 | + }); | ||
| 86 | + zip.readEntry(); | ||
| 87 | + }); | ||
| 88 | +} | ||
| 89 | + | ||
| 90 | +// All diagnostics are fixed strings. Never attach remote exceptions or response bodies. | ||
| 91 | +export async function publishPebbleStore({ | ||
| 92 | + pbwPath, checksumsPath, tag, notesPath, refreshToken, fetcher = fetch, log = console.log, | ||
| 93 | +}) { | ||
| 94 | + let stage = "Local release validation failed; no Store request was made."; | ||
| 95 | + try { | ||
| 96 | + if (typeof tag !== "string" || !tag.startsWith("v")) throw new Error("Invalid tag"); | ||
| 97 | + const version = tag.slice(1); | ||
| 98 | + const candidate = versionParts(version); | ||
| 99 | + const filename = `lapin-fute-${tag}.pbw`; | ||
| 100 | + if (basename(pbwPath) !== filename) throw new Error("Unexpected artifact name"); | ||
| 101 | + const bytes = await readBounded(createReadStream(pbwPath), 32 * 1024 * 1024); | ||
| 102 | + const checksums = (await readBounded(createReadStream(checksumsPath), MAX_JSON)).toString("utf8"); | ||
| 103 | + const notes = (await readBounded(createReadStream(notesPath), MAX_JSON)).toString("utf8"); | ||
| 104 | + const matching = checksums.split(/\r?\n/u).filter((line) => line.endsWith(` ${filename}`) || line.endsWith(` *${filename}`)); | ||
| 105 | + if (matching.length !== 1 || !/^[a-fA-F0-9]{64} [ *]/u.test(matching[0]) || | ||
| 106 | + matching[0].slice(66) !== filename || | ||
| 107 | + matching[0].slice(0, 64).toLowerCase() !== createHash("sha256").update(bytes).digest("hex")) { | ||
| 108 | + throw new Error("Checksum mismatch"); | ||
| 109 | + } | ||
| 110 | + const appInfo = await readAppInfo(bytes); | ||
| 111 | + if (appInfo.uuid !== APP_UUID || appInfo.versionLabel !== version) throw new Error("Archive identity mismatch"); | ||
| 112 | + if (typeof refreshToken !== "string" || refreshToken.length === 0 || refreshToken.length > 16384 || /\s/u.test(refreshToken)) { | ||
| 113 | + stage = "PEBBLE_FIREBASE_REFRESH_TOKEN is missing or invalid; no Store request was made."; | ||
| 114 | + throw new Error("Invalid refresh token"); | ||
| 115 | + } | ||
| 116 | + | ||
| 117 | + // The timeout remains active while reading the response body. No redirects or retries. | ||
| 118 | + async function request(url, options, timeout, json = true) { | ||
| 119 | + const response = await fetcher(url, { ...options, redirect: "error", signal: AbortSignal.timeout(timeout) }); | ||
| 120 | + if (response.redirected || response.status < 200 || response.status >= 300) { | ||
| 121 | + await response.body?.cancel(); | ||
| 122 | + throw new Error("Remote request failed"); | ||
| 123 | + } | ||
| 124 | + if (!json) { | ||
| 125 | + await response.body?.cancel(); | ||
| 126 | + return; | ||
| 127 | + } | ||
| 128 | + const payload = JSON.parse((await readBounded(response.body, MAX_JSON)).toString("utf8")); | ||
| 129 | + if (payload === null || typeof payload !== "object" || Array.isArray(payload)) throw new Error("Invalid response"); | ||
| 130 | + return payload; | ||
| 131 | + } | ||
| 132 | + | ||
| 133 | + stage = "Firebase session refresh failed. Renew the pebble login session and replace the environment secret if revoked."; | ||
| 134 | + const token = await request(TOKEN_URL, { | ||
| 135 | + method: "POST", | ||
| 136 | + body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken }), | ||
| 137 | + }, 20000); | ||
| 138 | + if (typeof token.id_token !== "string" || token.id_token.length === 0 || token.id_token.length > 16384 || | ||
| 139 | + !/^[A-Za-z0-9._~-]+$/u.test(token.id_token)) throw new Error("Invalid ID token"); | ||
| 140 | + if (token.refresh_token !== undefined && (typeof token.refresh_token !== "string" || !token.refresh_token)) { | ||
| 141 | + throw new Error("Invalid refreshed session"); | ||
| 142 | + } | ||
| 143 | + if (token.refresh_token !== undefined && token.refresh_token !== refreshToken) { | ||
| 144 | + log("Firebase returned a changed refresh token. The environment secret was not updated; renew pebble login and replace that secret before the next publication."); | ||
| 145 | + } | ||
| 146 | + const headers = { Authorization: `Bearer ${token.id_token}` }; | ||
| 147 | + stage = "Store account verification failed; no release upload was attempted."; | ||
| 148 | + const owner = await request(`${STORE}/api/v1/developer/me`, { method: "GET", headers }, 60000); | ||
| 149 | + const lookup = owner.app_lookup?.by_app_uuid; | ||
| 150 | + if (lookup === null || typeof lookup !== "object" || Array.isArray(lookup)) throw new Error("Invalid app lookup"); | ||
| 151 | + const matches = Object.entries(lookup).filter(([uuid]) => uuid.trim().toLowerCase() === APP_UUID); | ||
| 152 | + if (matches.length !== 1 || matches[0][1] !== APP_ID) throw new Error("Application not owned unambiguously"); | ||
| 153 | + | ||
| 154 | + stage = "Store version verification failed or this version is already published; no release upload was attempted."; | ||
| 155 | + for (const hardware of ["emery", "gabbro"]) { | ||
| 156 | + const listing = await request(`${STORE}/api/v1/apps/id/${APP_ID}?hardware=${hardware}`, { method: "GET" }, 60000); | ||
| 157 | + if (!Array.isArray(listing.data) || listing.data.length !== 1) throw new Error("Invalid app listing"); | ||
| 158 | + const app = listing.data[0]; | ||
| 159 | + if (app?.id !== APP_ID || app.uuid !== APP_UUID || !newerThan(candidate, app.latest_release?.version)) { | ||
| 160 | + throw new Error("Published version or identity mismatch"); | ||
| 161 | + } | ||
| 162 | + } | ||
| 163 | + | ||
| 164 | + const form = new FormData(); | ||
| 165 | + form.set("pbwFile", new Blob([bytes], { type: "application/octet-stream" }), filename); | ||
| 166 | + form.set("version", version); | ||
| 167 | + form.set("releaseNotes", notes); | ||
| 168 | + form.set("isPublished", "true"); | ||
| 169 | + form.set("replaceScreenshots", "false"); | ||
| 170 | + stage = "Store upload failed or its outcome is uncertain. Inspect the developer dashboard before rerunning; do not assume no release was created."; | ||
| 171 | + await request(`${STORE}/api/dashboard/apps/${APP_ID}/releases`, { method: "POST", headers, body: form }, 300000, false); | ||
| 172 | + log("Store accepted the release upload from the validated GitHub release artifact."); | ||
| 173 | + } catch { | ||
| 174 | + throw new Error(stage); | ||
| 175 | + } | ||
| 176 | +} | ||
| 177 | + | ||
| 178 | +if (process.argv[1] === fileURLToPath(import.meta.url)) { | ||
| 179 | + try { | ||
| 180 | + const { values } = parseArgs({ options: { | ||
| 181 | + pbw: { type: "string" }, checksums: { type: "string" }, tag: { type: "string" }, "notes-file": { type: "string" }, | ||
| 182 | + } }); | ||
| 183 | + if (Object.values(values).length !== 4 || Object.values(values).some((value) => !value)) { | ||
| 184 | + throw new Error("Missing arguments"); | ||
| 185 | + } | ||
| 186 | + await publishPebbleStore({ | ||
| 187 | + pbwPath: values.pbw, checksumsPath: values.checksums, tag: values.tag, notesPath: values["notes-file"], | ||
| 188 | + refreshToken: process.env.PEBBLE_FIREBASE_REFRESH_TOKEN, | ||
| 189 | + }).catch((error) => { | ||
| 190 | + console.error(error.message); | ||
| 191 | + process.exitCode = 1; | ||
| 192 | + }); | ||
| 193 | + } catch { | ||
| 194 | + console.error("Usage: node scripts/publish-pebble-store.mjs --pbw PATH --checksums PATH --tag vX.Y.Z --notes-file PATH"); | ||
| 195 | + process.exitCode = 1; | ||
| 196 | + } | ||
| 197 | +} | ||