diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..4ce1488 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,22 @@ +name: Check + +on: + pull_request: + push: + branches: + - master + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run check diff --git a/baselines/README.md b/baselines/README.md new file mode 100644 index 0000000..33b0a73 --- /dev/null +++ b/baselines/README.md @@ -0,0 +1,16 @@ +# Public route baseline + +`public-routes.json` records the public GitHub Pages surface that existed before the handbook work began. It is a non-breaking contract, not a permanent ban on editing a presentation. + +The fixture contains: + +- the source commit and GitHub Pages configuration; +- every route linked from the portfolio index; +- the live deck, speaker notes, standalone deck, carousel, PDF, and exported carousel images; +- the observed HTTP status, content type, response size, and final URL on the capture date; +- SHA-256 hashes for the root index and every presentation HTML entry point; and +- the current absence of `robots.txt`, `sitemap.xml`, and `llms.txt`. + +`npm run check:baseline` confirms that every source file still exists and that hashed presentation files have not changed silently. An intentional presentation edit should update the affected hash and explain the change in the pull request. The live HTTP observations are historical evidence; they are not fetched during CI. + +Captured on 2026-10-09 from commit `0f3b72ad28a41001604b74572368b505c7762378`. diff --git a/baselines/public-routes.json b/baselines/public-routes.json new file mode 100644 index 0000000..1a7b242 --- /dev/null +++ b/baselines/public-routes.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "capturedAt": "2026-10-09", + "sourceCommit": "0f3b72ad28a41001604b74572368b505c7762378", + "pages": { + "url": "https://last9.github.io/presentations/", + "status": "built", + "buildType": "legacy", + "sourceBranch": "master", + "sourcePath": "/", + "customDomain": null, + "httpsEnforced": true + }, + "discoveryEndpoints": [ + { "path": "/presentations/robots.txt", "status": 404, "contentType": "text/html; charset=utf-8", "contentLength": 9379, "finalUrl": "https://last9.github.io/presentations/robots.txt" }, + { "path": "/presentations/sitemap.xml", "status": 404, "contentType": "text/html; charset=utf-8", "contentLength": 9379, "finalUrl": "https://last9.github.io/presentations/sitemap.xml" }, + { "path": "/presentations/llms.txt", "status": 404, "contentType": "text/html; charset=utf-8", "contentLength": 9379, "finalUrl": "https://last9.github.io/presentations/llms.txt" } + ], + "routes": [ + { "path": "/presentations/", "sourcePath": "index.html", "status": 200, "contentType": "text/html; charset=utf-8", "contentLength": 7156, "finalUrl": "https://last9.github.io/presentations/", "sha256": "6811607e6a984f711978a2496f5899ce9a4b2e710d9811455b12c194679c9683" }, + { "path": "/presentations/talk-1/", "sourcePath": "talk-1/index.html", "status": 200, "contentType": "text/html; charset=utf-8", "contentLength": 31405, "finalUrl": "https://last9.github.io/presentations/talk-1/", "sha256": "cfcda2f8dabc06c95a72e34d1027cb02d65dacccc18b2f3d9430c0ee3ad20787" }, + { "path": "/presentations/talk-2/", "sourcePath": "talk-2/index.html", "status": 200, "contentType": "text/html; charset=utf-8", "contentLength": 12091, "finalUrl": "https://last9.github.io/presentations/talk-2/", "sha256": "194a32dad91d8b52124865e2e01f25df1a141595d29ec96937afd08b9c6f948f" }, + { "path": "/presentations/talk-3/", "sourcePath": "talk-3/index.html", "status": 200, "contentType": "text/html; charset=utf-8", "contentLength": 11738, "finalUrl": "https://last9.github.io/presentations/talk-3/", "sha256": "b60009a4c5d00bb2458915c7c0c1424008c9f0d05b9abcacb73284d4bd8b7152" }, + { "path": "/presentations/talk-4/", "sourcePath": "talk-4/index.html", "status": 200, "contentType": "text/html; charset=utf-8", "contentLength": 9310, "finalUrl": "https://last9.github.io/presentations/talk-4/", "sha256": "9d6abae2865d89e8670df2d39ee8690b29bab8152e5e1827455553e9a4fdfcf3" }, + { "path": "/presentations/talk-5/", "sourcePath": "talk-5/index.html", "status": 200, "contentType": "text/html; charset=utf-8", "contentLength": 9131, "finalUrl": "https://last9.github.io/presentations/talk-5/", "sha256": "d1d7246fdb86b056ef65b56ffeb6100be0f5c6d2c4d348d0314f3a4e2920ce3b" }, + { "path": "/presentations/talk-6/", "sourcePath": "talk-6/index.html", "status": 200, "contentType": "text/html; charset=utf-8", "contentLength": 11959, "finalUrl": "https://last9.github.io/presentations/talk-6/", "sha256": "15cee85eb534fb16fed12c1aeaecb1692abd39f1ce8dc12afcdf3a8ccb4055a5" }, + { "path": "/presentations/talk-7/", "sourcePath": "talk-7/index.html", "status": 200, "contentType": "text/html; charset=utf-8", "contentLength": 10072, "finalUrl": "https://last9.github.io/presentations/talk-7/", "sha256": "d00f073b4de637a029562831fcdb44fc5edbb58eaa34dd2a62ad7948a5622b45" }, + { "path": "/presentations/talk-8/", "sourcePath": "talk-8/index.html", "status": 200, "contentType": "text/html; charset=utf-8", "contentLength": 15524, "finalUrl": "https://last9.github.io/presentations/talk-8/", "sha256": "5246f0f3249d8fc5244675a604f405760f23d442fbec2e0c98e25665461696b5" }, + { "path": "/presentations/whose-cloud-is-it-anyway-sep-2-2026/", "sourcePath": "whose-cloud-is-it-anyway-sep-2-2026/index.html", "status": 200, "contentType": "text/html; charset=utf-8", "contentLength": 31178, "finalUrl": "https://last9.github.io/presentations/whose-cloud-is-it-anyway-sep-2-2026/", "sha256": "61ab292eea0ce4d7de3fee3246c6102ca670217a2192cfc0f01f90f43f6213a6" }, + { "path": "/presentations/docs/", "sourcePath": "docs/README.md", "status": 200, "contentType": "text/html; charset=utf-8", "contentLength": 14969, "finalUrl": "https://last9.github.io/presentations/docs/" }, + { "path": "/presentations/talk-live/", "sourcePath": "talk-live/index.html", "status": 200, "contentType": "text/html; charset=utf-8", "contentLength": 59528, "finalUrl": "https://last9.github.io/presentations/talk-live/", "sha256": "cf507f84a043d2efbb1c6fe075d6bbbde76a702359f12f1fa84b83a8db4dd5b1" }, + { "path": "/presentations/talk-live/notes.html", "sourcePath": "talk-live/notes.html", "status": 200, "contentType": "text/html; charset=utf-8", "contentLength": 21529, "finalUrl": "https://last9.github.io/presentations/talk-live/notes.html", "sha256": "bd36f0b50c39f99455d3aced2bafd93c0e197a16049430855d6fc1b0021a882f" }, + { "path": "/presentations/whose-cloud-is-it-anyway-sep-2-2026/whose-cloud-is-it-anyway.html", "sourcePath": "whose-cloud-is-it-anyway-sep-2-2026/whose-cloud-is-it-anyway.html", "status": 200, "contentType": "text/html; charset=utf-8", "contentLength": 518853, "finalUrl": "https://last9.github.io/presentations/whose-cloud-is-it-anyway-sep-2-2026/whose-cloud-is-it-anyway.html", "sha256": "f26cb8f6c64675d49915d7f0eab906a51ebd790f670e77b51d4dcbc2f4f80d60" }, + { "path": "/presentations/carousel/", "sourcePath": "carousel/index.html", "status": 200, "contentType": "text/html; charset=utf-8", "contentLength": 30832, "finalUrl": "https://last9.github.io/presentations/carousel/", "sha256": "7dfece23f66636194594e890c3b93dd384cd4a02ef6a9d53ec575cfcbe286a1d" }, + { "path": "/presentations/carousel/untrusted-agents-carousel.pdf", "sourcePath": "carousel/untrusted-agents-carousel.pdf", "status": 200, "contentType": "application/pdf", "contentLength": 1332876, "finalUrl": "https://last9.github.io/presentations/carousel/untrusted-agents-carousel.pdf" }, + { "path": "/presentations/carousel/out/slide-01.png", "sourcePath": "carousel/out/slide-01.png", "status": 200, "contentType": "image/png", "contentLength": 141400, "finalUrl": "https://last9.github.io/presentations/carousel/out/slide-01.png" }, + { "path": "/presentations/carousel/out/slide-02.png", "sourcePath": "carousel/out/slide-02.png", "status": 200, "contentType": "image/png", "contentLength": 317308, "finalUrl": "https://last9.github.io/presentations/carousel/out/slide-02.png" }, + { "path": "/presentations/carousel/out/slide-03.png", "sourcePath": "carousel/out/slide-03.png", "status": 200, "contentType": "image/png", "contentLength": 176380, "finalUrl": "https://last9.github.io/presentations/carousel/out/slide-03.png" }, + { "path": "/presentations/carousel/out/slide-04.png", "sourcePath": "carousel/out/slide-04.png", "status": 200, "contentType": "image/png", "contentLength": 297670, "finalUrl": "https://last9.github.io/presentations/carousel/out/slide-04.png" }, + { "path": "/presentations/carousel/out/slide-05.png", "sourcePath": "carousel/out/slide-05.png", "status": 200, "contentType": "image/png", "contentLength": 153523, "finalUrl": "https://last9.github.io/presentations/carousel/out/slide-05.png" }, + { "path": "/presentations/carousel/out/slide-06.png", "sourcePath": "carousel/out/slide-06.png", "status": 200, "contentType": "image/png", "contentLength": 188250, "finalUrl": "https://last9.github.io/presentations/carousel/out/slide-06.png" }, + { "path": "/presentations/carousel/out/slide-07.png", "sourcePath": "carousel/out/slide-07.png", "status": 200, "contentType": "image/png", "contentLength": 186396, "finalUrl": "https://last9.github.io/presentations/carousel/out/slide-07.png" }, + { "path": "/presentations/carousel/out/slide-08.png", "sourcePath": "carousel/out/slide-08.png", "status": 200, "contentType": "image/png", "contentLength": 201202, "finalUrl": "https://last9.github.io/presentations/carousel/out/slide-08.png" }, + { "path": "/presentations/carousel/out/slide-09.png", "sourcePath": "carousel/out/slide-09.png", "status": 200, "contentType": "image/png", "contentLength": 204271, "finalUrl": "https://last9.github.io/presentations/carousel/out/slide-09.png" }, + { "path": "/presentations/carousel/out/slide-10.png", "sourcePath": "carousel/out/slide-10.png", "status": 200, "contentType": "image/png", "contentLength": 205389, "finalUrl": "https://last9.github.io/presentations/carousel/out/slide-10.png" }, + { "path": "/presentations/carousel/out/slide-11.png", "sourcePath": "carousel/out/slide-11.png", "status": 200, "contentType": "image/png", "contentLength": 176095, "finalUrl": "https://last9.github.io/presentations/carousel/out/slide-11.png" }, + { "path": "/presentations/carousel/out/slide-12.png", "sourcePath": "carousel/out/slide-12.png", "status": 200, "contentType": "image/png", "contentLength": 166771, "finalUrl": "https://last9.github.io/presentations/carousel/out/slide-12.png" } + ] +} diff --git a/package.json b/package.json index 2bc25e9..86836a6 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "description": "Last9 conference talks and presentation assets", "type": "module", "scripts": { - "check": "node scripts/check-local-links.mjs", + "check": "node scripts/check-local-links.mjs && node scripts/check-public-route-baseline.mjs", + "check:baseline": "node scripts/check-public-route-baseline.mjs", "dev": "serve -l 8000 .", "start": "serve -l 8000 .", "assets": "node scripts/svg-to-png.mjs", diff --git a/scripts/check-public-route-baseline.mjs b/scripts/check-public-route-baseline.mjs new file mode 100644 index 0000000..35eb28b --- /dev/null +++ b/scripts/check-public-route-baseline.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const fixturePath = resolve(root, "baselines/public-routes.json"); +const fixture = JSON.parse(readFileSync(fixturePath, "utf8")); +const failures = []; +const seenPaths = new Set(); + +if (fixture.version !== 1) failures.push("fixture version must be 1"); +if (!/^\d{4}-\d{2}-\d{2}$/.test(fixture.capturedAt)) { + failures.push("capturedAt must use YYYY-MM-DD"); +} +if (!/^[0-9a-f]{40}$/.test(fixture.sourceCommit)) { + failures.push("sourceCommit must be a full Git commit SHA"); +} +if (!Array.isArray(fixture.routes) || fixture.routes.length === 0) { + failures.push("routes must be a non-empty array"); +} + +for (const endpoint of fixture.discoveryEndpoints ?? []) { + if (endpoint.status !== 404) { + failures.push(`${endpoint.path}: discovery baseline status must be 404`); + } + const expectedUrl = new URL(endpoint.path, "https://last9.github.io").href; + if (endpoint.finalUrl !== expectedUrl) { + failures.push(`${endpoint.path}: finalUrl must be ${expectedUrl}`); + } +} + +for (const route of fixture.routes ?? []) { + if (seenPaths.has(route.path)) failures.push(`duplicate route: ${route.path}`); + seenPaths.add(route.path); + + if (!route.path.startsWith("/presentations/")) { + failures.push(`${route.path}: route must remain under /presentations/`); + } + if (route.status !== 200) failures.push(`${route.path}: expected status must be 200`); + if (!route.contentType) failures.push(`${route.path}: contentType is required`); + if (!Number.isInteger(route.contentLength) || route.contentLength <= 0) { + failures.push(`${route.path}: contentLength must be a positive integer`); + } + + const expectedUrl = new URL(route.path, "https://last9.github.io").href; + if (route.finalUrl !== expectedUrl) { + failures.push(`${route.path}: finalUrl must be ${expectedUrl}`); + } + + const sourcePath = resolve(root, route.sourcePath); + if (!existsSync(sourcePath)) { + failures.push(`${route.path}: missing source ${route.sourcePath}`); + continue; + } + + if (route.sha256) { + const actualHash = createHash("sha256").update(readFileSync(sourcePath)).digest("hex"); + if (actualHash !== route.sha256) { + failures.push( + `${route.path}: ${route.sourcePath} changed (expected ${route.sha256}, got ${actualHash})`, + ); + } + } +} + +if (failures.length) { + console.error("Public route baseline failures:\n" + failures.map((item) => `- ${item}`).join("\n")); + process.exit(1); +} + +const hashedRoutes = fixture.routes.filter((route) => route.sha256).length; +console.log( + `Checked ${fixture.routes.length} public routes, ${hashedRoutes} content hashes, and ${fixture.discoveryEndpoints?.length ?? 0} discovery endpoints against the ${fixture.capturedAt} baseline.`, +);