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
10 changes: 9 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ on:
description: "Run hosted smoke checks after deploy"
required: true
type: boolean
default: false
default: true

permissions:
contents: read
Expand Down Expand Up @@ -82,12 +82,16 @@ jobs:
worker_origin="${{ vars.FORAGE_PRODUCTION_WORKER_ORIGIN }}"
pages_branch="${{ vars.FORAGE_PRODUCTION_PAGES_BRANCH }}"
default_pages_branch="main"
smoke_expect_production="true"
web_smoke_mode="public"
;;
staging)
web_origin="${{ vars.FORAGE_STAGING_WEB_ORIGIN }}"
worker_origin="${{ vars.FORAGE_STAGING_WORKER_ORIGIN }}"
pages_branch="${{ vars.FORAGE_STAGING_PAGES_BRANCH }}"
default_pages_branch="staging"
smoke_expect_production="false"
web_smoke_mode="access-protected"
;;
*)
echo "::error::Unsupported deployment environment: $environment_name"
Expand All @@ -114,6 +118,8 @@ jobs:
echo "web_origin=$web_origin"
echo "worker_origin=$worker_origin"
echo "pages_branch=$pages_branch"
echo "smoke_expect_production=$smoke_expect_production"
echo "web_smoke_mode=$web_smoke_mode"
} >> "$GITHUB_OUTPUT"

- name: Check infra formatting
Expand Down Expand Up @@ -145,4 +151,6 @@ jobs:
env:
FORAGE_WEB_ORIGIN: ${{ steps.resolve.outputs.web_origin }}
FORAGE_WORKER_ORIGIN: ${{ steps.resolve.outputs.worker_origin }}
FORAGE_SMOKE_EXPECT_PRODUCTION: ${{ steps.resolve.outputs.smoke_expect_production }}
FORAGE_WEB_SMOKE_MODE: ${{ steps.resolve.outputs.web_smoke_mode }}
run: pnpm smoke:hosted
3 changes: 2 additions & 1 deletion docs/17-ci-and-quality-gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ Current Worker API contract coverage:
Hosted smoke command:
- Script: `scripts/check-hosted-smoke.mjs`
- Command: `FORAGE_WEB_ORIGIN=https://forage.example.com FORAGE_WORKER_ORIGIN=https://api.forage.example.com pnpm smoke:hosted`
- Purpose: verify deployed Worker health, Worker CORS/preflight, Pages headers, CSP Worker origin, and basic rendered app HTML
- Purpose: verify deployed Worker health, Worker CORS/preflight, unauthenticated session shape, OAuth start redirect/PKCE setup, Pages headers, CSP Worker origin, and basic rendered app HTML
- Staging can use `FORAGE_WEB_SMOKE_MODE=access-protected` when the web hostname is behind Cloudflare Access.
- This is not part of the default CI gate because it requires live hosted domains.

Local developer hooks:
Expand Down
12 changes: 11 additions & 1 deletion docs/21-hosting-ui-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,17 @@ FORAGE_WORKER_ORIGIN=https://api.forage.example.com \
pnpm smoke:hosted
```

For staging, use the staging web and API origins and set `FORAGE_SMOKE_EXPECT_PRODUCTION=false`. This script verifies Worker health, CORS, preflight headers, Pages security headers, CSP Worker origin, and basic app HTML.
For staging, use the staging web and API origins, set `FORAGE_SMOKE_EXPECT_PRODUCTION=false`, and set `FORAGE_WEB_SMOKE_MODE=access-protected` when Cloudflare Access protects the staging web hostname:

```sh
FORAGE_WEB_ORIGIN=https://forage-staging.shrimpworks.dev \
FORAGE_WORKER_ORIGIN=https://api-staging.forage.shrimpworks.dev \
FORAGE_SMOKE_EXPECT_PRODUCTION=false \
FORAGE_WEB_SMOKE_MODE=access-protected \
pnpm smoke:hosted
```

This script verifies Worker health, CORS, unauthenticated session shape, GitHub OAuth start redirect/PKCE setup, preflight headers, and either public Pages HTML/security headers or Cloudflare Access protection depending on `FORAGE_WEB_SMOKE_MODE`.

Verify Worker health:
- `GET https://api.forage.example.com/api/health`
Expand Down
5 changes: 3 additions & 2 deletions docs/23-deployment-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Forage deploys through `.github/workflows/deploy.yml`. The workflow is manual-on
- `environment`: `staging` or `production`
- `deploy_worker`: deploy `apps/worker` with Wrangler
- `deploy_pages`: deploy `apps/web/dist` to Cloudflare Pages with Wrangler direct upload
- `run_hosted_smoke`: run `pnpm smoke:hosted` after deployment
- `run_hosted_smoke`: run `pnpm smoke:hosted` after deployment, enabled by default

