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
4 changes: 4 additions & 0 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ jobs:

- name: Build site
run: npm run build
env:
PIXELFORGE_BUILD_SHA: ${{ github.sha }}

- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v5
Expand Down Expand Up @@ -80,3 +82,5 @@ jobs:

- name: Post-deploy health check
run: node scripts/check-deploy-health.mjs "${{ needs.deploy.outputs.page_url }}"
env:
PIXELFORGE_EXPECTED_BUILD: ${{ github.sha }}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Browser visual smoke (builds, serves the preview, and drives headless Chrome acr
npm run smoke:visual
```

CI on `main` runs lint, tests, build, the bundle budget, and the browser visual smoke. Deployment to GitHub Pages is handled by `.github/workflows/deploy-pages.yml` after CI succeeds on `main`, with a manual dispatch fallback; a post-deploy health check then verifies the published page and its hashed script/stylesheet assets (`npm run check:deploy` runs the same check locally).
CI on `main` runs lint, tests, build, the bundle budget, and the browser visual smoke. Deployment to GitHub Pages is handled by `.github/workflows/deploy-pages.yml` after CI succeeds on `main`, with a manual dispatch fallback; a post-deploy health check then verifies the published page carries the build stamp for the deployed commit (`<meta name="pixelforge-build">`, injected at build time) and that its hashed script/stylesheet assets load (`npm run check:deploy` runs the same check locally; pass `--expect-build <sha>` to enforce the stamp).

## Security notes

Expand Down
10 changes: 7 additions & 3 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,13 +201,14 @@ PixelForge can generate images from a text prompt directly into a new layer. You
2. Click **Settings** (opens the AI Settings modal).
3. Paste your **Anthropic API key** (used to refine the prompt).
4. Paste your **Replicate API key** (used to render the image).
5. Click **Save**. Keys are stored in this browser's local storage only.
5. Paste the URL of a **CORS proxy** you run yourself that forwards requests to `api.replicate.com`. Replicate's API does not send CORS headers, so the browser cannot call it directly; without a proxy every generation fails at the request step.
6. Click **Save**. Keys are stored in this tab's session storage only and are cleared when the tab closes.

Get keys from:
- **Anthropic:** https://console.anthropic.com/ → API Keys
- **Replicate:** https://replicate.com/account/api-tokens

Your keys never leave your browser, never enter `.pforge` save files, and never enter autosaved drafts.
Your keys never enter `.pforge` save files or autosaved drafts. The Anthropic key goes only to `api.anthropic.com`. The Replicate key transits the CORS proxy you configured, so only use a proxy you control.

### Generating

Expand Down Expand Up @@ -343,7 +344,10 @@ Check that the active layer is **visible** (eye icon not crossed out) and has **
The Brush works only on raster layers. Add one via **+ Raster** in the Layers section, or let PixelForge auto-switch by clicking the highlighted layer.

**"AI Generate says 'Set your API keys'."**
Open **✨ Generate → Settings** and paste both keys. Keys are stored in your browser's local storage.
Open **✨ Generate → Settings** and paste both keys. Keys are stored in this tab's session storage, so a new tab or a restarted browser needs them again.

**"AI generation says it could not reach Replicate."**
Replicate blocks direct browser calls. Set the CORS proxy URL in AI Settings to a proxy you run that forwards to `api.replicate.com`.

**"AI generation failed."**
- Check your key validity on the provider dashboard
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
"budget": "node scripts/check-bundle-size.mjs",
"smoke:visual": "node scripts/run-visual-smoke.mjs",
"check:deploy": "node scripts/check-deploy-health.mjs",
"ops:state": "node tools/ops/build-ops-state.mjs",
"ops:validate": "node tools/ops/validate-ops-state.mjs",
"ci": "node scripts/verify-runtime.mjs && npm run lint && npm run test && npm run build && npm run budget"
},
"dependencies": {
Expand Down
79 changes: 63 additions & 16 deletions scripts/check-deploy-health.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,40 @@
//
// Fetches the deployed page, confirms the app shell markup is present, then
// fetches the hashed module script and stylesheet the page references so a
// broken asset upload cannot pass. Retries to ride out Pages propagation.
// broken asset upload cannot pass. When an expected build stamp is supplied
// (PIXELFORGE_EXPECTED_BUILD or --expect-build), the page must carry that
// stamp in its <meta name="pixelforge-build"> tag, so a stale or partial
// deployment cannot pass just because some earlier build is still served.
// Retries to ride out Pages propagation.
//
// Usage: node scripts/check-deploy-health.mjs [baseUrl]
// Usage: node scripts/check-deploy-health.mjs [baseUrl] [--expect-build <sha>]
// baseUrl defaults to PIXELFORGE_DEPLOY_URL or the live GitHub Pages origin.

import process from "node:process";
import { fileURLToPath } from "node:url";
import { resolve } from "node:path";

const baseUrl = normalizeBase(
process.argv[2] || process.env.PIXELFORGE_DEPLOY_URL || "https://davehomeassist.github.io/PixelForge/",
);
const maxAttempts = Number(process.env.PIXELFORGE_HEALTH_ATTEMPTS || 10);
const retryDelayMs = Number(process.env.PIXELFORGE_HEALTH_RETRY_MS || 6000);
export const BUILD_META_NAME = "pixelforge-build";

function normalizeBase(url) {
export function normalizeBase(url) {
return url.endsWith("/") ? url : `${url}/`;
}

export function parseArgs(argv) {
const positional = [];
let expectedBuild = null;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--expect-build") {
expectedBuild = argv[index + 1] || null;
index += 1;
} else {
positional.push(arg);
}
}
return { baseUrl: positional[0] || null, expectedBuild };
}

async function fetchOk(url, accept) {
const response = await fetch(url, { redirect: "follow", headers: { accept } });
if (!response.ok) {
Expand All @@ -31,7 +48,7 @@ async function fetchOk(url, accept) {
return { body, contentType: response.headers.get("content-type") || "" };
}

function extractSameOriginAssets(html) {
export function extractSameOriginAssets(html, baseUrl) {
const assets = [];
const patterns = [
{ kind: "script", regex: /<script[^>]*type="module"[^>]*src="([^"]+)"/g },
Expand All @@ -48,7 +65,13 @@ function extractSameOriginAssets(html) {
return assets;
}

async function checkOnce() {
export function extractBuildStamp(html) {
const match = html.match(/<meta\s+name="pixelforge-build"\s+content="([^"]*)"/i)
|| html.match(/<meta\s+content="([^"]*)"\s+name="pixelforge-build"/i);
return match ? match[1] : null;
}

export async function checkOnce({ baseUrl, expectedBuild }) {
const page = await fetchOk(baseUrl, "text/html");
if (!page.contentType.includes("text/html")) {
throw new Error(`Expected text/html from ${baseUrl}, got ${page.contentType}`);
Expand All @@ -61,7 +84,20 @@ async function checkOnce() {
}
console.log(`[health] PASS page | ${baseUrl}`);

const assets = extractSameOriginAssets(page.body);
const stamp = extractBuildStamp(page.body);
if (expectedBuild) {
if (!stamp) {
throw new Error(`Page at ${baseUrl} carries no ${BUILD_META_NAME} stamp; expected ${expectedBuild}`);
}
if (stamp !== expectedBuild) {
throw new Error(`Page at ${baseUrl} is build ${stamp}, expected ${expectedBuild}`);
}
console.log(`[health] PASS build | ${stamp}`);
} else {
console.log(`[health] INFO build | ${stamp || "unstamped"} (no expected build supplied, not enforced)`);
}

const assets = extractSameOriginAssets(page.body, baseUrl);
const hasScript = assets.some(asset => asset.kind === "script");
const hasStylesheet = assets.some(asset => asset.kind === "stylesheet");
if (!hasScript || !hasStylesheet) {
Expand All @@ -81,10 +117,18 @@ async function checkOnce() {
}

async function main() {
const args = parseArgs(process.argv.slice(2));
const baseUrl = normalizeBase(
args.baseUrl || process.env.PIXELFORGE_DEPLOY_URL || "https://davehomeassist.github.io/PixelForge/",
);
const expectedBuild = (args.expectedBuild || process.env.PIXELFORGE_EXPECTED_BUILD || "").trim() || null;
const maxAttempts = Number(process.env.PIXELFORGE_HEALTH_ATTEMPTS || 10);
const retryDelayMs = Number(process.env.PIXELFORGE_HEALTH_RETRY_MS || 6000);

let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await checkOnce();
await checkOnce({ baseUrl, expectedBuild });
console.log(`[health] Deployment healthy at ${baseUrl}`);
return;
} catch (error) {
Expand All @@ -98,7 +142,10 @@ async function main() {
throw lastError;
}

main().catch(error => {
console.error("[health] Failed:", error.message);
process.exitCode = 1;
});
const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (invokedDirectly) {
main().catch(error => {
console.error("[health] Failed:", error.message);
process.exitCode = 1;
});
}
1 change: 1 addition & 0 deletions src/AppPages.css
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@
.pf-quick-card strong, .pf-template-card strong, .pf-recent-file strong, .pf-preset-card strong { font-size: 14px; }
.pf-quick-card small, .pf-template-card small, .pf-template-card em, .pf-recent-file small { color: var(--page-muted); font-size: 12px; font-style: normal; }
.pf-recent-list { display: grid; gap: 8px; }
.pf-recent-empty { margin: 0; padding: 14px 16px; border: 1px dashed var(--page-line-strong); border-radius: 10px; color: var(--page-muted); font-size: 13px; }
.pf-recent-file { display: grid; grid-template-columns: auto minmax(0,1fr) auto auto; align-items: center; gap: 12px; padding: 11px 12px; text-align: left; }
.pf-recent-file > span:nth-child(3), .pf-recent-file > span:nth-child(4) { display: inline-flex; align-items: center; gap: 6px; color: var(--page-muted); font-size: 12px; }
.pf-file-thumb { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 7px; background: rgba(93,159,216,0.16); color: var(--page-blue); }
Expand Down
15 changes: 13 additions & 2 deletions src/PixelForge.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
cloneShape, mergePrefs, getToolRequirement, isToolCompatibleWithLayer, normalizePanelTab,
} from "./utils.js";
import { renderEditor } from "./render.js";
import { consumeLaunchIntent } from "./launchIntent.js";
import { commitFloat } from "./marquee.js";
import { cropToRect, trimTransparent, rotateCanvas, flipCanvas } from "./canvasOps.js";
import { hitShape } from "./shapes.js";
Expand Down Expand Up @@ -715,8 +716,18 @@ export default function PixelForge() {

/* ─── Init ─── */
useEffect(() => {
resetDocument();
}, [resetDocument]);
const intent = consumeLaunchIntent();
if (!intent) {
resetDocument();
return;
}
resetDocument(intent.width, intent.height, intent.background || DEFAULT_BG);
if (!intent.backgroundSupported) {
requestAnimationFrame(() => {
flash(`Background "${intent.requestedBackground}" is not supported yet. Opened with a white background.`, "info", 3200);
});
}
}, [flash, resetDocument]);

/* ─── Render ─── */
const renderFrame = useEffectEvent(() => {
Expand Down
99 changes: 99 additions & 0 deletions src/__tests__/deployHealth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it } from "vitest";
import { createServer } from "node:http";
import {
checkOnce,
extractBuildStamp,
extractSameOriginAssets,
parseArgs,
} from "../../scripts/check-deploy-health.mjs";

const PAGE = (stamp) => `<!doctype html><html><head>
<meta name="pixelforge-build" content="${stamp}">
<title>PixelForge</title>
<script type="module" crossorigin src="/PixelForge/assets/index-abc.js"></script>
<link rel="stylesheet" crossorigin href="/PixelForge/assets/index-abc.css">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter">
</head><body><div id="root"></div></body></html>`;

function serve(routes) {
const server = createServer((req, res) => {
const route = routes[req.url];
if (!route) {
res.statusCode = 404;
res.end("missing");
return;
}
res.setHeader("content-type", route.type);
res.end(route.body);
});
return new Promise(resolve => {
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
resolve({ server, baseUrl: `http://127.0.0.1:${port}/PixelForge/` });
});
});
}

