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
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Alchemy (required when using alchemy)
# Generate both the secret and the state token using `openssl rand -base64 32`.
ALCHEMY_SECRET=<***>
ALCHEMY_STATE_TOKEN=<***>
# The hostname used for deployment and for generating VITE_BASE_URL env.
HOSTNAME=<your app's hostname, e.g. devsantara.com>

# Application
VITE_BASE_URL=<*** (default: http://localhost:3000)>

# Cloudflare [CI] (required for deployment workflows)
# See https://alchemy.run/guides/cloudflare#api-token
CLOUDFLARE_API_TOKEN=<***>
# Your Cloudflare email
CLOUDFLARE_EMAIL=<***>
48 changes: 48 additions & 0 deletions .github/workflows/cleanup-preview.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# This workflow destroys the preview environment/deployment when a PR is closed.
# It prevents preview resources from leaking/stale after close.

name: Cleanup Preview
on:
pull_request:
branches:
- main
types:
- closed
# Shares a concurrency group with deployment-preview so the destroy queues
# behind any in-flight deploy on the same preview-<PR> stage.
concurrency:
group: preview-${{ github.event.number }}
cancel-in-progress: false
env:
STAGE: ${{ format('preview-{0}', github.event.number) }}
jobs:
cleanup:
runs-on: ubuntu-latest
timeout-minutes: 15
environment:
name: preview
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- name: Setup Vite+
uses: voidzero-dev/setup-vp@v1
with:
cache: true
- name: Install dependencies
run: vp install
- name: Destroy Preview
run: vp run alchemy destroy --stage ${{ env.STAGE }}
env:
# GitHub Context
PULL_REQUEST: ${{ github.event.number }}
GITHUB_SHA: ${{ github.sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Alchemy
HOSTNAME: ${{ vars.HOSTNAME }}
ALCHEMY_SECRET: ${{ secrets.ALCHEMY_SECRET }}
ALCHEMY_STATE_TOKEN: ${{ secrets.ALCHEMY_STATE_TOKEN }}
# Cloudflare CI
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
51 changes: 51 additions & 0 deletions .github/workflows/deployment-preview.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Deploys a per-PR preview environment for this repository.
# Triggered when a PR targeting main (commonly from release/* or hotfix/*) is opened, reopened, or updated (synchronize).
# Deploys to the "preview" environment with stage name "preview-<PR_NUMBER>" .

name: Deployment (Preview)
on:
pull_request:
branches:
- main
types:
- opened
- reopened
- synchronize
# Shares a concurrency group with cleanup-preview so a destroy never races an
# in-flight deploy on the same preview-<PR> stage (which would corrupt state).
concurrency:
group: preview-${{ github.event.number }}
cancel-in-progress: false
env:
STAGE: ${{ format('preview-{0}', github.event.number) }}
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
environment:
name: preview
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v4
- name: Setup Vite+
uses: voidzero-dev/setup-vp@v1
with:
cache: true
- name: Install dependencies
run: vp install
- name: Build and deploy
run: vp run alchemy deploy --stage ${{ env.STAGE }}
env:
# GitHub Context
PULL_REQUEST: ${{ github.event.number }}
GITHUB_SHA: ${{ github.sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Alchemy
HOSTNAME: ${{ vars.HOSTNAME }}
ALCHEMY_SECRET: ${{ secrets.ALCHEMY_SECRET }}
ALCHEMY_STATE_TOKEN: ${{ secrets.ALCHEMY_STATE_TOKEN }}
# Cloudflare CI
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
39 changes: 39 additions & 0 deletions .github/workflows/deployment-production.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Deploys the main branch to the production environment on every push to main.
# Deploys to the GitHub environment "production" with stage name "production".

name: Deployment (Production)
on:
push:
branches:
- main
concurrency:
group: production-${{ github.ref }}
cancel-in-progress: false
env:
STAGE: 'production'
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 15
environment:
name: production
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Setup Vite+
uses: voidzero-dev/setup-vp@v1
with:
cache: true
- name: Install dependencies
run: vp install
- name: Build and deploy
run: vp run alchemy deploy --stage ${{ env.STAGE }}
env:
# Alchemy
HOSTNAME: ${{ vars.HOSTNAME }}
ALCHEMY_SECRET: ${{ secrets.ALCHEMY_SECRET }}
ALCHEMY_STATE_TOKEN: ${{ secrets.ALCHEMY_STATE_TOKEN }}
# Cloudflare CI
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ node_modules/
# Output
dist/

# Deployment
.alchemy/
.wrangler/

# Environment variables
.env*
!.env.example
Expand Down
16 changes: 16 additions & 0 deletions @types/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Auto-generated Cloudflare binding types.
// @see https://alchemy.run/concepts/bindings/#type-safe-bindings

import type { worker } from '../alchemy.run.ts';

export type CloudflareEnv = typeof worker.Env;

declare global {
type Env = CloudflareEnv;
}

declare module 'cloudflare:workers' {
namespace Cloudflare {
export interface Env extends CloudflareEnv {}
}
}
181 changes: 181 additions & 0 deletions alchemy.run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import os from 'node:os';

import alchemy, { type Scope } from 'alchemy';
import { TanStackStart } from 'alchemy/cloudflare';
import { GitHubComment } from 'alchemy/github';
import { CloudflareStateStore } from 'alchemy/state';
import { parse as parseURL } from 'tldts';

import { alchemyEnv } from '#/lib/env/alchemy.ts';

const HOST_USER = os.userInfo().username;
const ALCHEMY_STATE_TOKEN = alchemy.secret(alchemyEnv.ALCHEMY_STATE_TOKEN);

/**
* Resolves the deployment stage from the alchemy scope.
*
* Stage is derived from the scope's stage string, which follows the format:
* `<stage>[-<name>]` (e.g. `production`, `staging-v2`, `preview-feat-x`, or `<host-user>`).
*
* @param scope - The alchemy scope provided at runtime.
* @returns An object containing the resolved `stage`, optional `name`, and `isDevMode` flag.
* @throws {Error} If the stage prefix does not match any known stage or the current host user.
*/
function getStage(scope: Scope) {
const isDevMode = scope.local;

// Match the local stage against the full stage string first — the username
// may itself contain hyphens, which the split below would otherwise truncate.
if (scope.stage === HOST_USER) {
return { id: 'local', name: HOST_USER, isDevMode } as const;
}

const [stage, ...nameParts] = scope.stage.split('-');
const name = nameParts.join('-') || null;

if (stage === 'production') {
return { id: 'production', name, isDevMode } as const;
}
if (stage === 'staging') {
return { id: 'staging', name, isDevMode } as const;
}
if (stage === 'preview') {
return { id: 'preview', name, isDevMode } as const;
}
throw new Error(`Unknown stage: ${scope.stage}`);
}
type Stage = ReturnType<typeof getStage>;

/**
* Resolves the custom domains for the deployment based on stage and hostname config.
*
* In dev mode, returns `undefined` so no custom domain is attached (the worker is
* served locally by `alchemy dev`; the workers.dev URL is disabled via `url: false`).
* For production, uses the bare hostname from `HOSTNAME`. For other stages, constructs
* a subdomain by combining the stage (and optional name) with the base domain.
*
* @param stage - The resolved stage object from {@link getStage}.
* @returns An array of domain strings, or `undefined` for local dev.
*/
function getDomains({ id: stage, name, isDevMode }: Stage) {
if (isDevMode) return undefined;

const { hostname, subdomain, domain } = parseURL(alchemyEnv.HOSTNAME);
const hasSubdomain = subdomain !== null && subdomain !== '';

if (stage === 'production') {
return [hostname ?? alchemyEnv.HOSTNAME];
}

if (hasSubdomain) {
if (name !== null) {
return [`${subdomain}--${stage}-${name}.${domain}`];
} else {
return [`${subdomain}--${stage}.${domain}`];
}
} else {
if (name !== null) {
return [`${stage}-${name}.${domain}`];
} else {
return [`${stage}.${domain}`];
}
}
}

/**
* Returns the Cloudflare Workers observability config for the given stage.
*
* Enabled for all non-local, non-dev deployments (production, staging, preview).
*
* @param stage - The resolved stage object from {@link getStage}.
* @returns An object with an `enabled` boolean for Cloudflare observability.
*/
function getObservability({ id: stage, isDevMode }: Stage) {
if (isDevMode) return { enabled: false };
switch (stage) {
case 'production':
case 'staging':
case 'preview':
return { enabled: true };
case 'local':
return { enabled: false };
}
}

/**
* Returns the Cloudflare Workers placement config for the given stage.
*
* Smart placement is only enabled for production to minimize latency globally.
* Other stages use the default placement to reduce cost.
*
* @param stage - The resolved stage object from {@link getStage}.
* @returns A placement config object, or `undefined` to use Cloudflare's default.
*/
function getPlacement({ id: stage, isDevMode }: Stage) {
if (isDevMode) return undefined;
switch (stage) {
case 'production':
return { mode: 'smart' } as const;
case 'staging':
case 'preview':
case 'local':
return undefined;
}
}

const app = await alchemy('devsantara', {
password: alchemyEnv.ALCHEMY_SECRET,
stateStore: (scope) =>
new CloudflareStateStore(scope, {
scriptName: 'devsantara-deployment-service',
stateToken: ALCHEMY_STATE_TOKEN,
}),
});

const stage = getStage(app);
const domains = getDomains(stage);
const observability = getObservability(stage);
const placement = getPlacement(stage);
const url = domains?.[0] ? `https://${domains[0]}` : null;

export const worker = await TanStackStart('website', {
adopt: true,
wrangler: { main: 'src/entry.server.ts' },
build: 'vp build',
dev: { command: 'vp dev' },
observability: observability,
placement: placement,
url: false,
domains: domains,
bindings: {
// Environment variables
VITE_BASE_URL: url ?? '',
},
});

if (process.env.PULL_REQUEST) {
const commitHash = process.env.GITHUB_SHA?.slice(0, 7) ?? 'unknown';
const updatedAt = new Date(worker.updatedAt).toUTCString();

// If this is a PR, add a comment to the PR with the preview URL
// It will auto-update with each push
await GitHubComment('preview-comment', {
owner: 'devsantara',
repository: 'website',
issueNumber: Number(process.env.PULL_REQUEST),
body: `## 🚀 Preview Deployment (${process.env.PULL_REQUEST})

Your changes have been deployed to a preview environment:

| Name | Preview URL | Commit | Updated (UTC) |
| :----------------- | :---------------------- | :------------ | :------------ |
| **${worker.name}** | [Visit Preview](${url}) | ${commitHash} | ${updatedAt} |

---
<sub>🏗️ This comment updates automatically with each push.</sub>`,
});
}

console.info({ app: app.name, worker: worker.name, url: url ?? 'localhost' });

await app.finalize();
Loading
Loading