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

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

Download ZIP

fix: close release verification gapsUnverified

anthodev committed 2026-09-13T12:40:03+02:00 Browse files
457bcb9 parent: d6184a2
modified .github/workflows/deploy-bunny.yml +10 -2
@@ -59,11 +59,11 @@ jobs:
5959 node-version-file: .node-version
6060 cache: npm
6161
62- - uses: oven-sh/setup-bun@v2
62+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
6363 with:
6464 bun-version: 1.4.0
6565
66- - uses: astral-sh/setup-uv@v6
66+ - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
6767
6868 - run: npm ci
6969
@@ -137,6 +137,10 @@ jobs:
137137 test -n "$BUNNY_STORAGE_ENDPOINT"
138138 test -n "$BUNNY_STORAGE_PASSWORD"
139139 endpoint="${BUNNY_STORAGE_ENDPOINT%/}"
140+ case "$endpoint" in
141+ https://*) ;;
142+ *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;;
143+ esac
140144 mkdir -p var/deploy-backup/files
141145 : > var/deploy-backup/missing
142146
@@ -267,6 +271,10 @@ jobs:
267271 set -euo pipefail
268272
269273 endpoint="${BUNNY_STORAGE_ENDPOINT%/}"
274+ case "$endpoint" in
275+ https://*) ;;
276+ *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;;
277+ esac
270278 restore_file() {
271279 local file="$1"
272280 local relative_path="${file#var/deploy-backup/files/}"
@@ -59,11 +59,11 @@ jobs:
59 node-version-file: .node-version59 node-version-file: .node-version
60 cache: npm60 cache: npm
61 61
62- - uses: oven-sh/setup-bun@v262+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
63 with:63 with:
64 bun-version: 1.4.064 bun-version: 1.4.0
65 65
66- - uses: astral-sh/setup-uv@v666+ - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0
67 67
68 - run: npm ci68 - run: npm ci
69 69
@@ -137,6 +137,10 @@ jobs:
137 test -n "$BUNNY_STORAGE_ENDPOINT"137 test -n "$BUNNY_STORAGE_ENDPOINT"
138 test -n "$BUNNY_STORAGE_PASSWORD"138 test -n "$BUNNY_STORAGE_PASSWORD"
139 endpoint="${BUNNY_STORAGE_ENDPOINT%/}"139 endpoint="${BUNNY_STORAGE_ENDPOINT%/}"
140+ case "$endpoint" in
141+ https://*) ;;
142+ *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;;
143+ esac
140 mkdir -p var/deploy-backup/files144 mkdir -p var/deploy-backup/files
141 : > var/deploy-backup/missing145 : > var/deploy-backup/missing
142 146
@@ -267,6 +271,10 @@ jobs:
267 set -euo pipefail271 set -euo pipefail
268 272
269 endpoint="${BUNNY_STORAGE_ENDPOINT%/}"273 endpoint="${BUNNY_STORAGE_ENDPOINT%/}"
274+ case "$endpoint" in
275+ https://*) ;;
276+ *) echo "BUNNY_STORAGE_ENDPOINT must use HTTPS" >&2; exit 1 ;;
277+ esac
270 restore_file() {278 restore_file() {
271 local file="$1"279 local file="$1"
272 local relative_path="${file#var/deploy-backup/files/}"280 local relative_path="${file#var/deploy-backup/files/}"
modified .github/workflows/rollback-bunny.yml +11 -2
@@ -31,8 +31,13 @@ jobs:
3131 run: |
3232 set -euo pipefail
3333 [[ "$DEPLOYMENT_RUN_ID" =~ ^[1-9][0-9]*$ ]]
34- conclusion="$(gh api "repos/$GH_REPO/actions/runs/$DEPLOYMENT_RUN_ID" --jq .conclusion)"
35- test "$conclusion" = success
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 ]]
3641
3742 - name: Download retained rollback bundle
3843 env:
@@ -130,6 +135,10 @@ jobs:
130135 set -euo pipefail
131136 test -n "$CONFIG_SITE_ORIGIN"
132137 origin="${CONFIG_SITE_ORIGIN%/}"
138+ case "$origin" in
139+ https://*) ;;
140+ *) echo "CONFIG_SITE_ORIGIN must use HTTPS" >&2; exit 1 ;;
141+ esac
133142
134143 for attempt in 1 2 3; do
135144 failed=0
@@ -31,8 +31,13 @@ jobs:
31 run: |31 run: |
32 set -euo pipefail32 set -euo pipefail
33 [[ "$DEPLOYMENT_RUN_ID" =~ ^[1-9][0-9]*$ ]]33 [[ "$DEPLOYMENT_RUN_ID" =~ ^[1-9][0-9]*$ ]]
34- conclusion="$(gh api "repos/$GH_REPO/actions/runs/$DEPLOYMENT_RUN_ID" --jq .conclusion)"34+ metadata="$(gh api "repos/$GH_REPO/actions/runs/$DEPLOYMENT_RUN_ID" \
35- test "$conclusion" = success35+ --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 ]]
36 41
37 - name: Download retained rollback bundle42 - name: Download retained rollback bundle
38 env:43 env:
@@ -130,6 +135,10 @@ jobs:
130 set -euo pipefail135 set -euo pipefail
131 test -n "$CONFIG_SITE_ORIGIN"136 test -n "$CONFIG_SITE_ORIGIN"
132 origin="${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
133 142
134 for attempt in 1 2 3; do143 for attempt in 1 2 3; do
135 failed=0144 failed=0
modified packages/config-page/src/catalog-client.js +41 -10
@@ -65,6 +65,39 @@ function isPage(value, revision, page, field, placeId) {
6565 && Array.isArray(value[field]) && (value.nextPage === null || value[field].length > 0);
6666 }
6767
68+function isSearchPlace(value) {
69+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
70+ const { searchText, ...place } = value;
71+ return isPlaceSearchItem(place) && PLACE_ID.test(place.placeId)
72+ && typeof searchText === "string" && searchText !== ""
73+ && normalizeCatalogSearchText(searchText) === searchText;
74+}
75+
76+export function isCatalogSearchPage(value, revision, page) {
77+ return isPage(value, revision, page, "places")
78+ && value.places.every(isSearchPlace);
79+}
80+
81+export function isCatalogServicesPage(value, revision, placeId, page) {
82+ if (!PLACE_ID.test(placeId) || !isPage(value, revision, page, "services", placeId)) return false;
83+ const identifiers = new Set();
84+ return value.services.every((service) => {
85+ if (!isServiceOption(service) || !SERVICE_ID.test(service.serviceId)
86+ || identifiers.has(service.serviceId)) return false;
87+ identifiers.add(service.serviceId);
88+ return true;
89+ });
90+}
91+
92+export function isCatalogServiceDocument(value, revision, serviceId) {
93+ return SERVICE_ID.test(serviceId)
94+ && exactFields(value, ["schemaVersion", "revision", "service"])
95+ && value.schemaVersion === SCHEMA_VERSION
96+ && value.revision === revision
97+ && isServiceOption(value.service)
98+ && value.service.serviceId === serviceId;
99+}
100+
68101 function compareMatches(left, right) {
69102 if (left.rank !== right.rank) return left.rank - right.rank;
70103 for (let index = 0; index < left.order.length; index += 1) {
@@ -165,13 +198,9 @@ export function createCatalogClient({
165198 const body = await json(`${pinned.revision}/search/${bucket}/${page}.json`, signal,
166199 page === 0 ? null : "BACKEND_UNAVAILABLE");
167200 if (body === null) return [];
168- if (!isPage(body, pinned.revision, page, "places")) throw new CatalogClientError("BACKEND_UNAVAILABLE");
201+ if (!isCatalogSearchPage(body, pinned.revision, page)) throw new CatalogClientError("BACKEND_UNAVAILABLE");
169202 for (const entry of body.places) {
170- if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new CatalogClientError("BACKEND_UNAVAILABLE");
171203 const { searchText, ...place } = entry;
172- if (!isPlaceSearchItem(place) || !PLACE_ID.test(place.placeId)
173- || typeof searchText !== "string" || searchText === ""
174- || normalizeCatalogSearchText(searchText) !== searchText) throw new CatalogClientError("BACKEND_UNAVAILABLE");
175204 const words = searchText.split(" ");
176205 if (!tokens.every((token) => words.some((word) => word.startsWith(token)))) continue;
177206 const stop = normalizeCatalogSearchText(place.stopLabel);
@@ -231,9 +260,11 @@ export function createCatalogClient({
231260 while (true) {
232261 const body = await json(`${pinned.revision}/places/${placeId}/${page}.json`, signal,
233262 page === 0 ? "INVALID_SERVICE" : "BACKEND_UNAVAILABLE");
234- if (!isPage(body, pinned.revision, page, "services", placeId)) throw new CatalogClientError("BACKEND_UNAVAILABLE");
263+ if (!isCatalogServicesPage(body, pinned.revision, placeId, page)) {
264+ throw new CatalogClientError("BACKEND_UNAVAILABLE");
265+ }
235266 for (const service of body.services) {
236- if (!isServiceOption(service) || !SERVICE_ID.test(service.serviceId) || seen.has(service.serviceId)) {
267+ if (seen.has(service.serviceId)) {
237268 throw new CatalogClientError("INVALID_SERVICE");
238269 }
239270 seen.add(service.serviceId);
@@ -250,9 +281,9 @@ export function createCatalogClient({
250281 const pinned = await pinnedManifest(signal);
251282 const body = await json(`${pinned.revision}/services/${serviceId}.json`, signal, null);
252283 if (body === null) return null;
253- if (!exactFields(body, ["schemaVersion", "revision", "service"])
254- || body.schemaVersion !== SCHEMA_VERSION || body.revision !== pinned.revision
255- || !isServiceOption(body.service) || body.service.serviceId !== serviceId) throw new CatalogClientError("INVALID_SERVICE");
284+ if (!isCatalogServiceDocument(body, pinned.revision, serviceId)) {
285+ throw new CatalogClientError("INVALID_SERVICE");
286+ }
256287 return body.service;
257288 }
258289
@@ -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) {
@@ -165,13 +198,9 @@ export function createCatalogClient({
165 const body = await json(`${pinned.revision}/search/${bucket}/${page}.json`, signal,198 const body = await json(`${pinned.revision}/search/${bucket}/${page}.json`, signal,
166 page === 0 ? null : "BACKEND_UNAVAILABLE");199 page === 0 ? null : "BACKEND_UNAVAILABLE");
167 if (body === null) return [];200 if (body === null) return [];
168- if (!isPage(body, pinned.revision, page, "places")) throw new CatalogClientError("BACKEND_UNAVAILABLE");201+ if (!isCatalogSearchPage(body, pinned.revision, page)) throw new CatalogClientError("BACKEND_UNAVAILABLE");
169 for (const entry of body.places) {202 for (const entry of body.places) {
170- if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new CatalogClientError("BACKEND_UNAVAILABLE");
171 const { searchText, ...place } = entry;203 const { searchText, ...place } = entry;
172- if (!isPlaceSearchItem(place) || !PLACE_ID.test(place.placeId)
173- || typeof searchText !== "string" || searchText === ""
174- || normalizeCatalogSearchText(searchText) !== searchText) throw new CatalogClientError("BACKEND_UNAVAILABLE");
175 const words = searchText.split(" ");204 const words = searchText.split(" ");
176 if (!tokens.every((token) => words.some((word) => word.startsWith(token)))) continue;205 if (!tokens.every((token) => words.some((word) => word.startsWith(token)))) continue;
177 const stop = normalizeCatalogSearchText(place.stopLabel);206 const stop = normalizeCatalogSearchText(place.stopLabel);
@@ -231,9 +260,11 @@ export function createCatalogClient({
231 while (true) {260 while (true) {
232 const body = await json(`${pinned.revision}/places/${placeId}/${page}.json`, signal,261 const body = await json(`${pinned.revision}/places/${placeId}/${page}.json`, signal,
233 page === 0 ? "INVALID_SERVICE" : "BACKEND_UNAVAILABLE");262 page === 0 ? "INVALID_SERVICE" : "BACKEND_UNAVAILABLE");
234- if (!isPage(body, pinned.revision, page, "services", placeId)) throw new CatalogClientError("BACKEND_UNAVAILABLE");263+ if (!isCatalogServicesPage(body, pinned.revision, placeId, page)) {
264+ throw new CatalogClientError("BACKEND_UNAVAILABLE");
265+ }
235 for (const service of body.services) {266 for (const service of body.services) {
236- if (!isServiceOption(service) || !SERVICE_ID.test(service.serviceId) || seen.has(service.serviceId)) {267+ if (seen.has(service.serviceId)) {
237 throw new CatalogClientError("INVALID_SERVICE");268 throw new CatalogClientError("INVALID_SERVICE");
238 }269 }
239 seen.add(service.serviceId);270 seen.add(service.serviceId);
@@ -250,9 +281,9 @@ export function createCatalogClient({
250 const pinned = await pinnedManifest(signal);281 const pinned = await pinnedManifest(signal);
251 const body = await json(`${pinned.revision}/services/${serviceId}.json`, signal, null);282 const body = await json(`${pinned.revision}/services/${serviceId}.json`, signal, null);
252 if (body === null) return null;283 if (body === null) return null;
253- if (!exactFields(body, ["schemaVersion", "revision", "service"])284+ if (!isCatalogServiceDocument(body, pinned.revision, serviceId)) {
254- || body.schemaVersion !== SCHEMA_VERSION || body.revision !== pinned.revision285+ throw new CatalogClientError("INVALID_SERVICE");
255- || !isServiceOption(body.service) || body.service.serviceId !== serviceId) throw new CatalogClientError("INVALID_SERVICE");286+ }
256 return body.service;287 return body.service;
257 }288 }
258 289
modified packages/config-page/test/config-site-build.test.ts +51 -7
@@ -58,6 +58,30 @@ test("configuration-site verification checks every local and served file on the
5858 const paths = fixture();
5959 try {
6060 const revision = "a".repeat(64);
61+ const placeId = `plc_${"a".repeat(43)}`;
62+ const serviceId = `svc_${"b".repeat(43)}`;
63+ const place = {
64+ placeId,
65+ stopLabel: "Gare du Nord",
66+ localityLabel: "Paris",
67+ mode: "BUS",
68+ lines: [{ lineLabel: "42", lineColor: "#e86a10", lineTextColor: "#ffffff" }],
69+ searchText: "gare du nord paris",
70+ };
71+ const service = {
72+ serviceId,
73+ stopLabel: "Gare du Nord",
74+ lineLabel: "42",
75+ destinationLabel: "Hôpital Européen",
76+ lineMode: "BUS",
77+ lineColor: "#e86a10",
78+ lineTextColor: "#ffffff",
79+ routing: {
80+ monitoringRef: "fixture-monitoring",
81+ lineRef: "fixture-line",
82+ destinationRef: "fixture-destination",
83+ },
84+ };
6185 const manifest = {
6286 schemaVersion: 1,
6387 revision,
@@ -74,13 +98,13 @@ test("configuration-site verification checks every local and served file on the
7498 ["index.html", "<h1>Lapin Futé</h1>"],
7599 ["catalog/manifest.json", JSON.stringify(manifest)],
76100 [`catalog/${revision}/search/a/0.json`, JSON.stringify({
77- schemaVersion: 1, revision, page: 0, nextPage: null, places: [],
101+ schemaVersion: 1, revision, page: 0, nextPage: null, places: [place],
78102 })],
79- [`catalog/${revision}/places/plc_${"a".repeat(43)}/0.json`, JSON.stringify({
80- schemaVersion: 1, revision, placeId: `plc_${"a".repeat(43)}`, page: 0, nextPage: null, services: [],
103+ [`catalog/${revision}/places/${placeId}/0.json`, JSON.stringify({
104+ schemaVersion: 1, revision, placeId, page: 0, nextPage: null, services: [service],
81105 })],
82- [`catalog/${revision}/services/svc_${"b".repeat(43)}.json`, JSON.stringify({
83- schemaVersion: 1, revision, service: {},
106+ [`catalog/${revision}/services/${serviceId}.json`, JSON.stringify({
107+ schemaVersion: 1, revision, service,
84108 })],
85109 ]);
86110 for (const [relativePath, body] of files) {
@@ -110,7 +134,23 @@ test("configuration-site verification checks every local and served file on the
110134 .map((path) => `https://config.example.test/lapin-fute/${path}`)
111135 .sort());
112136
113- files.set(`catalog/${revision}/services/svc_${"b".repeat(43)}.json`, "{}");
137+ const servicePath = `catalog/${revision}/services/${serviceId}.json`;
138+ writeFileSync(join(paths.outputPath, servicePath), JSON.stringify({
139+ schemaVersion: 1,
140+ revision,
141+ service: {},
142+ }));
143+ await assert.rejects(
144+ verifyConfigSite({
145+ sitePath: paths.outputPath,
146+ origin: "https://config.example.test/lapin-fute/",
147+ fetcher: async () => new Response(null, { status: 404 }),
148+ }),
149+ /does not satisfy the static catalog contract/u,
150+ );
151+ writeFileSync(join(paths.outputPath, servicePath), files.get(servicePath));
152+
153+ files.set(servicePath, "{}");
114154 await assert.rejects(
115155 verifyConfigSite({
116156 sitePath: paths.outputPath,
@@ -139,6 +179,8 @@ test("Bunny releases deploy production tags while preview tags stop after qualit
139179 assert.match(workflow, /tags:\s*\["v\*", "pre-v\*"\]/u);
140180 assert.match(workflow, /startsWith\(github\.ref_name, 'v'\)/u);
141181 assert.match(workflow, /package:\s+name: Build release PBW[\s\S]+needs: test/u);
182+ assert.match(workflow, /oven-sh\/setup-bun@[a-f0-9]{40} # v2\.2\.0/u);
183+ assert.match(workflow, /astral-sh\/setup-uv@[a-f0-9]{40} # v6\.8\.0/u);
142184 assert.match(workflow, /pebble-tool==5\.0\.40[\s\S]+pebble sdk install 4\.33\.1/u);
143185 assert.match(workflow, /LAPIN_FUTE_CONFIG_URL: \$\{\{ vars\.CONFIG_SITE_ORIGIN \}\}[\s\S]+npm run build/u);
144186 assert.match(workflow, /sha256sum "lapin-fute-\$RELEASE_TAG\.pbw" > SHA256SUMS/u);
@@ -165,6 +207,8 @@ test("Bunny releases deploy production tags while preview tags stop after qualit
165207 assert.match(workflow, /Restoring \$relative_path[\s\S]+Removing newly introduced mutable file/u);
166208 assert.match(rollbackWorkflow, /deployment_run_id:[\s\S]+type: string/u);
167209 assert.match(rollbackWorkflow, /gh run download "\$DEPLOYMENT_RUN_ID"[\s\S]+bunny-rollback-\$DEPLOYMENT_RUN_ID/u);
168- assert.match(rollbackWorkflow, /conclusion="\$\(gh api[\s\S]+test "\$conclusion" = success/u);
210+ assert.match(rollbackWorkflow, /test "\$status" = completed[\s\S]+workflow_path" = "\.github\/workflows\/deploy-bunny\.yml"/u);
211+ assert.match(rollbackWorkflow, /head_repository" = "\$GH_REPO"[\s\S]+event" = workflow_dispatch/u);
169212 assert.match(rollbackWorkflow, /Restore previous mutable files[\s\S]+Purge Bunny CDN cache[\s\S]+Verify restored mutable files/u);
213+ assert.match(rollbackWorkflow, /CONFIG_SITE_ORIGIN must use HTTPS/u);
170214 });
@@ -58,6 +58,30 @@ test("configuration-site verification checks every local and served file on the
58 const paths = fixture();58 const paths = fixture();
59 try {59 try {
60 const revision = "a".repeat(64);60 const revision = "a".repeat(64);
61+ const placeId = `plc_${"a".repeat(43)}`;
62+ const serviceId = `svc_${"b".repeat(43)}`;
63+ const place = {
64+ placeId,
65+ stopLabel: "Gare du Nord",
66+ localityLabel: "Paris",
67+ mode: "BUS",
68+ lines: [{ lineLabel: "42", lineColor: "#e86a10", lineTextColor: "#ffffff" }],
69+ searchText: "gare du nord paris",
70+ };
71+ const service = {
72+ serviceId,
73+ stopLabel: "Gare du Nord",
74+ lineLabel: "42",
75+ destinationLabel: "Hôpital Européen",
76+ lineMode: "BUS",
77+ lineColor: "#e86a10",
78+ lineTextColor: "#ffffff",
79+ routing: {
80+ monitoringRef: "fixture-monitoring",
81+ lineRef: "fixture-line",
82+ destinationRef: "fixture-destination",
83+ },
84+ };
61 const manifest = {85 const manifest = {
62 schemaVersion: 1,86 schemaVersion: 1,
63 revision,87 revision,
@@ -74,13 +98,13 @@ test("configuration-site verification checks every local and served file on the
74 ["index.html", "<h1>Lapin Futé</h1>"],98 ["index.html", "<h1>Lapin Futé</h1>"],
75 ["catalog/manifest.json", JSON.stringify(manifest)],99 ["catalog/manifest.json", JSON.stringify(manifest)],
76 [`catalog/${revision}/search/a/0.json`, JSON.stringify({100 [`catalog/${revision}/search/a/0.json`, JSON.stringify({
77- schemaVersion: 1, revision, page: 0, nextPage: null, places: [],101+ schemaVersion: 1, revision, page: 0, nextPage: null, places: [place],
78 })],102 })],
79- [`catalog/${revision}/places/plc_${"a".repeat(43)}/0.json`, JSON.stringify({103+ [`catalog/${revision}/places/${placeId}/0.json`, JSON.stringify({
80- schemaVersion: 1, revision, placeId: `plc_${"a".repeat(43)}`, page: 0, nextPage: null, services: [],104+ schemaVersion: 1, revision, placeId, page: 0, nextPage: null, services: [service],
81 })],105 })],
82- [`catalog/${revision}/services/svc_${"b".repeat(43)}.json`, JSON.stringify({106+ [`catalog/${revision}/services/${serviceId}.json`, JSON.stringify({
83- schemaVersion: 1, revision, service: {},107+ schemaVersion: 1, revision, service,
84 })],108 })],
85 ]);109 ]);
86 for (const [relativePath, body] of files) {110 for (const [relativePath, body] of files) {
@@ -110,7 +134,23 @@ test("configuration-site verification checks every local and served file on the
110 .map((path) => `https://config.example.test/lapin-fute/${path}`)134 .map((path) => `https://config.example.test/lapin-fute/${path}`)
111 .sort());135 .sort());
112 136
113- files.set(`catalog/${revision}/services/svc_${"b".repeat(43)}.json`, "{}");137+ const servicePath = `catalog/${revision}/services/${serviceId}.json`;
138+ writeFileSync(join(paths.outputPath, servicePath), JSON.stringify({
139+ schemaVersion: 1,
140+ revision,
141+ service: {},
142+ }));
143+ await assert.rejects(
144+ verifyConfigSite({
145+ sitePath: paths.outputPath,
146+ origin: "https://config.example.test/lapin-fute/",
147+ fetcher: async () => new Response(null, { status: 404 }),
148+ }),
149+ /does not satisfy the static catalog contract/u,
150+ );
151+ writeFileSync(join(paths.outputPath, servicePath), files.get(servicePath));
152+
153+ files.set(servicePath, "{}");
114 await assert.rejects(154 await assert.rejects(
115 verifyConfigSite({155 verifyConfigSite({
116 sitePath: paths.outputPath,156 sitePath: paths.outputPath,
@@ -139,6 +179,8 @@ test("Bunny releases deploy production tags while preview tags stop after qualit
139 assert.match(workflow, /tags:\s*\["v\*", "pre-v\*"\]/u);179 assert.match(workflow, /tags:\s*\["v\*", "pre-v\*"\]/u);
140 assert.match(workflow, /startsWith\(github\.ref_name, 'v'\)/u);180 assert.match(workflow, /startsWith\(github\.ref_name, 'v'\)/u);
141 assert.match(workflow, /package:\s+name: Build release PBW[\s\S]+needs: test/u);181 assert.match(workflow, /package:\s+name: Build release PBW[\s\S]+needs: test/u);
182+ assert.match(workflow, /oven-sh\/setup-bun@[a-f0-9]{40} # v2\.2\.0/u);
183+ assert.match(workflow, /astral-sh\/setup-uv@[a-f0-9]{40} # v6\.8\.0/u);
142 assert.match(workflow, /pebble-tool==5\.0\.40[\s\S]+pebble sdk install 4\.33\.1/u);184 assert.match(workflow, /pebble-tool==5\.0\.40[\s\S]+pebble sdk install 4\.33\.1/u);
143 assert.match(workflow, /LAPIN_FUTE_CONFIG_URL: \$\{\{ vars\.CONFIG_SITE_ORIGIN \}\}[\s\S]+npm run build/u);185 assert.match(workflow, /LAPIN_FUTE_CONFIG_URL: \$\{\{ vars\.CONFIG_SITE_ORIGIN \}\}[\s\S]+npm run build/u);
144 assert.match(workflow, /sha256sum "lapin-fute-\$RELEASE_TAG\.pbw" > SHA256SUMS/u);186 assert.match(workflow, /sha256sum "lapin-fute-\$RELEASE_TAG\.pbw" > SHA256SUMS/u);
@@ -165,6 +207,8 @@ test("Bunny releases deploy production tags while preview tags stop after qualit
165 assert.match(workflow, /Restoring \$relative_path[\s\S]+Removing newly introduced mutable file/u);207 assert.match(workflow, /Restoring \$relative_path[\s\S]+Removing newly introduced mutable file/u);
166 assert.match(rollbackWorkflow, /deployment_run_id:[\s\S]+type: string/u);208 assert.match(rollbackWorkflow, /deployment_run_id:[\s\S]+type: string/u);
167 assert.match(rollbackWorkflow, /gh run download "\$DEPLOYMENT_RUN_ID"[\s\S]+bunny-rollback-\$DEPLOYMENT_RUN_ID/u);209 assert.match(rollbackWorkflow, /gh run download "\$DEPLOYMENT_RUN_ID"[\s\S]+bunny-rollback-\$DEPLOYMENT_RUN_ID/u);
168- assert.match(rollbackWorkflow, /conclusion="\$\(gh api[\s\S]+test "\$conclusion" = success/u);210+ assert.match(rollbackWorkflow, /test "\$status" = completed[\s\S]+workflow_path" = "\.github\/workflows\/deploy-bunny\.yml"/u);
211+ assert.match(rollbackWorkflow, /head_repository" = "\$GH_REPO"[\s\S]+event" = workflow_dispatch/u);
169 assert.match(rollbackWorkflow, /Restore previous mutable files[\s\S]+Purge Bunny CDN cache[\s\S]+Verify restored mutable files/u);212 assert.match(rollbackWorkflow, /Restore previous mutable files[\s\S]+Purge Bunny CDN cache[\s\S]+Verify restored mutable files/u);
213+ assert.match(rollbackWorkflow, /CONFIG_SITE_ORIGIN must use HTTPS/u);
170 });214 });
modified scripts/verify-config-site.mjs +55 -12
@@ -1,12 +1,18 @@
11 import { readdirSync, readFileSync, statSync } from "node:fs";
22 import { dirname, join, relative, resolve, sep } from "node:path";
33 import { fileURLToPath } from "node:url";
4-import { isCatalogManifest } from "../packages/config-page/src/catalog-client.js";
4+import {
5+ isCatalogManifest,
6+ isCatalogSearchPage,
7+ isCatalogServiceDocument,
8+ isCatalogServicesPage,
9+} from "../packages/config-page/src/catalog-client.js";
510
611 const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
712 const DEFAULT_SITE_PATH = join(PROJECT_ROOT, "var/config-site");
813 const REQUIRED_CATALOG_GROUPS = ["search", "places", "services"];
914 const REMOTE_CONCURRENCY = 16;
15+const REMOTE_TIMEOUT_MS = 30_000;
1016
1117 function fail(message) {
1218 throw new Error(`verify:config-site failed: ${message}`);
@@ -79,9 +85,21 @@ function verifyLocalSite(sitePath) {
7985 if (groupFiles.length === 0) fail(`current catalog has no ${group} JSON`);
8086 for (const path of groupFiles) {
8187 const document = parseJson(path, relative(root, path));
82- if (document?.schemaVersion !== manifest.schemaVersion || document?.revision !== manifest.revision) {
83- fail(`${relative(root, path)} does not match the current schema and revision`);
84- }
88+ const catalogPath = relative(revisionRoot, path).split(sep);
89+ const page = catalogPath.at(-1)?.match(/^([0-9]+)\.json$/u);
90+ const valid = group === "search"
91+ ? catalogPath.length === 3 && page !== undefined && page !== null
92+ && isCatalogSearchPage(document, manifest.revision, Number(page[1]))
93+ : group === "places"
94+ ? catalogPath.length === 3 && page !== undefined && page !== null
95+ && isCatalogServicesPage(document, manifest.revision, catalogPath[1], Number(page[1]))
96+ : catalogPath.length === 2
97+ && isCatalogServiceDocument(
98+ document,
99+ manifest.revision,
100+ catalogPath[1].replace(/\.json$/u, ""),
101+ );
102+ if (!valid) fail(`${relative(root, path)} does not satisfy the static catalog contract`);
85103 catalogJsonCount += 1;
86104 }
87105 }
@@ -109,19 +127,44 @@ async function verifyServedFiles(local, origin, fetcher) {
109127 const path = local.files[next];
110128 next += 1;
111129 const url = remoteUrl(origin, local.root, path);
112- let response;
130+ const expected = readFileSync(path);
113131 try {
114- response = await fetcher(url, {
132+ const response = await fetcher(url, {
115133 headers: { Accept: "*/*", "Cache-Control": "no-cache" },
116134 redirect: "error",
135+ signal: AbortSignal.timeout(REMOTE_TIMEOUT_MS),
117136 });
118- } catch {
119- fail(`could not fetch ${url.href}`);
137+ if (!response.ok) fail(`${url.href} returned HTTP ${response.status}`);
138+ const declaredLength = Number(response.headers.get("content-length"));
139+ if (Number.isFinite(declaredLength) && declaredLength > expected.length) {
140+ fail(`served response is too large for ${url.href}`);
141+ }
142+ if (response.body === null) fail(`served response has no body for ${url.href}`);
143+ const reader = response.body.getReader();
144+ const chunks = [];
145+ let bytes = 0;
146+ let finished = false;
147+ try {
148+ while (true) {
149+ const { done, value } = await reader.read();
150+ if (done) {
151+ finished = true;
152+ break;
153+ }
154+ bytes += value.byteLength;
155+ if (bytes > expected.length) fail(`served response is too large for ${url.href}`);
156+ chunks.push(value);
157+ }
158+ } finally {
159+ if (!finished) await reader.cancel();
160+ reader.releaseLock();
161+ }
162+ const actual = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), bytes);
163+ if (!actual.equals(expected)) fail(`served bytes differ for ${url.href}`);
164+ } catch (error) {
165+ if (error instanceof Error && error.message.startsWith("verify:config-site failed:")) throw error;
166+ fail(`could not fetch or validate ${url.href}`);
120167 }
121- if (!response.ok) fail(`${url.href} returned HTTP ${response.status}`);
122- const expected = readFileSync(path);
123- const actual = Buffer.from(await response.arrayBuffer());
124- if (!actual.equals(expected)) fail(`served bytes differ for ${url.href}`);
125168 }
126169 }
127170 await Promise.all(Array.from(
@@ -1,12 +1,18 @@
1 import { readdirSync, readFileSync, statSync } from "node:fs";1 import { readdirSync, readFileSync, statSync } from "node:fs";
2 import { dirname, join, relative, resolve, sep } from "node:path";2 import { dirname, join, relative, resolve, sep } from "node:path";
3 import { fileURLToPath } from "node:url";3 import { fileURLToPath } from "node:url";
4-import { isCatalogManifest } from "../packages/config-page/src/catalog-client.js";4+import {
5+ isCatalogManifest,
6+ isCatalogSearchPage,
7+ isCatalogServiceDocument,
8+ isCatalogServicesPage,
9+} from "../packages/config-page/src/catalog-client.js";
5 10
6 const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");11 const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
7 const DEFAULT_SITE_PATH = join(PROJECT_ROOT, "var/config-site");12 const DEFAULT_SITE_PATH = join(PROJECT_ROOT, "var/config-site");
8 const REQUIRED_CATALOG_GROUPS = ["search", "places", "services"];13 const REQUIRED_CATALOG_GROUPS = ["search", "places", "services"];
9 const REMOTE_CONCURRENCY = 16;14 const REMOTE_CONCURRENCY = 16;
15+const REMOTE_TIMEOUT_MS = 30_000;
10 16
11 function fail(message) {17 function fail(message) {
12 throw new Error(`verify:config-site failed: ${message}`);18 throw new Error(`verify:config-site failed: ${message}`);
@@ -79,9 +85,21 @@ function verifyLocalSite(sitePath) {
79 if (groupFiles.length === 0) fail(`current catalog has no ${group} JSON`);85 if (groupFiles.length === 0) fail(`current catalog has no ${group} JSON`);
80 for (const path of groupFiles) {86 for (const path of groupFiles) {
81 const document = parseJson(path, relative(root, path));87 const document = parseJson(path, relative(root, path));
82- if (document?.schemaVersion !== manifest.schemaVersion || document?.revision !== manifest.revision) {88+ const catalogPath = relative(revisionRoot, path).split(sep);
83- fail(`${relative(root, path)} does not match the current schema and revision`);89+ const page = catalogPath.at(-1)?.match(/^([0-9]+)\.json$/u);
84- }90+ const valid = group === "search"
91+ ? catalogPath.length === 3 && page !== undefined && page !== null
92+ && isCatalogSearchPage(document, manifest.revision, Number(page[1]))
93+ : group === "places"
94+ ? catalogPath.length === 3 && page !== undefined && page !== null
95+ && isCatalogServicesPage(document, manifest.revision, catalogPath[1], Number(page[1]))
96+ : catalogPath.length === 2
97+ && isCatalogServiceDocument(
98+ document,
99+ manifest.revision,
100+ catalogPath[1].replace(/\.json$/u, ""),
101+ );
102+ if (!valid) fail(`${relative(root, path)} does not satisfy the static catalog contract`);
85 catalogJsonCount += 1;103 catalogJsonCount += 1;
86 }104 }
87 }105 }
@@ -109,19 +127,44 @@ async function verifyServedFiles(local, origin, fetcher) {
109 const path = local.files[next];127 const path = local.files[next];
110 next += 1;128 next += 1;
111 const url = remoteUrl(origin, local.root, path);129 const url = remoteUrl(origin, local.root, path);
112- let response;130+ const expected = readFileSync(path);
113 try {131 try {
114- response = await fetcher(url, {132+ const response = await fetcher(url, {
115 headers: { Accept: "*/*", "Cache-Control": "no-cache" },133 headers: { Accept: "*/*", "Cache-Control": "no-cache" },
116 redirect: "error",134 redirect: "error",
135+ signal: AbortSignal.timeout(REMOTE_TIMEOUT_MS),
117 });136 });
118- } catch {137+ if (!response.ok) fail(`${url.href} returned HTTP ${response.status}`);
119- fail(`could not fetch ${url.href}`);138+ const declaredLength = Number(response.headers.get("content-length"));
139+ if (Number.isFinite(declaredLength) && declaredLength > expected.length) {
140+ fail(`served response is too large for ${url.href}`);
141+ }
142+ if (response.body === null) fail(`served response has no body for ${url.href}`);
143+ const reader = response.body.getReader();
144+ const chunks = [];
145+ let bytes = 0;
146+ let finished = false;
147+ try {
148+ while (true) {
149+ const { done, value } = await reader.read();
150+ if (done) {
151+ finished = true;
152+ break;
153+ }
154+ bytes += value.byteLength;
155+ if (bytes > expected.length) fail(`served response is too large for ${url.href}`);
156+ chunks.push(value);
157+ }
158+ } finally {
159+ if (!finished) await reader.cancel();
160+ reader.releaseLock();
161+ }
162+ const actual = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)), bytes);
163+ if (!actual.equals(expected)) fail(`served bytes differ for ${url.href}`);
164+ } catch (error) {
165+ if (error instanceof Error && error.message.startsWith("verify:config-site failed:")) throw error;
166+ fail(`could not fetch or validate ${url.href}`);
120 }167 }
121- if (!response.ok) fail(`${url.href} returned HTTP ${response.status}`);
122- const expected = readFileSync(path);
123- const actual = Buffer.from(await response.arrayBuffer());
124- if (!actual.equals(expected)) fail(`served bytes differ for ${url.href}`);
125 }168 }
126 }169 }
127 await Promise.all(Array.from(170 await Promise.all(Array.from(