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
85 changes: 85 additions & 0 deletions .github/workflows/deploy-smoke.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
name: 🚦 Deploy smoke test

# Checks that Reframe is served with the headers it depends on: COOP/COEP for
# cross-origin isolation (multi-threaded FFmpeg) plus the security headers,
# correct MIME types and immutable caching for hashed assets.
#
# No secrets, no tokens, read-only permissions:
# * static-export builds the site and serves out/ locally with the headers
# declared in vercel.json (kept identical to netlify.toml and
# public/_headers by deployConfig.test.ts), then runs the deploy check.
# * deployed runs the same check against a real Netlify deploy preview once
# Netlify reports it ready, or against any URL given by hand. Note that
# GitHub only runs `status`-triggered workflows from the default branch, so
# this job starts working once this file is on main.

on:
pull_request:
push:
branches: [main]
status:
workflow_dispatch:
inputs:
url:
description: "Deployed URL to check (https://…)"
required: true

permissions:
contents: read

jobs:
static-export:
if: github.event_name == 'pull_request' || github.event_name == 'push'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6

- uses: oven-sh/setup-bun@v2

- run: bun install --frozen-lockfile

# No Datadog keys exist here, so RUM can't start in this build.
- run: bun run build

- name: Serve out/ with the declared headers
run: |
node scripts/serve-isolated.mjs 4300 &
for _ in $(seq 1 30); do curl -sf -o /dev/null http://127.0.0.1:4300/ && exit 0; sleep 1; done
echo "server did not start" && exit 1

# GITHUB_SHA is the commit next.config.ts stamped into the build.
- name: Check headers, MIME types, caching and the build commit
run: node scripts/deploy/check-deploy.mjs http://127.0.0.1:4300/ --expect-commit "$GITHUB_SHA"

deployed:
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'status' &&
github.event.state == 'success' &&
startsWith(github.event.context, 'netlify/'))
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6

- name: Check the deployed URL
env:
# Passed through the environment, never interpolated into the script.
URL: ${{ github.event.inputs.url || github.event.target_url }}
run: |
case "$URL" in
https://*) ;;
*) echo "Not an https URL; nothing to check."; exit 0 ;;
esac
set +e
node scripts/deploy/check-deploy.mjs "$URL"
code=$?
if [ "$code" = 3 ]; then
echo "::notice::The deployment requires authentication, so its headers were not checked."
exit 0
fi
if [ "$code" = 4 ]; then
echo "::error::The deployment could not be reached, so nothing was checked."
fi
exit $code
33 changes: 31 additions & 2 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,39 @@ Netlify badge and brand logos are served from this site instead of hotlinked.
Check a deployment with:

```bash
curl -sI https://your-site.example | grep -i cross-origin
node scripts/deploy/check-deploy.mjs https://your-site.example [--expect-commit <sha>]
```

and, in the browser console on the site, `crossOriginIsolated` should be `true`.
It requests the page, a hashed script, the preloaded font, the FFmpeg worker
chunk, the engine's WASM on jsDelivr, the trailing-slash redirect and a 404,
and checks the headers, MIME types and caching on each. With
`--expect-commit`, it also checks that the site serves that commit (each
build stamps it in `<meta name="reframe-build">`), which catches a stale
deploy. It exits 0 when everything passes, 1 on a failure, 3 when the
deployment needs a login and 4 when it can't be reached. In the browser
console on the site, `crossOriginIsolated` should be `true`.

`deploy-smoke.yml` runs this check on every pull request against a local
build, and against each Netlify deploy preview once Netlify reports it
ready.

## The other headers

Every host sends the same set (`netlify.toml`, `public/_headers` and
`vercel.json`, kept identical by `src/lib/tests/deployConfig.test.ts`):

- `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`,
`Referrer-Policy` and `Permissions-Policy` (no camera, microphone or
location).
- `Cross-Origin-Resource-Policy: same-origin`: other sites can't embed
Reframe's files.
- `Content-Security-Policy: base-uri 'self'; object-src 'none';
frame-ancestors 'none'; form-action 'self'`. It has no fetch directives,
so it can't block the FFmpeg cores or analytics; it stops framing,
plugins, `<base>` hijacking and off-site form posts.
- `Cache-Control: public, max-age=31536000, immutable` on `/_next/static/*`,
whose file names change with their content. Pages are revalidated on
every visit.

## Netlify (recommended)

Expand Down
13 changes: 13 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,19 @@
Strict-Transport-Security = "max-age=31536000; includeSubDomains"
Cross-Origin-Opener-Policy = "same-origin"
Cross-Origin-Embedder-Policy = "require-corp"
Cross-Origin-Resource-Policy = "same-origin"
# No fetch directives (script-src, connect-src …), so nothing the app
# loads (the FFmpeg cores, Datadog RUM) can be blocked; it only stops
# framing, plugins, <base> hijacking and off-site form posts.
Content-Security-Policy = "base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'"

# Hashed build assets never change, so browsers may keep them for a year.
# Declared here rather than relying on @netlify/plugin-nextjs, which was what
# set this before: a deploy without the plugin served them with max-age=0.
[[headers]]
for = "/_next/static/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"

# The service worker (public/sw.js) must always be re-checked, or browsers
# keep running an old one after a deploy. Its content changes with every
Expand Down
6 changes: 6 additions & 0 deletions public/_headers
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
Strict-Transport-Security: max-age=31536000; includeSubDomains
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: same-origin
Content-Security-Policy: base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'