## GitHub Repository Secrets

Expand Down Expand Up @@ -77,10 +77,11 @@ Hosted smoke:
FORAGE_WEB_ORIGIN=https://staging.forage.example.com \
FORAGE_WORKER_ORIGIN=https://api-staging.forage.example.com \
FORAGE_SMOKE_EXPECT_PRODUCTION=false \
FORAGE_WEB_SMOKE_MODE=access-protected \
pnpm smoke:hosted
```

Set `FORAGE_SMOKE_EXPECT_PRODUCTION=false` for staging because staging Worker config intentionally exposes non-secret setup diagnostics. Leave it unset for production so the smoke check verifies that production config hides those diagnostics.
The deploy workflow sets `FORAGE_SMOKE_EXPECT_PRODUCTION=false` and `FORAGE_WEB_SMOKE_MODE=access-protected` automatically for staging because staging Worker config intentionally exposes non-secret setup diagnostics and the staging web hostname is behind Cloudflare Access. For production, the workflow sets `FORAGE_SMOKE_EXPECT_PRODUCTION=true` and `FORAGE_WEB_SMOKE_MODE=public` so the smoke check verifies public Pages HTML/security headers and confirms production config hides diagnostics.

When using Cloudflare Pages custom branch domains, the staging branch name should match the OpenTofu environment key. For example, the `staging` environment maps to `staging.<pages_project_name>.pages.dev` and can be connected to a custom hostname such as `forage-staging.example.com`.

Expand Down
141 changes: 120 additions & 21 deletions scripts/check-hosted-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const untrustedOrigin = normalizeOrigin(
process.env.FORAGE_UNTRUSTED_ORIGIN ?? "https://forage-smoke.invalid",
);
const expectProductionConfig = parseBooleanEnv(process.env.FORAGE_SMOKE_EXPECT_PRODUCTION, true);
const webSmokeMode = process.env.FORAGE_WEB_SMOKE_MODE ?? "public";
const skipOAuthStart = parseBooleanEnv(process.env.FORAGE_SMOKE_SKIP_OAUTH_START, false);

const failures = [];

Expand All @@ -21,10 +23,17 @@ Set:

Optional:
FORAGE_UNTRUSTED_ORIGIN=https://untrusted.example.com
FORAGE_WEB_SMOKE_MODE=public
FORAGE_SMOKE_SKIP_OAUTH_START=false
`);
process.exit(1);
}

if (!["public", "access-protected"].includes(webSmokeMode)) {
console.error("FORAGE_WEB_SMOKE_MODE must be public or access-protected.");
process.exit(1);
}

const webIsHttps = webOrigin.startsWith("https://");
const workerIsHttps = workerOrigin.startsWith("https://");

Expand Down Expand Up @@ -81,6 +90,78 @@ if (rejectedConfig.response.headers.has("access-control-allow-origin")) {
failures.push("Worker config returned Access-Control-Allow-Origin for an untrusted origin.");
}

const session = await fetchJson(`${workerOrigin}/api/session`, {
label: "Worker unauthenticated session",
headers: {
Origin: webOrigin,
},
});
assertHeader(
session.response,
"access-control-allow-origin",
webOrigin,
"Worker unauthenticated session",
);
assertHeader(
session.response,
"access-control-allow-credentials",
/^true$/i,
"Worker unauthenticated session",
);
if (session.payload?.authenticated !== false) {
failures.push("Worker unauthenticated session response must report authenticated: false.");
}

