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
28 changes: 28 additions & 0 deletions .github/actions/deploy-env/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: deploy setup
description: >-
Toolchain and dependencies for an alchemy deploy job. Secrets come from the job's GitHub
Environment (`production` or `pr-preview`) as env vars; alchemy reads them through Config
when it plans the stack (see apps/*/alchemy.run.ts and apps/backend/src/worker/env.ts).

runs:
using: composite
steps:
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.3.14

- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
~/.bun/install/cache
node_modules
*/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-

- name: Install dependencies
shell: bash
run: bun install --frozen-lockfile
3 changes: 3 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ jobs:
- name: Type check
run: bun run typecheck

- name: Type check the alchemy stack
run: bun run alchemy:typecheck

test:
runs-on: ubuntu-latest
permissions:
Expand Down
83 changes: 83 additions & 0 deletions .github/workflows/deploy-pr-preview.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: Deploy PR Preview (Cloudflare via Alchemy)

# Label-gated: a PR gets its own stage (`pr-<n>`) while it carries `preview`, torn down when the
# label comes off or the PR closes. Previews share one preview database (`HAZEL_PG_URL`).
on:
pull_request:
types: [opened, reopened, synchronize, labeled, unlabeled, closed]

concurrency:
group: pr-preview-${{ github.event.pull_request.number }}
# Queue instead of cancelling: whether to cancel is decided by the *incoming* run, so any
# cancellation could cut short an in-progress teardown and leak the preview stage. `queue: max`
# keeps every pending run too (the default replaces a pending teardown with the next event).
cancel-in-progress: false
Comment thread
coderabbitai[bot] marked this conversation as resolved.
queue: max

permissions:
contents: read
pull-requests: write

jobs:
preview:
if: >-
${{ github.event.pull_request.head.repo.full_name == github.repository
&& ((github.event.action == 'unlabeled' && github.event.label.name == 'preview')
|| (github.event.action != 'unlabeled'
&& contains(github.event.pull_request.labels.*.name, 'preview'))) }}
runs-on: ubuntu-latest
timeout-minutes: 45
environment:
name: pr-preview
url: ${{ steps.deploy.outputs.web_url }}
env:
# `unlabeled` only reaches here for the `preview` label, so both mean teardown.
TEARDOWN: ${{ (github.event.action == 'closed' || github.event.action == 'unlabeled') && 'true' || 'false' }}
PR_NUMBER: ${{ github.event.pull_request.number }}
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
HAZEL_PG_URL: ${{ secrets.HAZEL_PG_URL }}
VITE_CLERK_PUBLISHABLE_KEY: ${{ vars.VITE_CLERK_PUBLISHABLE_KEY }}
CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }}
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_PUBLIC_URL: ${{ vars.S3_PUBLIC_URL }}
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
CLUSTER_URL: ${{ vars.CLUSTER_URL }}
CLUSTER_API_SECRET: ${{ secrets.CLUSTER_API_SECRET }}
INTEGRATION_ENCRYPTION_KEY: ${{ secrets.INTEGRATION_ENCRYPTION_KEY }}
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Deploy setup
uses: ./.github/actions/deploy-env

- name: Deploy preview stage
id: deploy
if: ${{ env.TEARDOWN != 'true' }}
run: bun run alchemy:deploy:pr

- name: Destroy preview stage
if: ${{ env.TEARDOWN == 'true' }}
run: bun run alchemy:destroy:pr

