Skip to content
Open
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
59 changes: 53 additions & 6 deletions .github/workflows/deploy-docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ concurrency:
group: deploy-docs-${{ github.ref }}
cancel-in-progress: false

permissions:
contents: read
pull-requests: write
deployments: write

env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
Expand Down Expand Up @@ -66,27 +71,37 @@ jobs:
needs: stage
if: needs.stage.outputs.action == 'deploy'
runs-on: ubuntu-latest
# Production/staging keep dedicated environments. PR stages share
# `docs-preview` so GitHub does not wait for approval of a brand-new
# `docs-pr-N` environment (that is why recent PR deploys sat in
# `action_required` and never ran).
environment:
name: docs-${{ needs.stage.outputs.stage }}
name: ${{ (needs.stage.outputs.stage == 'production' || needs.stage.outputs.stage == 'staging') && format('docs-{0}', needs.stage.outputs.stage) || 'docs-preview' }}
url: ${{ steps.deploy.outputs.url }}
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.2"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Build worker
- name: Stamp build info for memo invalidation
working-directory: apps/docs
run: |
set -euo pipefail
bun run build:worker
cat > .build-info <<EOF
sha=${{ github.sha }}
ref=${{ github.ref }}
event=${{ github.event_name }}
run_id=${{ github.run_id }}
EOF
- name: Repair known unreadable Alchemy state
if: needs.stage.outputs.stage == 'production'
env:
STAGE: ${{ needs.stage.outputs.stage }}
SOPS_AGE_KEY: ${{ secrets.SECRETS_AGE_KEY_DEV }}
REPAIR_ALCHEMY_STATE_TARGETS: >-
stackpanel-docs/production/Docs
stackpanel-docs/production/DocsIncrementalCache
run: |
set -euo pipefail
bunx alchemy deploy packages/infra/src/repair-alchemy-state.ts \
Expand All @@ -110,14 +125,44 @@ jobs:
# --adopt: resources use stable physical names (stackpanel-docs-<stage>),
# so if state is ever lost/reset the deploy re-imports the existing
# cloud resources instead of failing or duplicating them.
bunx alchemy deploy --stage ${{ needs.stage.outputs.stage }} --yes --adopt
# Website.Nextjs builds OpenNext itself; do not pre-run wrangler.
bunx alchemy deploy --stage ${{ needs.stage.outputs.stage }} --yes --adopt \
2>&1 | tee /tmp/alchemy-deploy.log
url="$(sed -nE 's/.*url: "(https:\/\/[^"]+)".*/\1/p' /tmp/alchemy-deploy.log | tail -n1)"
if [[ -z "$url" ]]; then
echo "::error::alchemy deploy produced no url output"
exit 1
fi
echo "url=$url" >> "$GITHUB_OUTPUT"
echo "Deployed $url"
- name: Smoke check
env:
URL: ${{ steps.deploy.outputs.url }}
run: |
set -euo pipefail
smoke() {
local path="$1"
local out="/tmp/docs-smoke${path//\//_}.html"
local code
code="$(curl -sS -L --retry 12 --retry-delay 5 --retry-all-errors \
-o "$out" -w "%{http_code}" "$URL$path" || true)"
echo "GET $path -> $code"
if [[ "$code" != "200" ]]; then
echo "::error::expected 200 from $path, got $code"
head -c 2000 "$out" || true
echo
return 1
fi
}
smoke /
smoke /docs/quick-start
- name: Comment preview URL on PR
if: github.event_name == 'pull_request'
uses: marocchino/sticky-pull-request-comment@v3
with:
header: preview-docs
message: |
Docs preview deployed to `${{ needs.stage.outputs.stage }}`
Docs preview deployed to `${{ needs.stage.outputs.stage }}`: ${{ steps.deploy.outputs.url }}

destroy:
needs: stage
Expand All @@ -129,6 +174,8 @@ jobs:
steps:
- uses: actions/checkout@v7
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.2"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Destroy PR preview
Expand Down
170 changes: 42 additions & 128 deletions apps/docs/alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ import {
} from "@stackpanel/infra/lib/deploy";
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Output from "alchemy/Output";
import * as Workers from "@distilled.cloud/cloudflare/workers";
import * as Effect from "effect/Effect";

const PROJECT = "stackpanel";
Expand All @@ -18,97 +16,62 @@ const SERVICE = "docs";
const deployStage = resolveDeployStage();
const { appEnv } = deployStage;

// Decrypts the per-app SOPS payload and injects it into process.env so
// `wrangler`/opennext and the Cloudflare provider can read CLOUDFLARE_*
// credentials without `sops exec-env`. Hard-fails with a copy-pasteable
// message listing every missing required env var.
// Decrypts the per-app SOPS payload and injects it into process.env so the
// Cloudflare provider can read CLOUDFLARE_* credentials without `sops
// exec-env`. Hard-fails with a copy-pasteable message listing every missing
// required env var.
await loadDeployEnv(SERVICE, appEnv);

// stackpanel.com — same zone used by apps/web for the apex deployment.
const STACKPANEL_ZONE = "d34628a3ab639230ff1f6dc1eb640eec";

// Custom hostname per stage:
// production => docs.stackpanel.com
// staging => docs.staging.stackpanel.com
// <other> => docs.<stage>.stackpanel.com
const hostnameFor = (stage: string): string =>
stage === "production" ? "docs.stackpanel.com" : `docs.${stage}.stackpanel.com`;
// PR / other => workers.dev (zone wildcard is `*.stackpanel.com`, so
// `docs.pr-N.stackpanel.com` is not covered; Alchemy's
// workers.dev URL is the reliable preview target)
const hostnameFor = (stage: string): string | undefined => {
if (stage === "production") return "docs.stackpanel.com";
if (stage === "staging") return "docs.staging.stackpanel.com";
return undefined;
};

const program = Effect.gen(function* () {
const stage = yield* Alchemy.Stage;
const incrementalCache = yield* Cloudflare.R2Bucket("DocsIncrementalCache", {
name: `${PROJECT}-${SERVICE}-${stage}-incremental-cache`,
});
const hostname = hostnameFor(stage);

// OpenNext-on-Cloudflare emits the worker entrypoint and assets directory.
// The build is expected to have already run (`bun run build:worker`); this
// resource only handles upload + binding wiring.
const website = yield* Cloudflare.Worker("Docs", {
// Website.Nextjs runs the wrangler-free OpenNext pipeline from
// `@alchemy.run/frontend-frameworks/nextjs`. Build command lives in
// `open-next.config.ts` (`buildCommand`) — the resource no longer
// accepts a `nextjs.buildCommand` prop.
const website = yield* Cloudflare.Website.Nextjs("Docs", {
// Stable physical name prevents orphaned workers when Alchemy's
// per-deploy InstanceId changes (e.g. state loss between CI runs).
name: `stackpanel-docs-${stage}`,
// `.open-next/worker.js` is OpenNext's tiny ~2KB entrypoint — it expects to
// be passed through a wrangler-style bundler that resolves the relative
// `./cloudflare/*.js` imports and inlines them. Two viable bundlers:
//
// 1. wrangler (esbuild under the hood) — bundles statics, *preserves*
// runtime `import()` paths. This is what `opennextjs-cloudflare deploy`
// uses internally and what OpenNext is designed against.
// 2. alchemy's built-in cloudflareRolldown — also bundles statics, but
// mangles OpenNext's dynamic `import("./server-functions/default/
// handler.mjs")` so its `resolveWrapper(...)` returns `undefined` at
// request time. The deployed Worker then throws
// `TypeError: Cannot destructure property 'name' of '(intermediate
// value)'`
// inside `createGenericHandler` and every dynamic Next route
// (`/docs/*`, …) returns 500. Static routes (`/`, `/api/search`)
// survive because they're served by the ASSETS binding without
// entering the broken handler.
//
// We pre-bundle with wrangler in `bun run build:worker`
// (`wrangler deploy --dry-run --outdir=.open-next/dist`) and point
// `main:` at the resulting self-contained file, then tell alchemy to skip
// its own bundling pass with `bundle: false` so the byte-for-byte upload
// is the wrangler artifact.
main: ".open-next/dist/worker.js",
// OpenNext emits a plain Workers default export `{ fetch }` — the alchemy
// bootstrap that wraps `main` in `Layer.effect(tag, entry)` mis-handles
// that shape and the deployed worker throws CF 1101 on first request.
// `isExternal: true` skips the wrapper so the bundle keeps OpenNext's own
// entrypoint.
isExternal: true,
// The `bundle: false` opt-out is added by patches/alchemy@2.0.0-beta.43.patch
// (a backport of the proposed upstream change at
// https://github.com/alchemy-run/alchemy-effect — the
// `feat(cloudflare/Worker): add bundle: false …` commit). It short-
// circuits `prepareBundle` to upload `props.main` byte-for-byte. Drop the
// patch + this prop once cloudflareRolldown's dynamic-import handling is
// fixed upstream and we can bundle through alchemy directly.
bundle: false,
// Mirror apps/docs/wrangler.jsonc — OpenNext serves its own routing so the
// worker must run for missed asset paths, and we want the SPA-style
// trailing-slash handling for static MDX routes.
assets: {
directory: ".open-next/assets",
config: {
notFoundHandling: "none",
htmlHandling: "auto-trailing-slash",
runWorkerFirst: false,
},
},
bindings: {
NEXT_INC_CACHE_R2_BUCKET: incrementalCache,
memo: {
include: [
"src/**",
"content/**",
"public/**",
"package.json",
"next.config.mjs",
"open-next.config.ts",
"source.config.ts",
"tsconfig.json",
".build-info",
],
},
env: {
NEXT_INC_CACHE_R2_PREFIX: `${stage}/incremental-cache`,
// Zone is inferred from the hostname; omitted for local/dev/PR.
...(hostname !== undefined ? { domain: hostname } : {}),
// Match wrangler.jsonc so prerendered HTML is served from ASSETS when
// the path hits a static file. OpenNext still runs for API/search.
assets: {
htmlHandling: "auto-trailing-slash",
notFoundHandling: "none",
runWorkerFirst: false,
},
compatibility: {
// Must be >= 2026-03-17 — that's the date Cloudflare started providing
// node:perf_hooks as a native module. OpenNext (via Next.js's edge
// runtime) imports it transitively, and on earlier dates the unenv
// polyfill itself references node:perf_hooks, so the worker throws
// `No such module "node:perf_hooks"` on first request (CF error 1101).
date: "2026-03-17",
// Website.Nextjs defaults to 2026-05-12; keep explicit so node:perf_hooks
// (required by OpenNext/Next edge) stays available.
date: "2026-05-12",
flags: [
"nodejs_compat",
"nodejs_compat_populate_process_env",
Expand All @@ -117,56 +80,7 @@ const program = Effect.gen(function* () {
},
});

let url: Output.Output<string | undefined> = website.url;

if (stage !== "dev") {
const hostname = hostnameFor(stage);
url = Output.all(website.accountId, website.workerName).pipe(
Output.mapEffect(([accountId, workerName]) =>
Effect.gen(function* () {
const existing = yield* Workers.listDomains({
accountId,
hostname,
});
const stale = existing.result.filter(
(d) => d.hostname === hostname && d.id,
);
if (stale.length > 0) {
yield* Effect.log(
`[alchemy] purging ${stale.length} existing binding(s) at ${hostname}: ${stale
.map((d) => `${d.service ?? "?"}#${d.id}`)
.join(", ")}`,
);
}
for (const d of stale) {
// The distilled client fails to decode the `null` body of a
// successful workers-domain DELETE (CloudflareHttpError with
// status 200). The delete DID happen server-side — treat that
// exact signature (and an already-gone binding) as success;
// every real HTTP error still fails the deploy.
yield* Workers.deleteDomain({ accountId, domainId: d.id! }).pipe(
Effect.catchTags({
DomainNotFound: () => Effect.void,
CloudflareHttpError: (e) =>
e.status === 200 && e.statusText === "Schema decode failed"
? Effect.void
: Effect.fail(e),
}),
);
}
yield* Workers.putDomain({
accountId,
hostname,
service: workerName,
zoneId: STACKPANEL_ZONE,
});
return `https://${hostname}` as string | undefined;
}).pipe(Effect.orDie),
),
);
}

return { url };
return { url: website.url };
});

export default Alchemy.Stack(
Expand Down
18 changes: 12 additions & 6 deletions apps/docs/open-next.config.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import r2IncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache";
import staticAssetsIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/static-assets-incremental-cache";

export default defineCloudflareConfig({
// See https://opennext.js.org/cloudflare/caching for more details
incrementalCache: r2IncrementalCache,
enableCacheInterception: true,
});
export default {
...defineCloudflareConfig({
// Read-only prerendered cache from Worker static assets. R2 +
// enableCacheInterception SSRs on miss, which 500s this Fumadocs
// worker (no content/ on the isolate). Revalidation is a no-op.
incrementalCache: staticAssetsIncrementalCache,
}),
// Default is `npx next build`, which goes through npm and dies on this
// repo's `catalog:` protocol (`EOVERRIDE for effect@catalog:`).
buildCommand: "bun run build",
};
Loading