Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
13 changes: 11 additions & 2 deletions DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
26 changes: 26 additions & 0 deletions DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions config/devlog-releases.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -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": {
Expand Down
103 changes: 103 additions & 0 deletions scripts/verify-static-package.mjs
Original file line number Diff line number Diff line change
@@ -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;
});
}
60 changes: 60 additions & 0 deletions scripts/verify-static-package.test.mjs
Original file line number Diff line number Diff line change
@@ -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(`
<link rel="stylesheet" href="./assets/game.css">
<script src="https://example.com/analytics.js"></script>
<script type="module" src="./assets/game.js"></script>
`);

expect(references).toEqual(['./assets/game.css', './assets/game.js']);
});

it('accepts a complete portable package', () => {
const indexPath = createPackage(
'<link href="./assets/game.css" rel="stylesheet"><script src="./assets/game.js"></script>',
{ '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('<script src="./assets/index-missing.js"></script>');

expect(() => verifyLocalPackage(indexPath)).toThrow('missing or empty');
});

it('rejects root-relative assets that would break in an embedded upload', () => {
const indexPath = createPackage('<script src="/assets/game.js"></script>');

expect(() => verifyLocalPackage(indexPath)).toThrow('Root-relative asset path');
});
});
2 changes: 1 addition & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
Loading