From 60b74bdbc3d0b1a61a35d391db6e187456e45057 Mon Sep 17 00:00:00 2001 From: Ari Bradshaw Date: Thu, 20 Aug 2026 17:14:33 -0700 Subject: [PATCH] Guard HTML5 release packages --- .github/workflows/pages.yml | 1 + .github/workflows/quality.yml | 1 + DEPLOYMENT.md | 13 +++- DEVLOG.md | 26 +++++++ README.md | 3 +- config/devlog-releases.json | 11 +++ package-lock.json | 4 +- package.json | 3 +- scripts/verify-static-package.mjs | 103 +++++++++++++++++++++++++ scripts/verify-static-package.test.mjs | 60 ++++++++++++++ vitest.config.ts | 2 +- 11 files changed, 220 insertions(+), 7 deletions(-) create mode 100644 scripts/verify-static-package.mjs create mode 100644 scripts/verify-static-package.test.mjs diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 8b72d70..6839866 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -31,6 +31,7 @@ jobs: - run: npm ci - run: npm run release:verify - run: npm run build + - run: npm run verify:static - uses: actions/configure-pages@v5 - uses: actions/upload-pages-artifact@v3 with: diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 14abc12..52c3d6e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -25,5 +25,6 @@ jobs: - run: npm run lint - run: npm test - run: npm run build + - run: npm run verify:static - run: npx playwright install --with-deps chromium - run: npm run test:e2e diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 0276a2a..0173914 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -19,15 +19,24 @@ This creates a `dist` folder with all production-ready files. The live project is `aribradshaw/1976`. Use itch.io's official Butler uploader so releases are incremental and versioned. 1. Build and verify the project. + ```bash + npm run build + npm run verify:static + ``` 2. Authenticate once with `butler login`. 3. Push the built directory as the HTML5 channel: ```bash -butler push dist aribradshaw/1976:html5 --userversion 2.7.10 +butler push dist aribradshaw/1976:html5 --userversion 2.7.13 ``` -4. In the itch.io edit page, mark the `html5` upload as **This file will be played in the browser** and save. +4. In the itch.io edit page, mark only the current `html5` Butler upload as **This file will be played in the browser**, hide superseded manual ZIP uploads, and save. 5. Verify the public game at `https://aribradshaw.itch.io/1976` in both desktop and mobile layouts. +6. Verify the exact public iframe and all of its entrypoint assets: + +```bash +npm run verify:static -- --url=https://html-classic.itch.zone/html/UPLOAD-BUILD/index.html +``` Use `butler status aribradshaw/1976:html5` to confirm the processed build and displayed version. diff --git a/DEVLOG.md b/DEVLOG.md index 3674bfb..cb495ba 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -7,6 +7,32 @@ Production-style releases use the same Arizona-calendar versioning policy as Cam - The first live push in a new year advances the major number, sets minor to the Arizona calendar month minus one, and resets patch to `1`. - Rebuilds of the same commit do not create another release. +## 2.7.13, August 20, 2026 + +Playable HTML5 release guard + +- Repaired the itch.io browser build by promoting the complete Butler upload and hiding the obsolete manual ZIP whose hashed assets were missing. +- Added a static-package verifier that rejects missing entrypoint assets, empty files, directory escapes, and root-relative paths that break inside hosted subdirectories. +- Added the package-integrity check to both the GitHub quality gate and GitHub Pages deployment before anything is published. +- Documented the one-browser-upload rule and exact public iframe verification step for future itch.io releases. + +## 2.7.12, August 19, 2026 + +Keep release history verifiable + +- Moved shared release metadata onto the open-source DevLog package without changing gameplay or the election-broadcast presentation. +- Blocked future DevLog package updates unless the game version and public release record advance together. + +## 2.7.11, August 18, 2026 + +Readable decisions and the election-night desk + +- Brought campaign day labels, historical decision effects, and source links to WCAG AA contrast on dark broadcast panels and warm editorial paper. +- Contained election night in a two-column desktop spread that keeps the scoreboard, outcome, and latest state calls inside the results desk. +- Added a native public DevLog with the full release history, playable return path, GitHub star prompt, and contributor entry point. +- Expanded automated contrast coverage across setup, planning, reports, forecasts, decisions, interviews, recaps, election night, and the DevLog. +- Added a deterministic 50-second trailer capture workflow. + ## 2.7.10, August 18, 2026 Mobile campaign tour and state drill-in diff --git a/README.md b/README.md index 54d75eb..3234233 100644 --- a/README.md +++ b/README.md @@ -63,10 +63,11 @@ npm run release:verify npm run lint npm test npm run build +npm run verify:static npm run test:e2e ``` -Version 2.7.10 passes 33 deterministic and unit tests plus 17 shipped Chromium journeys. Browser coverage includes setup, the responsive spotlight tutorial, the two-family typography and spacing system, short-screen and receiver breakpoint behavior, portrait-safe candidate cards, the weekly decision and recap loop, autosave and resume, keyboard setup, persisted accessibility settings, State Table drill-in, and complete 25-week campaigns at 320x568, 360x640, 390x844, 430x932, and 768x1024 through election night. The complete dependency tree audits cleanly. +Version 2.7.13 passes 37 deterministic and unit tests plus 19 shipped Chromium journeys. Browser coverage includes setup, the responsive spotlight tutorial, the two-family typography and spacing system, short-screen and receiver breakpoint behavior, portrait-safe candidate cards, the weekly decision and recap loop, autosave and resume, keyboard setup, persisted accessibility settings, State Table drill-in, and complete 25-week campaigns at 320x568, 360x640, 390x844, 430x932, and 768x1024 through election night. The release gate also verifies that every script and stylesheet referenced by the static HTML5 entrypoint exists and uses portable relative paths. The complete dependency tree audits cleanly. ## Architecture diff --git a/config/devlog-releases.json b/config/devlog-releases.json index 72a428d..3eb96e5 100644 --- a/config/devlog-releases.json +++ b/config/devlog-releases.json @@ -1,4 +1,15 @@ [ + { + "version": "2.7.13", + "date": "2026-08-20", + "title": "Guard the playable HTML5 package", + "notes": [ + "Repair the itch.io browser build by promoting the complete Butler upload and hiding the obsolete manual ZIP whose hashed assets were missing.", + "Reject release packages with missing entrypoint assets, empty files, directory escapes, or root-relative paths that fail inside hosted subdirectories.", + "Run the static-package integrity check in both the GitHub quality gate and GitHub Pages deployment before publishing.", + "Document the one-browser-upload rule and exact public iframe verification step for future itch.io releases." + ] + }, { "version": "2.7.12", "date": "2026-08-19", diff --git a/package-lock.json b/package-lock.json index 75d157a..3463a4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1976-election-sim", - "version": "2.7.12", + "version": "2.7.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1976-election-sim", - "version": "2.7.12", + "version": "2.7.13", "dependencies": { "@aribradshaw/devlog": "1.0.2", "@fontsource-variable/source-sans-3": "^5.3.0", diff --git a/package.json b/package.json index f9d67dc..f98ba71 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "1976-election-sim", - "version": "2.7.12", + "version": "2.7.13", "description": "A playable historical strategy game about the Carter-Ford 1976 presidential campaign", "type": "module", "engines": { @@ -16,6 +16,7 @@ "test:e2e:install": "playwright install chromium", "trailer:capture": "node scripts/capture-trailer.mjs", "release:verify": "node scripts/verify-devlog-release.mjs", + "verify:static": "node scripts/verify-static-package.mjs", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { diff --git a/scripts/verify-static-package.mjs b/scripts/verify-static-package.mjs new file mode 100644 index 0000000..38a1e7f --- /dev/null +++ b/scripts/verify-static-package.mjs @@ -0,0 +1,103 @@ +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const ATTRIBUTE_PATTERN = /<(?:script|link)\b[^>]*?\b(?:src|href)\s*=\s*["']([^"']+)["'][^>]*>/gi; + +export function collectStaticReferences(html) { + return [...html.matchAll(ATTRIBUTE_PATTERN)] + .map((match) => match[1].trim()) + .filter((reference) => reference.length > 0) + .filter((reference) => !/^(?:[a-z]+:|\/\/|#)/i.test(reference)); +} + +function assertRelativeReference(reference) { + if (reference.startsWith('/')) { + throw new Error(`Root-relative asset path is not portable: ${reference}`); + } +} + +function assertInsideDirectory(filePath, directory) { + const resolvedDirectory = resolve(directory); + const resolvedFile = resolve(filePath); + const directoryPrefix = `${resolvedDirectory}${sep}`; + + if (resolvedFile !== resolvedDirectory && !resolvedFile.startsWith(directoryPrefix)) { + throw new Error(`Asset path escapes the package directory: ${resolvedFile}`); + } +} + +export function verifyLocalPackage(indexPath = resolve('dist', 'index.html')) { + const resolvedIndex = resolve(indexPath); + const packageDirectory = resolve(resolvedIndex, '..'); + + if (!existsSync(resolvedIndex)) { + throw new Error(`Static entrypoint does not exist: ${resolvedIndex}`); + } + + const html = readFileSync(resolvedIndex, 'utf8'); + const references = collectStaticReferences(html); + + if (references.length === 0) { + throw new Error(`No local script or stylesheet references found in ${resolvedIndex}`); + } + + for (const reference of references) { + assertRelativeReference(reference); + const cleanReference = reference.split(/[?#]/, 1)[0]; + const assetPath = fileURLToPath(new URL(cleanReference, pathToFileURL(resolvedIndex))); + assertInsideDirectory(assetPath, packageDirectory); + + if (!existsSync(assetPath) || !statSync(assetPath).isFile() || statSync(assetPath).size === 0) { + throw new Error(`Referenced asset is missing or empty: ${reference}`); + } + } + + return { indexPath: resolvedIndex, references }; +} + +export async function verifyRemotePackage(indexUrl) { + const response = await fetch(indexUrl, { redirect: 'follow' }); + if (!response.ok) { + throw new Error(`Static entrypoint returned HTTP ${response.status}: ${indexUrl}`); + } + + const html = await response.text(); + const references = collectStaticReferences(html); + + if (references.length === 0) { + throw new Error(`No local script or stylesheet references found at ${indexUrl}`); + } + + for (const reference of references) { + assertRelativeReference(reference); + const assetUrl = new URL(reference, response.url).toString(); + const assetResponse = await fetch(assetUrl, { redirect: 'follow' }); + + if (!assetResponse.ok) { + throw new Error(`Referenced asset returned HTTP ${assetResponse.status}: ${assetUrl}`); + } + } + + return { indexUrl: response.url, references }; +} + +async function main() { + const urlArgument = process.argv.find((argument) => argument.startsWith('--url=')); + const result = urlArgument + ? await verifyRemotePackage(urlArgument.slice('--url='.length)) + : verifyLocalPackage(process.argv[2]); + + const target = 'indexUrl' in result ? result.indexUrl : result.indexPath; + console.log(`Static package verified: ${result.references.length} assets reachable from ${target}`); +} + +const isCommandLine = process.argv[1] + && import.meta.url === pathToFileURL(resolve(process.argv[1])).href; + +if (isCommandLine) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/scripts/verify-static-package.test.mjs b/scripts/verify-static-package.test.mjs new file mode 100644 index 0000000..91e349e --- /dev/null +++ b/scripts/verify-static-package.test.mjs @@ -0,0 +1,60 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { collectStaticReferences, verifyLocalPackage } from './verify-static-package.mjs'; + +const temporaryDirectories = []; + +function createPackage(indexHtml, assets = {}) { + const directory = mkdtempSync(join(tmpdir(), '1976-static-package-')); + temporaryDirectories.push(directory); + writeFileSync(join(directory, 'index.html'), indexHtml); + + for (const [assetPath, contents] of Object.entries(assets)) { + const fullPath = join(directory, assetPath); + mkdirSync(join(fullPath, '..'), { recursive: true }); + writeFileSync(fullPath, contents); + } + + return join(directory, 'index.html'); +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +describe('static HTML5 package verification', () => { + it('collects local scripts and stylesheets without external resources', () => { + const references = collectStaticReferences(` + + + + `); + + expect(references).toEqual(['./assets/game.css', './assets/game.js']); + }); + + it('accepts a complete portable package', () => { + const indexPath = createPackage( + '', + { 'assets/game.css': 'body {}', 'assets/game.js': 'console.log("1976")' }, + ); + + expect(verifyLocalPackage(indexPath).references).toHaveLength(2); + }); + + it('rejects a missing hashed asset', () => { + const indexPath = createPackage(''); + + expect(() => verifyLocalPackage(indexPath)).toThrow('missing or empty'); + }); + + it('rejects root-relative assets that would break in an embedded upload', () => { + const indexPath = createPackage(''); + + expect(() => verifyLocalPackage(indexPath)).toThrow('Root-relative asset path'); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 58a72b8..10576c1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', - include: ['src/**/*.test.ts'], + include: ['src/**/*.test.ts', 'scripts/**/*.test.mjs'], clearMocks: true, }, });