diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index 76707a8..4cea05e 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -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
@@ -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 }}
diff --git a/README.md b/README.md
index cfb8016..e42b686 100644
--- a/README.md
+++ b/README.md
@@ -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 (``, injected at build time) and that its hashed script/stylesheet assets load (`npm run check:deploy` runs the same check locally; pass `--expect-build ` to enforce the stamp).
## Security notes
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index a6a5fe7..c46893d 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -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
@@ -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
diff --git a/package.json b/package.json
index c2bd376..325bec0 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/scripts/check-deploy-health.mjs b/scripts/check-deploy-health.mjs
index 65af170..9d66c03 100644
--- a/scripts/check-deploy-health.mjs
+++ b/scripts/check-deploy-health.mjs
@@ -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 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 ]
// 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) {
@@ -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: /
+
+
+