- name: Comment the preview URLs
if: ${{ env.TEARDOWN != 'true' && steps.deploy.outcome == 'success' }}
env:
GH_TOKEN: ${{ github.token }}
WEB_URL: ${{ steps.deploy.outputs.web_url }}
API_URL: ${{ steps.deploy.outputs.api_url }}
run: |
marker="<!-- hazel-preview-comment -->"
body="$marker
**Preview** for \`${COMMIT_SHA:0:7}\`: app $WEB_URL · api $API_URL"
existing=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -1)
if [ -n "$existing" ]; then
gh api -X PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing" -f body="$body" >/dev/null
else
gh pr comment "$PR_NUMBER" --body "$body"
fi
94 changes: 94 additions & 0 deletions .github/workflows/deploy-prd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: Deploy PRD (Cloudflare via Alchemy)

# Gated on CI passing on main (not `push: main`); `workflow_dispatch` skips the gate.
on:
workflow_run:
workflows: ["Test"]
types: [completed]
branches: [main]
workflow_dispatch:

concurrency:
group: deploy-prd
cancel-in-progress: false

permissions:
contents: read

jobs:
deploy:
# Only CI runs for pushes to this repo's main deploy; a PR's Test run must never reach prd.
if: >-
${{ github.event_name == 'workflow_dispatch'
|| (github.event.workflow_run.conclusion == 'success'
&& github.event.workflow_run.event == 'push'
&& github.event.workflow_run.head_repository.full_name == github.repository) }}
runs-on: ubuntu-latest
timeout-minutes: 45
environment:
name: production
url: https://app.hazel.sh
env:
# On workflow_run, `github.sha` is the branch head, not the commit CI tested.
COMMIT_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }}
PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }}
PLANETSCALE_ORGANIZATION: ${{ vars.PLANETSCALE_ORGANIZATION }}
# Build inputs (web)
VITE_CLERK_PUBLISHABLE_KEY: ${{ vars.VITE_CLERK_PUBLISHABLE_KEY }}
VITE_PUBLIC_POSTHOG_KEY: ${{ vars.VITE_PUBLIC_POSTHOG_KEY }}
VITE_MAPLE_PUBLIC_KEY: ${{ vars.VITE_MAPLE_PUBLIC_KEY }}
# api Worker (apps/backend/src/worker/env.ts)
CLERK_PUBLISHABLE_KEY: ${{ vars.CLERK_PUBLISHABLE_KEY }}
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
CLERK_WEBHOOK_SECRET: ${{ secrets.CLERK_WEBHOOK_SECRET }}
COOKIE_DOMAIN: ${{ vars.COOKIE_DOMAIN }}
S3_BUCKET: ${{ vars.S3_BUCKET }}
S3_ENDPOINT: ${{ vars.S3_ENDPOINT }}
S3_PUBLIC_URL: ${{ vars.S3_PUBLIC_URL }}
S3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY_ID }}
S3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_ACCESS_KEY }}
CLUSTER_URL: ${{ vars.CLUSTER_URL }}
CLUSTER_API_SECRET: ${{ secrets.CLUSTER_API_SECRET }}
INTEGRATION_ENCRYPTION_KEY: ${{ secrets.INTEGRATION_ENCRYPTION_KEY }}
INTEGRATION_ENCRYPTION_KEY_VERSION: ${{ vars.INTEGRATION_ENCRYPTION_KEY_VERSION }}
INTEGRATION_ENCRYPTION_KEY_PREV: ${{ secrets.INTEGRATION_ENCRYPTION_KEY_PREV }}
INTEGRATION_ENCRYPTION_KEY_VERSION_PREV: ${{ vars.INTEGRATION_ENCRYPTION_KEY_VERSION_PREV }}
LINEAR_CLIENT_ID: ${{ vars.LINEAR_CLIENT_ID }}
LINEAR_CLIENT_SECRET: ${{ secrets.LINEAR_CLIENT_SECRET }}
DISCORD_CLIENT_ID: ${{ vars.DISCORD_CLIENT_ID }}
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
DISCORD_GATEWAY_ENABLED: ${{ vars.DISCORD_GATEWAY_ENABLED }}
GITHUB_APP_ID: ${{ vars.HAZEL_GITHUB_APP_ID }}
GITHUB_APP_SLUG: ${{ vars.HAZEL_GITHUB_APP_SLUG }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.HAZEL_GITHUB_APP_PRIVATE_KEY }}
GITHUB_WEBHOOK_SECRET: ${{ secrets.HAZEL_GITHUB_WEBHOOK_SECRET }}
INTERNAL_SECRET: ${{ secrets.INTERNAL_SECRET }}
KLIPY_API_KEY: ${{ secrets.KLIPY_API_KEY }}
# electric-proxy + self-hosted Electric (DATABASE_URL comes from the stack's replication role)
ELECTRIC_SECRET: ${{ secrets.ELECTRIC_SECRET }}
OTEL_BASE_URL: ${{ vars.OTEL_BASE_URL }}
MAPLE_INGEST_KEY: ${{ secrets.MAPLE_INGEST_KEY }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ env.COMMIT_SHA }}

- name: Deploy setup
uses: ./.github/actions/deploy-env

- name: Deploy with Alchemy
run: bun run alchemy:deploy:prd

- name: Check the api serves this revision
run: |
for attempt in 1 2 3 4 5 6; do
if curl -fsS https://api.hazel.sh/health >/dev/null; then exit 0; fi
sleep 10
done
echo "::error::api.hazel.sh/health did not answer after the deploy"
exit 1
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ opensrc/

# Migration exports (contain PII — user emails, IDs, avatars)
apps/backend/exports/
.alchemy/
133 changes: 133 additions & 0 deletions alchemy.run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// The Hazel stack. Each app declares its resources in `apps/<app>/alchemy.run.ts`; this file
// builds the deploy context (`HazelStack`) and yields them in dependency order.
// Plan: infra/cloudflare-migration-plan.md
import { appendFileSync } from "node:fs"
import * as Alchemy from "alchemy"
import * as Cloudflare from "alchemy/Cloudflare"
import * as Planetscale from "alchemy/Planetscale"
import { ConfigError } from "effect/Config"
import { SourceError } from "effect/ConfigProvider"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import {
declareHazelDb,
formatHazelStage,
HazelStack,
type HazelStackContext,
parseHazelStageEffect,
resolveHazelDomains,
} from "@hazel/infra/cloudflare"
import { plainWithDefault } from "@hazel/infra/env"
import Actors from "./apps/actors/alchemy.run.ts"
import ApiLive, { Api } from "./apps/backend/src/worker.ts"
import BotGateway from "./apps/bot-gateway/alchemy.run.ts"
import Docs from "./apps/docs/alchemy.run.ts"
import ElectricProxy from "./apps/electric-proxy/alchemy.run.ts"
import Landing from "./apps/landing/alchemy.run.ts"
import LinkPreview from "./apps/link-preview-worker/alchemy.run.ts"
import Web from "./apps/web/alchemy.run.ts"

// Some secret stores define CLOUDFLARE_DEFAULT_ACCOUNT_ID; alchemy reads CLOUDFLARE_ACCOUNT_ID.
if (!process.env.CLOUDFLARE_ACCOUNT_ID && process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID) {
process.env.CLOUDFLARE_ACCOUNT_ID = process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID
}

/** `alchemy dev` sets ALCHEMY_DEV on its exec child. Not stage-derived: a dev stage can be deployed. */
const isDevServer = process.env.ALCHEMY_DEV === "true"

// Inter-app URLs must be plain strings at plan time (`worker.url` is a lazy Output), so
// deployed stages use custom domains and dev stages fall back to env-supplied URLs.
const resolveUrl = (domain: string | undefined, envKey: string, fallback: string) =>
domain
? Effect.succeed(`https://${domain}`)
: Effect.map(plainWithDefault(envKey, fallback), (record) => record[envKey] ?? fallback)

