Skip to content
This repository was archived by the owner on Nov 2, 2026. It is now read-only.
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
20 changes: 8 additions & 12 deletions .github/workflows/alchemy.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Deploys with Alchemy (alchemy.run.ts). A push to main deploys the stage `main`,
# the production deployment. A pull request from this repository deploys a stage
# made from its branch, a preview named after the branch; closing the pull request
# destroys the stage and deletes the preview. The state of every stage is in the
# Deploys with Alchemy (alchemy.run.ts). A push to main deploys the stage `prod`,
# the production deployment. A pull request from this repository deploys the stage
# `pr-<number>`, a preview with that name; closing the pull request destroys the
# stage and deletes the preview. The state of every stage is in the
# `alchemy-state` project of the team, under the stack name GITHUB_REPOSITORY.
# Every run needs only the team token CONVEX_ACCESS_TOKEN; it mints the deploy
# keys itself. Only the Alchemy steps get the secret.
Expand All @@ -23,6 +23,9 @@ jobs:
runs-on: ubuntu-latest
env:
BRANCH: ${{ github.head_ref || github.ref_name }}
# The stage, as https://alchemy.run/environments/ci names it: `pr-<number>` for a pull
# request, `prod` on main, and the branch name otherwise, where no step below deploys.
STAGE: ${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.number) || (github.ref == 'refs/heads/main' && 'prod' || github.ref_name) }}
# GitHub.GitHubEnv in alchemy.run.ts reads it for the preview comment. Empty on a push.
PULL_REQUEST: ${{ github.event.pull_request.number }}
steps:
Expand All @@ -46,13 +49,6 @@ jobs:
echo "::error::The checkout is not the head of $BRANCH ($head). Nothing was deployed."
exit 1
fi
# An Alchemy stage allows only letters, digits, "-", and "_". The hash of the branch
# keeps two branches apart, such as a/b and a-b, and never yields `main`.
- name: Make the pull request stage
if: github.event_name == 'pull_request'
run: |
slug=$(printf '%s' "$BRANCH" | tr 'A-Z' 'a-z' | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g')
echo "STAGE=${slug:+$slug-}$(printf '%s' "$BRANCH" | sha256sum | cut -c1-8)" >> "$GITHUB_ENV"
# A repository without the token, such as one just created from this template, deploys nothing
# and says so.
- name: Skip without the Convex token
Expand All @@ -66,7 +62,7 @@ jobs:
fi
- name: Deploy production
if: steps.keys.outputs.skip != 'true' && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
run: pnpm exec alchemy deploy --stage main --yes --no-input $ADOPT
run: pnpm exec alchemy deploy --stage "$STAGE" --yes --no-input $ADOPT
env:
ADOPT: ${{ inputs.adopt && '--adopt' || '' }}
CONVEX_ACCESS_TOKEN: ${{ secrets.CONVEX_ACCESS_TOKEN }}
Expand Down
72 changes: 50 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ that a new app might not need.
The Convex deployment runs the backend and also serves the app's files through
[convex-hosting](https://github.com/samebase/convex-hosting). [Alchemy](https://alchemy.run) with
[@samebase/alchemy-convex](https://github.com/samebase/alchemy-convex) deploys it: `main` to the
production deployment, each pull request to a preview. This repository deploys itself the same way:
https://ideal-dolphin-15.convex.site is its `main`. The same app without Alchemy is
stage `prod`, the production deployment, and each pull request to the stage `pr-<number>`, a
preview. This repository deploys itself the same way: https://ideal-dolphin-15.convex.site is its
`prod`. The same app without Alchemy is
[samebase/base-hosting](https://github.com/samebase/base-hosting), and on Cloudflare Workers
[samebase/base](https://github.com/samebase/base).

Expand Down Expand Up @@ -43,13 +44,13 @@ create todos, see real-time updates, and scan a QR code to open the same list on
`gh secret set CONVEX_ACCESS_TOKEN`, which asks for the pasted value without showing it. The
token creates the Convex project of the repository, the state project `alchemy-state` of the
team, and the short-lived deploy keys of each run. No deploy key is stored anywhere.
5. Push to `main`, or run `alchemy.yml` from the **Actions** tab. The first run creates the
project, named after the repository, with its production deployment, pushes to it, and keeps the
state of the stage `main` in `alchemy-state`. Each push to `main` deploys from then on, and each
pull request deploys a preview. The run's log ends with the site URL,
`https://<deployment>.convex.site`. When the team has a project with the name of the repository
already, such as one that `pnpm run dev` created, the first run stops: run `alchemy.yml` once
with **adopt** (`gh workflow run alchemy.yml -f adopt=true`) to take it over.
5. Push to `main`, or run `alchemy.yml` from the **Actions** tab. The first run creates the project,
named after the repository, with its production deployment, pushes to it, and keeps the state of
the stage `prod` in `alchemy-state`. Each push to `main` deploys from then on, and each pull
request deploys the preview `pr-<number>` and comments its URL on the pull request. The run's log
ends with the site URL, `https://<deployment>.convex.site`. When the team has a project with the
name of the repository already, such as one that `pnpm run dev` created, the first run stops: run
`alchemy.yml` once with **adopt** (`gh workflow run alchemy.yml -f adopt=true`) to take it over.

## Local development

Expand Down Expand Up @@ -105,19 +106,21 @@ which the first `pnpm run dev` writes; CI passes a placeholder.

## Deployment contract

`alchemy.run.ts` is the stack. The stage `main` is the production deployment; any other stage is a
preview deployment named after the pull request branch, which `alchemy destroy` deletes. The push
is the `deploy` package script, run by the Convex deployment resource when the functions, the
build inputs, `package.json`, or the lockfile change; an unchanged checkout deploys nothing.
`.github/workflows/alchemy.yml` deploys each push to `main`, deploys each pull request from this
repository to a stage made from its branch, and destroys that stage when the pull request closes,
all with the team token `CONVEX_ACCESS_TOKEN`. The stack is named after the repository,
`owner/name` (`GITHUB_REPOSITORY`, or the origin remote on a laptop), and so is its Convex
project. Every run, in CI or local, keeps the state of its stage in the `alchemy-state` project of
the team, which the first run creates, under the stack name; the stage `main` owns the project,
and `alchemy destroy --stage main` deletes it. The state has no lock, so the workflow runs one
deploy of a stage at a time. A local run uses `npx convex login` when `CONVEX_ACCESS_TOKEN` is not
set:
`alchemy.run.ts` is the stack. The stage `prod` is the production deployment; any other stage is a
preview deployment named after the stage, which `alchemy destroy` deletes. The push is the `deploy`
package script, run by the Convex deployment resource when the functions, the build inputs,
`package.json`, or the lockfile change; an unchanged checkout deploys nothing.
`.github/workflows/alchemy.yml` names the stages as
[Alchemy's CI guide](https://alchemy.run/environments/ci) does: it deploys each push to `main` to
the stage `prod`, deploys each pull request from this repository to the stage `pr-<number>`, a
preview deployment with that name whose URL, `https://<deployment>.convex.site`, the run comments on
the pull request, and destroys that stage when the pull request closes, all with the team token
`CONVEX_ACCESS_TOKEN`. The stack is named after the repository, `owner/name` (`GITHUB_REPOSITORY`,
or the origin remote on a laptop), and so is its Convex project. Every run, in CI or local, keeps
the state of its stage in the `alchemy-state` project of the team, which the first run creates,
under the stack name; the stage `prod` owns the project, and `alchemy destroy --stage prod` deletes
it. The state has no lock, so the workflow runs one deploy of a stage at a time. A local run uses
`npx convex login` when `CONVEX_ACCESS_TOKEN` is not set:

```sh
pnpm exec alchemy deploy --stage my-preview
Expand All @@ -138,6 +141,31 @@ The files follow Cloudflare's static asset rules. See the
[convex-hosting documentation](https://github.com/samebase/convex-hosting#documentation) for custom
domains, headers, and the deploy options.

### Renaming the production stage

A repository that deployed production as the stage `main`, before this template named it `prod`,
moves it once:

1. Run `alchemy.yml` on `main` with **adopt** (`gh workflow run alchemy.yml -f adopt=true`). The
stage `prod` takes over the existing project and production deployment and pushes to it; nothing
is created or deleted.
2. Delete the state of the stage `main`, and only its state. Never run
`alchemy destroy --stage main`: it deletes the project, which is also the project of `prod`.
`alchemy state delete <stack>/main --recursive` cannot do it either, because it splits its path
at each `/` and the stack name `owner/name` has one. Run the state store's `removeStack`
mutation, signed in with `npx convex login`:

```sh
npx convex run --deployment <team>:alchemy-state:prod state:removeStack "{stack: 'owner/name', stage: 'main'}"
```

3. Check the stages that are left; the list has `prod` and the `pr-<number>` stages of open pull
requests:

```sh
npx convex run --deployment <team>:alchemy-state:prod state:stages "{stack: 'owner/name'}"
```

## Important files

- `package.json` defines the supported development, check, build, and deploy commands.
Expand Down
33 changes: 17 additions & 16 deletions alchemy.run.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Stage `main` is the production deployment of the Convex project of this
// repository. Any other stage is a preview deployment named after the pull
// request branch (GITHUB_HEAD_REF, or else the stage), which `alchemy destroy`
// deletes. The push is the `deploy` script: convex-hosting builds the site,
// sets the Convex Auth keys, uploads the site, and pushes the functions.
// Stage `prod` is the production deployment of the Convex project of this
// repository. Any other stage, such as `pr-42` for a pull request, is a
// preview deployment named after the stage, which `alchemy destroy` deletes.
// The push is the `deploy` script: convex-hosting builds the site, sets the
// Convex Auth keys, uploads the site, and pushes the functions.
//
// The stack is named after the repository, `owner/name`, and its Convex
// project after `name`. The state of every stage is in the `alchemy-state`
Expand Down Expand Up @@ -39,29 +39,30 @@ export default Alchemy.Stack(
},
Effect.gen(function* () {
const { stage } = yield* Alchemy.Stack;
// scripts/ensure-convex-auth.ts selects a preview by GITHUB_HEAD_REF.
const name = process.env["GITHUB_HEAD_REF"] || stage;
const source = {
path: "./convex",
command: ["node", "--run", "deploy"],
// The build inputs. The functions, package.json, and the lockfile are always hashed.
include: ["src/**", "public/**", "scripts/**", "vite.config.ts", "tsconfig*.json"],
} satisfies Convex.Source;
// The stage `main` owns the project, and `alchemy destroy --stage main` deletes it with
// every deployment. A preview stage reads the project from the state of `main`.
// The stage `prod` owns the project, and `alchemy destroy --stage prod` deletes it with
// every deployment. A preview stage reads the project from the state of `prod`.
const project =
stage === "main"
stage === "prod"
? yield* Convex.Project("Project", {
team,
name: repository.slice(repository.indexOf("/") + 1),
}).pipe(RemovalPolicy.destroy())
: yield* Convex.Project.ref("Project", { stage: "main" });
: yield* Convex.Project.ref("Project", { stage: "prod" });
const deployment =
stage === "main"
stage === "prod"
? yield* Convex.Deployment("Deployment", { project, type: "prod", source })
: yield* Convex.Deployment("Deployment", { project, type: "preview", name, source }).pipe(
RemovalPolicy.destroy(),
);
: yield* Convex.Deployment("Deployment", {
project,
type: "preview",
name: stage,
source,
}).pipe(RemovalPolicy.destroy());

const github = yield* GitHub.GitHubEnv;
if (github?.pr) {
Expand All @@ -71,7 +72,7 @@ export default Alchemy.Stack(
owner: github.owner,
repository: github.repository,
issueNumber: github.pr,
body: Output.interpolate`Preview \`${name}\`: ${deployment.siteUrl}`,
body: Output.interpolate`Preview \`${stage}\`: ${deployment.siteUrl}`,
}).pipe(RemovalPolicy.retain());
}
return { url: deployment.siteUrl };
Expand Down
Loading