From 1b2ff413be6c1608d404323a60a46c50fd6d855b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Mon, 29 Jun 2026 12:16:10 -0700 Subject: [PATCH] Update compute previews for AI and main --- .github/workflows/compute-preview.yml | 24 ++- Architecture/compute-preview-deploy.md | 34 +++- Architecture/demo-compute-bundling.md | 10 +- FEATURES.md | 4 + README.md | 11 +- .../compute-preview-comment.mjs | 6 - .../compute-preview-deploy.mjs | 69 +++++--- .../compute-preview/compute-preview-utils.mjs | 93 ++++++++-- .../compute-preview-utils.test.ts | 166 ++++++++++++++++-- 9 files changed, 347 insertions(+), 70 deletions(-) diff --git a/.github/workflows/compute-preview.yml b/.github/workflows/compute-preview.yml index 203514b5..43a92601 100644 --- a/.github/workflows/compute-preview.yml +++ b/.github/workflows/compute-preview.yml @@ -1,6 +1,9 @@ name: compute preview on: + push: + branches: + - main pull_request: types: - opened @@ -19,12 +22,15 @@ concurrency: jobs: deploy-preview: - if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + if: >- + (github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository) || + (github.event_name == 'push' && github.ref == 'refs/heads/main') runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ github.event.pull_request.head.sha }} + ref: ${{ github.event.pull_request.head.sha || github.sha }} - name: Setup pnpm uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 @@ -52,21 +58,27 @@ jobs: - name: Deploy preview id: deploy env: + ANTHROPIC_API_KEY: ${{ secrets.STUDIO_PREVIEW_ANTHROPIC_API_KEY }} PRISMA_API_TOKEN: ${{ secrets.STUDIO_PREVIEW_COMPUTE_TOKEN }} - PREVIEW_BRANCH_NAME: ${{ github.event.pull_request.head.ref }} + PREVIEW_BRANCH_NAME: ${{ github.event.pull_request.head.ref || github.ref_name }} run: node scripts/compute-preview/compute-preview-deploy.mjs - name: Comment preview URL on PR + if: github.event_name == 'pull_request' env: GITHUB_REPOSITORY: ${{ github.repository }} GITHUB_TOKEN: ${{ github.token }} - PREVIEW_BRANCH_NAME: ${{ github.event.pull_request.head.ref }} PREVIEW_PR_NUMBER: ${{ github.event.pull_request.number }} - PREVIEW_SERVICE_NAME: ${{ steps.deploy.outputs.preview_service_name }} PREVIEW_SERVICE_URL: ${{ steps.deploy.outputs.preview_service_url }} - PREVIEW_VERSION_URL: ${{ steps.deploy.outputs.preview_version_url }} run: node scripts/compute-preview/compute-preview-comment.mjs + - name: Summarize main preview URL + if: github.event_name == 'push' + run: | + echo "### Main Compute preview" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "${{ steps.deploy.outputs.preview_service_url }}" >> "$GITHUB_STEP_SUMMARY" + destroy-preview: if: github.event_name == 'delete' && github.event.ref_type == 'branch' runs-on: ubuntu-latest diff --git a/Architecture/compute-preview-deploy.md b/Architecture/compute-preview-deploy.md index cec403f3..1b68a7ae 100644 --- a/Architecture/compute-preview-deploy.md +++ b/Architecture/compute-preview-deploy.md @@ -1,11 +1,12 @@ # Compute Preview Deploys -This document is normative for branch-scoped Compute preview deployments. +This document is normative for branch-scoped and latest-main Compute preview +deployments. ## Purpose -Pull requests need a live Studio preview without manually creating and cleaning up -Compute services for every branch. +Pull requests and the latest `main` branch need live Studio previews without +manually creating and cleaning up Compute services. The preview deployment path uses the existing `pnpm build:deploy` artifact and publishes it into the dedicated Compute project named `studio-preview`. @@ -14,6 +15,7 @@ publishes it into the dedicated Compute project named `studio-preview`. - A preview deploy MUST run when a pull request is opened, reopened, or updated with new commits. +- A stable `main` preview deploy MUST run whenever `main` receives new commits. - Preview deploys MUST only run for branches inside this repository. Forked pull requests MUST NOT receive the Compute token. - A preview service MUST be destroyed when the corresponding Git branch is @@ -24,7 +26,9 @@ publishes it into the dedicated Compute project named `studio-preview`. ## Service Naming -- Preview services MUST be keyed by the pull request branch name. +- Pull request preview services MUST be keyed by the pull request branch name. +- The latest-main preview service MUST be keyed by the literal branch name + `main`. - Because Compute service names need a filesystem- and URL-safe shape, the raw branch name MUST be normalized to a lowercase slug containing only alphanumeric segments separated by `-`. @@ -45,8 +49,22 @@ publishes it into the dedicated Compute project named `studio-preview`. - If the service already exists, the helper MUST deploy a new version to that same service. - Deployments MUST use the published CLI entrypoint: -- `bunx @prisma/compute-cli@latest deploy --skip-build --path deploy --entrypoint bundle/compute-entrypoint.js --http-port 8080`. -- The Compute artifact MUST include `bundle/compute-entrypoint.js`, which defaults `STUDIO_DEMO_PORT` to `8080` before importing `bundle/server.bundle.js`. Preview deploys MUST NOT pass runtime environment variables through the Compute CLI while its deploy API rejects the CLI's `envVars` request key. + `bunx @prisma/compute-cli@latest deploy --skip-build --path deploy --entrypoint bundle/compute-entrypoint.js --http-port 8080`. +- The Compute artifact MUST include `bundle/compute-entrypoint.js`, which + defaults `STUDIO_DEMO_PORT` to `8080` before importing + `bundle/server.bundle.js`. +- Preview deploys MUST pass runtime environment through a temporary Compute CLI + env file rather than embedding secrets into the artifact. +- Preview runtime env MUST include `STUDIO_DEMO_PORT=8080`, + `STUDIO_DEMO_AI_ENABLED=true`, and `ANTHROPIC_API_KEY` from the GitHub + Actions secret `STUDIO_PREVIEW_ANTHROPIC_API_KEY`. + +## Main Preview + +- The stable preview for the latest `main` MUST use `main` as the service key so + the Compute service URL remains stable across pushes. +- Successful `main` preview deploys SHOULD write the live service URL to the + GitHub Actions job summary. ## PR Feedback @@ -54,5 +72,5 @@ publishes it into the dedicated Compute project named `studio-preview`. request. - The PR comment MUST be sticky: later deploys for the same PR update the existing preview comment instead of creating duplicates. -- The comment MUST include the original branch name plus the resolved Compute - service name so any slug normalization stays visible. +- The visible PR comment content MUST include only one preview URL. Branch, + service, and version identifiers MUST stay out of the visible comment body. diff --git a/Architecture/demo-compute-bundling.md b/Architecture/demo-compute-bundling.md index 6f1843c5..52dd6966 100644 --- a/Architecture/demo-compute-bundling.md +++ b/Architecture/demo-compute-bundling.md @@ -24,11 +24,14 @@ It is responsible for: 4. copying Prisma Dev runtime assets into `bundle/` with their stable filenames 5. bundling Prisma Streams local's worker into `touch/processor_worker.js` 6. copying the worker's vendored `hash_vendor/` files into `touch/` -7. writing a self-contained output directory whose entrypoint is `bundle/server.bundle.js` +7. writing a self-contained output directory whose Compute entrypoint is + `bundle/compute-entrypoint.js` +8. writing `bundle/compute-entrypoint.js` so it defaults `STUDIO_DEMO_PORT` to + `8080` before importing `bundle/server.bundle.js` ## Prisma Dev Runtime Assets -`@prisma/dev@0.24.6` exposes a Bun runtime-asset manifest for PGlite and also +`@prisma/dev@0.24.13` exposes a Bun runtime-asset manifest for PGlite and also exports `copyPrismaDevRuntimeAssets()`. That means when `build-compute.ts` bundles `demo/ppg-dev/server.ts` with Bun: @@ -49,7 +52,8 @@ hashed Bun-emitted assets and the canonical names. For the Compute artifact that means: -- the server entrypoint stays at `deploy/bundle/server.bundle.js` +- the Compute entrypoint stays at `deploy/bundle/compute-entrypoint.js` +- the bundled server stays at `deploy/bundle/server.bundle.js` - stable PGlite assets live in `deploy/bundle/` - the Streams worker must live at `deploy/touch/processor_worker.js` - the worker's vendored hashing modules must live at `deploy/touch/hash_vendor/` diff --git a/FEATURES.md b/FEATURES.md index d18c0018..61f1bfa5 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -33,6 +33,10 @@ Compute project without hand-creating services for each branch. The preview workflow derives a stable Compute-safe service name from the branch, reuses that service across later pushes, posts the live URL back to the PR, and destroys the preview service when the branch is deleted. +The same workflow deploys the latest `main` branch to a stable `main` Compute +service URL, and preview deployments carry the configured Anthropic key so the +demo's AI filtering, SQL generation, visualization, and Query Insights +recommendation features are available in previews. ## Introspection Recovery and Retry diff --git a/README.md b/README.md index aeb7bb5c..7aa65621 100644 --- a/README.md +++ b/README.md @@ -676,12 +676,17 @@ This repo also maintains branch-scoped Compute previews for pull requests. duplicates. - The workflow updates one sticky PR comment with the live preview URL after a successful deploy. +- Pushes to `main` deploy the latest default-branch build to the stable `main` + Compute service and write that service URL to the GitHub Actions job summary. - When a Git branch is deleted, the same workflow destroys the matching preview service. -The workflow expects the GitHub Actions secret -`STUDIO_PREVIEW_COMPUTE_TOKEN`, which should contain a Compute API token for the -`studio-preview` project. +The workflow expects these GitHub Actions secrets: + +- `STUDIO_PREVIEW_COMPUTE_TOKEN`, a Compute API token for the `studio-preview` + project +- `STUDIO_PREVIEW_ANTHROPIC_API_KEY`, the Anthropic key used to enable Studio AI + features in Compute previews For branch-deletion cleanup to happen automatically, the workflow must be present on the default branch. In practice that means merging the preview diff --git a/scripts/compute-preview/compute-preview-comment.mjs b/scripts/compute-preview/compute-preview-comment.mjs index 3b9e9c42..9e0d24b0 100644 --- a/scripts/compute-preview/compute-preview-comment.mjs +++ b/scripts/compute-preview/compute-preview-comment.mjs @@ -9,10 +9,7 @@ async function main() { const githubToken = getRequiredEnv("GITHUB_TOKEN"); const repository = getRequiredEnv("GITHUB_REPOSITORY"); const prNumber = getRequiredEnv("PREVIEW_PR_NUMBER"); - const branchName = getRequiredEnv("PREVIEW_BRANCH_NAME"); - const serviceName = getRequiredEnv("PREVIEW_SERVICE_NAME"); const serviceUrl = getRequiredEnv("PREVIEW_SERVICE_URL"); - const versionUrl = process.env.PREVIEW_VERSION_URL?.trim(); const [owner, repo] = repository.split("/"); if (!owner || !repo) { @@ -20,10 +17,7 @@ async function main() { } const body = buildPreviewCommentBody({ - branchName, - serviceName, serviceUrl, - versionUrl, }); const comments = await githubRequest({ githubToken, diff --git a/scripts/compute-preview/compute-preview-deploy.mjs b/scripts/compute-preview/compute-preview-deploy.mjs index 4ce4c48c..c6f5d545 100644 --- a/scripts/compute-preview/compute-preview-deploy.mjs +++ b/scripts/compute-preview/compute-preview-deploy.mjs @@ -2,12 +2,19 @@ import { execFile } from "node:child_process"; import { appendFileSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { promisify } from "node:util"; import { PREVIEW_PROJECT_NAME, + buildComputeDeployArgs, + buildPreviewDeployResult, + buildPreviewRuntimeEnv, findNamedProject, findNamedService, + formatDotenvFile, sanitizeComputeServiceName, } from "./compute-preview-utils.mjs"; @@ -15,6 +22,7 @@ const execFileAsync = promisify(execFile); async function main() { const branchName = getRequiredEnv("PREVIEW_BRANCH_NAME"); + const anthropicApiKey = getRequiredEnv("ANTHROPIC_API_KEY"); const projectName = process.env.PREVIEW_PROJECT_NAME ?? PREVIEW_PROJECT_NAME; const deployPath = process.env.PREVIEW_DEPLOY_PATH ?? "deploy"; const entrypoint = @@ -28,32 +36,49 @@ async function main() { region: project.defaultRegion ?? "eu-west-3", serviceName, }); - const deployResult = await runComputeJson([ - "deploy", - "--skip-build", - "--path", - deployPath, - "--entrypoint", - entrypoint, - "--http-port", - httpPort, - "--service", - service.id, - ]); + const runtimeEnvDir = await mkdtemp( + join(tmpdir(), "studio-compute-preview-env-"), + ); + const runtimeEnvPath = join(runtimeEnvDir, ".env"); + let deployResult; + + try { + await writeFile( + runtimeEnvPath, + formatDotenvFile( + buildPreviewRuntimeEnv({ + anthropicApiKey, + httpPort, + }), + ), + { mode: 0o600 }, + ); + deployResult = await runComputeJson( + buildComputeDeployArgs({ + deployPath, + entrypoint, + envFilePath: runtimeEnvPath, + httpPort, + serviceId: service.id, + }), + ); + } finally { + await rm(runtimeEnvDir, { force: true, recursive: true }); + } - const result = { + const result = buildPreviewDeployResult({ branchName, - projectId: project.id, region: project.defaultRegion ?? "eu-west-3", - serviceId: service.id, + project, + deployResult, + service, serviceName, - serviceUrl: deployResult.serviceEndpointDomain, - versionId: deployResult.versionId, - versionUrl: deployResult.versionEndpointDomain, - }; + }); writeOutputs(result); - process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + process.stdout.write( + `${JSON.stringify({ previewUrl: result.serviceUrl }, null, 2)}\n`, + ); } async function resolveProject(projectName) { @@ -139,7 +164,9 @@ function writeOutputs(result) { preview_service_url: result.serviceUrl, preview_version_id: result.versionId, preview_version_url: result.versionUrl, - }).map(([key, value]) => `${key}=${value}`); + }) + .filter(([, value]) => value != null) + .map(([key, value]) => `${key}=${value}`); appendFileSync(outputPath, `${lines.join("\n")}\n`); } diff --git a/scripts/compute-preview/compute-preview-utils.mjs b/scripts/compute-preview/compute-preview-utils.mjs index 1d098521..39b6b9cb 100644 --- a/scripts/compute-preview/compute-preview-utils.mjs +++ b/scripts/compute-preview/compute-preview-utils.mjs @@ -39,26 +39,95 @@ export function findNamedService(services, serviceName) { return services.find((service) => service.name === serviceName); } -export function buildPreviewCommentBody(args) { +export function buildPreviewRuntimeEnv(args) { + const { anthropicApiKey, httpPort } = args; + + return { + ANTHROPIC_API_KEY: anthropicApiKey, + STUDIO_DEMO_AI_ENABLED: "true", + STUDIO_DEMO_PORT: httpPort, + }; +} + +export function formatDotenvFile(env) { + return `${Object.entries(env) + .map(([key, value]) => `${key}=${JSON.stringify(value)}`) + .join("\n")}\n`; +} + +export function buildComputeDeployArgs(args) { const { + deployPath, + entrypoint, + envFilePath, + httpPort, + serviceId, + } = args; + const deployArgs = [ + "deploy", + "--skip-build", + "--path", + deployPath, + "--entrypoint", + entrypoint, + "--http-port", + httpPort, + ]; + + if (envFilePath) { + deployArgs.push("--env", envFilePath); + } + + deployArgs.push("--service", serviceId); + + return deployArgs; +} + +export function normalizePreviewUrl(value) { + const trimmedValue = value?.trim(); + + if (!trimmedValue) { + return undefined; + } + + return /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmedValue) + ? trimmedValue + : `https://${trimmedValue}`; +} + +export function buildPreviewDeployResult(args) { + const { branchName, deployResult, project, region, service, serviceName } = + args; + const serviceUrl = normalizePreviewUrl( + deployResult.appEndpointDomain ?? deployResult.serviceEndpointDomain, + ); + + if (!serviceUrl) { + throw new Error("Compute deploy did not return a preview URL."); + } + + return { branchName, + projectId: project.id, + region, + serviceId: service.id, serviceName, serviceUrl, - versionUrl, - } = args; + versionId: deployResult.deploymentId ?? deployResult.versionId, + versionUrl: normalizePreviewUrl( + deployResult.deploymentEndpointDomain ?? + deployResult.versionEndpointDomain, + ), + }; +} + +export function buildPreviewCommentBody(args) { + const { serviceUrl } = args; const lines = [ PREVIEW_COMMENT_MARKER, - "Compute preview deployed.", - "", - `Branch: \`${branchName}\``, - `Service: \`${serviceName}\``, - `Preview: ${serviceUrl}`, + `🖥️ Preview: ${serviceUrl}`, ]; - if (versionUrl) { - lines.push(`Version: ${versionUrl}`); - } - return lines.join("\n"); } diff --git a/scripts/compute-preview/compute-preview-utils.test.ts b/scripts/compute-preview/compute-preview-utils.test.ts index c105380e..b53a57d5 100644 --- a/scripts/compute-preview/compute-preview-utils.test.ts +++ b/scripts/compute-preview/compute-preview-utils.test.ts @@ -5,9 +5,14 @@ import { describe, expect, it } from "vitest"; import { PREVIEW_COMMENT_MARKER, + buildComputeDeployArgs, buildPreviewCommentBody, + buildPreviewDeployResult, + buildPreviewRuntimeEnv, findNamedProject, findNamedService, + formatDotenvFile, + normalizePreviewUrl, sanitizeComputeServiceName, } from "./compute-preview-utils.mjs"; @@ -70,36 +75,175 @@ describe("findNamedService", () => { }); describe("buildPreviewCommentBody", () => { - it("builds a sticky PR comment with the preview URL", () => { + it("builds a short sticky PR comment with one preview URL", () => { expect( buildPreviewCommentBody({ - branchName: "codex/public-origin-main", - serviceName: "codex-public-origin-main", serviceUrl: "https://example.cdg.prisma.build", - versionUrl: "https://version.cdg.prisma.build", }), ).toBe( [ PREVIEW_COMMENT_MARKER, - "Compute preview deployed.", + "🖥️ Preview: https://example.cdg.prisma.build", + ].join("\n"), + ); + }); +}); + +describe("normalizePreviewUrl", () => { + it("adds https to bare Compute endpoint domains", () => { + expect(normalizePreviewUrl("example.cdg.prisma.build")).toBe( + "https://example.cdg.prisma.build", + ); + }); + + it("keeps already absolute URLs unchanged", () => { + expect(normalizePreviewUrl("https://example.cdg.prisma.build")).toBe( + "https://example.cdg.prisma.build", + ); + }); +}); + +describe("buildPreviewDeployResult", () => { + it("maps current Compute CLI deploy output fields", () => { + expect( + buildPreviewDeployResult({ + branchName: "codex/stable-main-compute-preview", + deployResult: { + appEndpointDomain: "service.cdg.prisma.build", + deploymentEndpointDomain: "version.cdg.prisma.build", + deploymentId: "dep_123", + }, + project: { id: "proj_123" }, + region: "eu-west-3", + service: { id: "svc_123" }, + serviceName: "codex-stable-main-compute-preview", + }), + ).toEqual({ + branchName: "codex/stable-main-compute-preview", + projectId: "proj_123", + region: "eu-west-3", + serviceId: "svc_123", + serviceName: "codex-stable-main-compute-preview", + serviceUrl: "https://service.cdg.prisma.build", + versionId: "dep_123", + versionUrl: "https://version.cdg.prisma.build", + }); + }); + + it("rejects deploy results without a preview URL", () => { + expect(() => + buildPreviewDeployResult({ + branchName: "branch", + deployResult: {}, + project: { id: "proj_123" }, + region: "eu-west-3", + service: { id: "svc_123" }, + serviceName: "branch", + }), + ).toThrow("Compute deploy did not return a preview URL"); + }); +}); + +describe("buildPreviewRuntimeEnv", () => { + it("enables Studio AI with the configured Anthropic key", () => { + expect( + buildPreviewRuntimeEnv({ + anthropicApiKey: "sk-ant-test", + httpPort: "8080", + }), + ).toEqual({ + ANTHROPIC_API_KEY: "sk-ant-test", + STUDIO_DEMO_AI_ENABLED: "true", + STUDIO_DEMO_PORT: "8080", + }); + }); +}); + +describe("formatDotenvFile", () => { + it("quotes values for the Compute CLI env file", () => { + expect( + formatDotenvFile({ + ANTHROPIC_API_KEY: "sk-ant-test", + STUDIO_DEMO_AI_ENABLED: "true", + }), + ).toBe( + [ + 'ANTHROPIC_API_KEY="sk-ant-test"', + 'STUDIO_DEMO_AI_ENABLED="true"', "", - "Branch: `codex/public-origin-main`", - "Service: `codex-public-origin-main`", - "Preview: https://example.cdg.prisma.build", - "Version: https://version.cdg.prisma.build", ].join("\n"), ); }); }); +describe("buildComputeDeployArgs", () => { + it("uses the compute entrypoint and env file for deploys", () => { + expect( + buildComputeDeployArgs({ + deployPath: "deploy", + entrypoint: "bundle/compute-entrypoint.js", + envFilePath: "/tmp/preview.env", + httpPort: "8080", + serviceId: "svc_123", + }), + ).toEqual([ + "deploy", + "--skip-build", + "--path", + "deploy", + "--entrypoint", + "bundle/compute-entrypoint.js", + "--http-port", + "8080", + "--env", + "/tmp/preview.env", + "--service", + "svc_123", + ]); + }); +}); + describe("compute preview deploy script", () => { - it("uses the compute entrypoint instead of passing rejected env vars to deploy", async () => { + it("requires the Anthropic key and passes a Compute env file to deploy", async () => { const deployScript = await readFile( join(import.meta.dirname, "compute-preview-deploy.mjs"), "utf8", ); expect(deployScript).toContain('"bundle/compute-entrypoint.js"'); - expect(deployScript).not.toContain('"--env"'); + expect(deployScript).toContain('getRequiredEnv("ANTHROPIC_API_KEY")'); + expect(deployScript).toContain("buildPreviewRuntimeEnv"); + expect(deployScript).toContain("envFilePath"); + }); +}); + +describe("compute preview workflow", () => { + it("deploys same-repo PR branches and the latest main branch", async () => { + const workflow = await readFile( + join( + import.meta.dirname, + "..", + "..", + ".github", + "workflows", + "compute-preview.yml", + ), + "utf8", + ); + + expect(workflow).toContain("push:"); + expect(workflow).toContain("- main"); + expect(workflow).toContain( + "github.event.pull_request.head.repo.full_name == github.repository", + ); + expect(workflow).toContain("github.ref == 'refs/heads/main'"); + expect(workflow).toContain( + "PREVIEW_BRANCH_NAME: ${{ github.event.pull_request.head.ref || github.ref_name }}", + ); + expect(workflow).toContain( + "ANTHROPIC_API_KEY: ${{ secrets.STUDIO_PREVIEW_ANTHROPIC_API_KEY }}", + ); + expect(workflow).not.toContain("PREVIEW_SERVICE_NAME:"); + expect(workflow).not.toContain("PREVIEW_VERSION_URL:"); }); });