From 32564fe02aab7b7fa36535448ea4702fa79a7fdd Mon Sep 17 00:00:00 2001 From: Nicu Chiciuc Date: Wed, 21 Oct 2026 20:49:46 +0300 Subject: [PATCH] Name stages prod and pr-, the upstream way The production stage is `prod` and a pull request deploys the stage `pr-`, as https://alchemy.run/environments/ci names them. Previews read the project from the state of `prod`, and the preview deployment takes the name of the stage. The README says how a repository that deployed production as `main` moves it to `prod` once. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/alchemy.yml | 20 ++++------ README.md | 72 ++++++++++++++++++++++++----------- alchemy.run.ts | 33 ++++++++-------- 3 files changed, 75 insertions(+), 50 deletions(-) diff --git a/.github/workflows/alchemy.yml b/.github/workflows/alchemy.yml index 22edd9e..241c603 100644 --- a/.github/workflows/alchemy.yml +++ b/.github/workflows/alchemy.yml @@ -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-`, 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. @@ -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-` 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: @@ -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 @@ -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 }} diff --git a/README.md b/README.md index 71c04a9..6d0b72c 100644 --- a/README.md +++ b/README.md @@ -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-`, 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). @@ -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://.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-` and comments its URL on the pull request. The run's log + ends with the site URL, `https://.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 @@ -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-`, a +preview deployment with that name whose URL, `https://.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 @@ -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 /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 :alchemy-state:prod state:removeStack "{stack: 'owner/name', stage: 'main'}" + ``` + +3. Check the stages that are left; the list has `prod` and the `pr-` stages of open pull + requests: + + ```sh + npx convex run --deployment :alchemy-state:prod state:stages "{stack: 'owner/name'}" + ``` + ## Important files - `package.json` defines the supported development, check, build, and deploy commands. diff --git a/alchemy.run.ts b/alchemy.run.ts index 949f778..974e302 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -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` @@ -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) { @@ -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 };