const asConfigError = (error: { readonly message: string }) =>
Effect.fail(new ConfigError(new SourceError({ message: error.message, cause: error })))

/** Append `key=value` lines to the GitHub Actions step-output file, if any. */
const appendStepOutputs = (lines: string[]): void => {
const file = process.env.GITHUB_OUTPUT
if (file) appendFileSync(file, `${lines.join("\n")}\n`)
}

const HazelStackLive = Layer.effect(
HazelStack,
Effect.gen(function* () {
const stage = yield* parseHazelStageEffect(yield* Alchemy.Stage)
const domains = resolveHazelDomains(stage)
const context: HazelStackContext = {
stage,
domains,
isDevServer,
db: yield* declareHazelDb(stage),
urls: {
web: yield* resolveUrl(domains.web, "HAZEL_WEB_URL", "http://localhost:3000"),
api: yield* resolveUrl(domains.api, "HAZEL_API_URL", "http://localhost:3003"),
electric: yield* resolveUrl(domains.electric, "HAZEL_ELECTRIC_URL", "http://localhost:8184"),
rivet: yield* resolveUrl(domains.rivet, "HAZEL_RIVET_URL", "http://localhost:6420"),
linkPreview: yield* resolveUrl(
domains.linkPreview,
"HAZEL_LINK_PREVIEW_URL",
"http://localhost:5471",
),
},
}
return context
}),
)