describe("check-deploy-health parsing", () => {
it("parses positional base url and expected build flag", () => {
expect(parseArgs(["https://example.test/PixelForge/", "--expect-build", "abc123"])).toEqual({
baseUrl: "https://example.test/PixelForge/",
expectedBuild: "abc123",
});
expect(parseArgs([])).toEqual({ baseUrl: null, expectedBuild: null });
});

it("extracts the build stamp and only same-origin assets", () => {
const html = PAGE("deadbeef");
expect(extractBuildStamp(html)).toBe("deadbeef");
expect(extractBuildStamp("<html><head></head></html>")).toBeNull();
const assets = extractSameOriginAssets(html, "https://example.test/PixelForge/");
expect(assets.map(asset => asset.kind)).toEqual(["script", "stylesheet"]);
expect(assets.every(asset => asset.url.startsWith("https://example.test/"))).toBe(true);
});
});

describe("check-deploy-health against a served page", () => {
let active = null;
afterEach(async () => {
if (active) await new Promise(resolve => active.close(resolve));
active = null;
});

async function serveBuild(stamp) {
const served = await serve({
"/PixelForge/": { type: "text/html; charset=utf-8", body: PAGE(stamp) },
"/PixelForge/assets/index-abc.js": { type: "text/javascript", body: "console.log(1)" },
"/PixelForge/assets/index-abc.css": { type: "text/css", body: "body{}" },
});
active = served.server;
return served.baseUrl;
}

it("passes when the served build matches the expected stamp", async () => {
const baseUrl = await serveBuild("abc123");
await expect(checkOnce({ baseUrl, expectedBuild: "abc123" })).resolves.toBeUndefined();
});

it("fails when the served build is stale", async () => {
const baseUrl = await serveBuild("older");
await expect(checkOnce({ baseUrl, expectedBuild: "abc123" })).rejects.toThrow(/is build older, expected abc123/);
});

it("fails when the page carries no stamp but one is expected", async () => {
const served = await serve({
"/PixelForge/": { type: "text/html", body: PAGE("x").replace(/<meta name="pixelforge-build"[^>]*>/, "") },
});
active = served.server;
await expect(checkOnce({ baseUrl: served.baseUrl, expectedBuild: "abc123" })).rejects.toThrow(/carries no pixelforge-build stamp/);
});

it("fails when a referenced asset is missing", async () => {
const served = await serve({
"/PixelForge/": { type: "text/html", body: PAGE("abc123") },
"/PixelForge/assets/index-abc.css": { type: "text/css", body: "body{}" },
});
active = served.server;
await expect(checkOnce({ baseUrl: served.baseUrl, expectedBuild: null })).rejects.toThrow(/index-abc\.js returned 404/);
});
});
Loading
Loading