# Hashed build assets never change (same rule as netlify.toml).
/_next/static/*
Cache-Control: public, max-age=31536000, immutable

# The service worker must always be re-checked (see netlify.toml).
/sw.js
Expand Down
132 changes: 132 additions & 0 deletions scripts/deploy/check-deploy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Checks a deployed (or locally served) Reframe site with real HTTP requests:
// security and cross-origin-isolation headers, MIME types and caching on the
// HTML page, a hashed JS chunk, the preloaded font, the FFmpeg worker chunk
// and a 404; the trailing-slash redirect; CORS on the jsDelivr WASM the
// worker loads; and, with --expect-commit, that the build is the right one.
// Rules live in lib/deployCheck.mjs.
//
// Usage: node scripts/deploy/check-deploy.mjs <url> [--expect-commit <sha>] [--json]
// Exit codes:
// 0 pass · 1 fail (including a stale/wrong commit) · 2 usage error
// 3 protected: the deployment requires authentication, nothing was checked
// 4 unavailable: the site couldn't be reached (DNS, network, 5xx on the page)
// For an SSO-protected Vercel preview, set VERCEL_AUTOMATION_BYPASS_SECRET
// (Project → Settings → Deployment Protection → Protection Bypass for
// Automation). It is sent as a header and never printed.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
RESULT,
checkResponse,
commitCheck,
discoverFromHtml,
exitCode,
expectedHeaders,
isWorkerChunk,
lazyChunksFromRuntime,
overallResult,
wasmUrlFromWorker,
} from "./lib/deployCheck.mjs";

const args = process.argv.slice(2);
const json = args.includes("--json");
const commitIdx = args.indexOf("--expect-commit");
const expectCommit = commitIdx >= 0 ? args[commitIdx + 1] : null;
const target = args.find((a, i) => !a.startsWith("--") && (commitIdx < 0 || i !== commitIdx + 1));

let base;
try {
base = new URL(target);
} catch {
base = null;
}
if (!base || !/^https?:$/.test(base.protocol) || (commitIdx >= 0 && !expectCommit)) {
console.error("usage: node scripts/deploy/check-deploy.mjs <http(s) url> [--expect-commit <sha>] [--json]");
process.exit(2);
}

const vercelJson = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../vercel.json");
const expected = expectedHeaders(fs.readFileSync(vercelJson, "utf8"));

const requestHeaders = {};
if (process.env.VERCEL_AUTOMATION_BYPASS_SECRET) {
requestHeaders["x-vercel-protection-bypass"] = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
}

const checks = [];
const log = (line) => {
if (!json) console.log(line);
};

async function request(url, method = "GET") {
try {
const res = await fetch(url, { method, headers: requestHeaders, redirect: "manual" });
return { res, body: method === "GET" ? await res.text() : "" };
} catch (err) {
return { error: err instanceof Error ? err.cause?.code ?? err.message : String(err) };
}
}

async function check(kind, url, method) {
const { res, body, error } = await request(url, method);
const outcome = error
? { kind, url, status: null, result: RESULT.UNAVAILABLE, problems: [], notes: [`unreachable: ${error}`] }
: checkResponse({ kind, url, status: res.status, headers: res.headers, expected, location: res.headers.get("location") });
checks.push(outcome);
const mark = { pass: "ok ", fail: "FAIL", protected: "AUTH", unavailable: "DOWN" }[outcome.result];
log(`${mark} ${kind.padEnd(9)} ${outcome.status ?? "---"} ${url}`);
for (const p of outcome.problems) log(` ✗ ${p}`);
for (const n of outcome.notes) log(` · ${n}`);
return { outcome, body };
}

const page = await check("document", base.href);
let commit = null;
if (page.outcome.result !== RESULT.PROTECTED && page.outcome.result !== RESULT.UNAVAILABLE && page.outcome.status === 200) {
const found = discoverFromHtml(page.body);
commit = commitCheck(found.commit, expectCommit);
log(`${commit.result === RESULT.PASS ? "ok " : "FAIL"} commit ${commit.note}`);

if (found.script) await check("script", new URL(found.script, base).href);
if (found.font) await check("font", new URL(found.font, base).href);
else log(" font (no preloaded font in the page; skipped)");

let workerJs = null;
if (found.runtime) {
const runtime = await request(new URL(found.runtime, base).href);
for (const chunk of lazyChunksFromRuntime(runtime.body ?? "").slice(0, 40)) {
const probe = await request(new URL(chunk, base).href);
if (probe.body && isWorkerChunk(probe.body)) {
workerJs = (await check("worker", new URL(chunk, base).href)).body;
break;
}
}
}
if (!workerJs) {
checks.push({ kind: "worker", result: RESULT.FAIL, problems: ["FFmpeg worker chunk not found via the webpack runtime"], notes: [] });
log("FAIL worker FFmpeg worker chunk not found via the webpack runtime");
} else {
const wasm = wasmUrlFromWorker(workerJs);
if (wasm) await check("wasm", wasm, "HEAD");
}

await check("redirect", new URL("/privacy/", base).href);
await check("notFound", new URL("/__reframe-deploy-check-404", base).href);
}

const results = checks.map((c) => c.result);
if (commit) results.push(commit.result);
if (page.outcome.status && page.outcome.status >= 500) results.push(RESULT.UNAVAILABLE);
const overall = overallResult(results);

const summary = {
pass: "All checks passed.",
fail: "Some checks failed.",
protected: "Not checked: the deployment requires authentication (set VERCEL_AUTOMATION_BYPASS_SECRET for Vercel previews).",
unavailable: "Not checked: the deployment could not be reached.",
}[overall];

if (json) console.log(JSON.stringify({ url: base.href, result: overall, commit, checks }, null, 2));
else console.log(`\n${summary}`);
process.exit(exitCode(overall));
Loading
Loading