export default Alchemy.Stack(
"hazel",
{
// PlanetScale's credential lookup runs when the layer is built; `alchemy dev` never needs it.
providers: isDevServer
? Cloudflare.providers()
: Cloudflare.providers().pipe(Layer.provideMerge(Planetscale.providers())),
// ALCHEMY_LOCAL_STATE=1 uses .alchemy/ file state instead of the account-wide store.
state: process.env.ALCHEMY_LOCAL_STATE ? Alchemy.localState() : Cloudflare.state(),
},
Effect.gen(function* () {
const { stage, domains, urls } = yield* HazelStack

// Before api, which binds its BotGateway Durable Objects cross-script.
const botGateway = yield* BotGateway
// The Live layer registers the api Worker's Durable Object classes in its bundle.
const api = yield* Effect.provide(Api, ApiLive)
// Also yields the `electric` Worker (self-hosted Electric in a Container) on deployed stages.
const electricProxy = yield* ElectricProxy
const linkPreview = yield* LinkPreview
const actors = yield* Actors
const web = yield* Web
// Shared marketing/docs sites: prd only (previews and dev run their own dev servers).
const landing = stage.kind === "prd" ? yield* Landing : undefined
const docs = stage.kind === "prd" ? yield* Docs : undefined

const summary = {
stage: formatHazelStage(stage),
webUrl: domains.web ? `https://${domains.web}` : "",
apiUrl: urls.api,
electricUrl: urls.electric,
}
yield* Effect.sync(() =>
appendStepOutputs([`web_url=${summary.webUrl}`, `api_url=${summary.apiUrl}`]),
)

return {
...summary,
apiWorker: api.workerName,
botGatewayWorker: botGateway.workerName,
electricProxyWorker: electricProxy.workerName,
webWorker: web.workerName,
linkPreviewWorker: linkPreview.workerName,
actorsWorker: actors.workerName,
landingWorker: landing?.workerName,
docsWorker: docs?.workerName,
}
}).pipe(
// The stack IS the entry point: the one place `HazelStack` is provided.
Effect.provide(HazelStackLive),
// `Alchemy.Stack` admits only `ConfigError`.
Effect.catchTags({ "@hazel/infra/HazelStageError": asConfigError }),
),
)
42 changes: 42 additions & 0 deletions apps/actors/alchemy.run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* RivetKit actors: an async Worker whose `ActorHandler` Durable Object class comes from
* `@rivetkit/cloudflare-workers`. On the adopting deploy the binding must keep the existing
* `ACTOR_DO` -> `ActorHandler` mapping (alchemy matches the live class by binding name).
*/
import { HazelStack, hazelWorkerProps, stageProps } from "@hazel/infra/cloudflare"
import * as Cloudflare from "alchemy/Cloudflare"
import { Effect } from "effect"

export const ActorKv = Cloudflare.KV.Namespace(
"actor-kv",
stageProps("actor-kv", (name, stage) => ({ title: stage.kind === "prd" ? "hazel-actor-kv" : name })),
)

export default Effect.gen(function* () {
const stack = yield* HazelStack
const actorKv = yield* ActorKv
return yield* Cloudflare.Worker("actors", {
...hazelWorkerProps("actors", stack),
main: new URL("./src/index.ts", import.meta.url).pathname,
workersDev: stack.stage.kind !== "prd",
domain: stack.domains.rivet,
env: {
ACTOR_DO: Cloudflare.DurableObject("ACTOR_DO", { className: "ActorHandler" }),
ACTOR_KV: actorKv,
NODE_ENV: stack.stage.kind === "dev" ? "development" : "production",
RIVET_PUBLIC_ENDPOINT: stack.urls.rivet,
},
observability: {
enabled: true,
headSamplingRate: 1,
logs: {
enabled: true,
headSamplingRate: 1,
persist: true,
invocationLogs: true,
destinations: ["maple-logs"],
},
traces: { enabled: true, persist: true, headSamplingRate: 1, destinations: ["maple-traces"] },
},
})
})
Loading
Loading