if (!skipOAuthStart) {
const authStart = await fetch(`${workerOrigin}/auth/github`, {
redirect: "manual",
headers: {
Origin: webOrigin,
},
});
if (authStart.status !== 302) {
failures.push(`GitHub OAuth start returned ${authStart.status}; expected 302.`);
}
assertHeader(
authStart,
"location",
/^https:\/\/github\.com\/login\/oauth\/authorize\?/i,
"GitHub OAuth start",
);
assertHeader(authStart, "set-cookie", /forage_oauth_state=/i, "GitHub OAuth start");

const authLocation = authStart.headers.get("location");
if (authLocation) {
const authUrl = new URL(authLocation);
const redirectUri = authUrl.searchParams.get("redirect_uri");
if (redirectUri !== `${workerOrigin}/auth/github/callback`) {
failures.push(
`GitHub OAuth start redirect_uri was ${redirectUri}; expected ${workerOrigin}/auth/github/callback.`,
);
}
if (authUrl.searchParams.get("code_challenge_method") !== "S256") {
failures.push("GitHub OAuth start is missing PKCE code_challenge_method=S256.");
}
if (!authUrl.searchParams.get("code_challenge")) {
failures.push("GitHub OAuth start is missing PKCE code_challenge.");
}
if (!authUrl.searchParams.get("state")) {
failures.push("GitHub OAuth start is missing state.");
}
}

const oauthCookie = authStart.headers.get("set-cookie") ?? "";
if (!/HttpOnly/i.test(oauthCookie)) {
failures.push("GitHub OAuth start cookie is missing HttpOnly.");
}
if (!/SameSite=Lax/i.test(oauthCookie)) {
failures.push("GitHub OAuth start cookie is missing SameSite=Lax.");
}
if (workerIsHttps && !/Secure/i.test(oauthCookie)) {
failures.push("GitHub OAuth start cookie is missing Secure.");
}
}

const preflight = await fetch(`${workerOrigin}/api/settings`, {
method: "OPTIONS",
headers: {
Expand All @@ -100,28 +181,46 @@ assertHeader(
"Worker settings preflight",
);

const webResponse = await fetch(webOrigin);
if (!webResponse.ok) {
failures.push(`Web app returned ${webResponse.status}; expected 2xx.`);
}
const webHtml = await webResponse.text();
if (!webHtml.includes('id="forage-app"')) {
failures.push("Web app HTML is missing the Forage app root.");
}
if (!webHtml.includes("Starred repos, ready to sort through.")) {
failures.push("Web app HTML is missing the expected heading.");
}
if (!webHtml.includes(`connect-src 'self' ${workerOrigin}`)) {
failures.push("Web app CSP meta tag does not include the configured Worker origin.");
}
assertHeader(webResponse, "x-content-type-options", /^nosniff$/i, "Web app");
assertHeader(webResponse, "x-frame-options", /^DENY$/i, "Web app");
assertHeader(webResponse, "referrer-policy", /^strict-origin-when-cross-origin$/i, "Web app");
assertHeader(webResponse, "permissions-policy", /camera=\(\)/i, "Web app");
assertHeader(webResponse, "content-security-policy", /frame-ancestors 'none'/i, "Web app");
if (webSmokeMode === "public") {
const webResponse = await fetch(webOrigin);
if (!webResponse.ok) {
failures.push(`Web app returned ${webResponse.status}; expected 2xx.`);
}
const webHtml = await webResponse.text();
if (!webHtml.includes('id="forage-app"')) {
failures.push("Web app HTML is missing the Forage app root.");
}
if (!webHtml.includes("Starred repos, ready to sort through.")) {
failures.push("Web app HTML is missing the expected heading.");
}
if (!webHtml.includes(`connect-src 'self' ${workerOrigin}`)) {
failures.push("Web app CSP meta tag does not include the configured Worker origin.");
}
assertHeader(webResponse, "x-content-type-options", /^nosniff$/i, "Web app");
assertHeader(webResponse, "x-frame-options", /^DENY$/i, "Web app");
assertHeader(webResponse, "referrer-policy", /^strict-origin-when-cross-origin$/i, "Web app");
assertHeader(webResponse, "permissions-policy", /camera=\(\)/i, "Web app");
assertHeader(webResponse, "content-security-policy", /frame-ancestors 'none'/i, "Web app");

if (webIsHttps) {
assertHeader(webResponse, "strict-transport-security", /max-age=/i, "Web app");
}
} else {
const webResponse = await fetch(webOrigin, { redirect: "manual" });
const location = webResponse.headers.get("location") ?? "";
const body = await webResponse.text().catch(() => "");
const accessRedirect =
[301, 302, 303, 307, 308].includes(webResponse.status) &&
(/cloudflareaccess\.com/i.test(location) || /\/cdn-cgi\/access/i.test(location));
const accessDenied =
[401, 403].includes(webResponse.status) &&
(/cloudflare access/i.test(body) || /\/cdn-cgi\/access/i.test(body));

if (webIsHttps) {
assertHeader(webResponse, "strict-transport-security", /max-age=/i, "Web app");
if (!accessRedirect && !accessDenied) {
failures.push(
`Access-protected web returned ${webResponse.status}; expected Cloudflare Access redirect or denial.`,
);
}
}
if (workerIsHttps) {
assertHeader(health.response, "strict-transport-security", /max-age=/i, "Worker health", {
Expand Down
Loading