diff --git a/docs/deploying.md b/docs/deploying.md index 6beb62651..043127235 100644 --- a/docs/deploying.md +++ b/docs/deploying.md @@ -36,19 +36,21 @@ npm run deploy -- --help -## Automated deploys +## Continuous deployment -After deploying an app manually at least once, Observable can handle subsequent deploys for you automatically. You can automate deploys both [on commit](https://observablehq.com/documentation/data-apps/github) (whenever you push a new commit to your project’s default branch) and [on schedule](https://observablehq.com/documentation/data-apps/schedules) (such as daily or weekly). +### Cloud builds -Automatic deploys — also called _continuous deployment_ or _CD_ — ensure that your data is always up to date, and that any changes you make to your app are immediately reflected in the deployed version. +Connect your app to Observable to handle deploys automatically. You can automate deploys both [on commit](https://observablehq.com/documentation/data-apps/github) (whenever you push a new commit to your project’s default branch) and [on schedule](https://observablehq.com/documentation/data-apps/schedules) (such as daily or weekly). + +Continuous deployment (for short, _CD_) ensures that your data is always up to date, and that any changes you make to your app are immediately reflected in the deployed version. On your app settings page on Observable, open the **Build settings** tab to set up a link to a GitHub repository hosting your project’s files. Observable will then listen for changes in the repo and deploy the app automatically. -The settings page also allows you to trigger a manual deploy on Observable Cloud, add secrets (for data loaders to use private APIs and passwords), view logs, configure sharing, _etc._ For details, see the [Building & deploying](https://observablehq.com/documentation/data-apps/deploys) documentation. +The settings page also allows you to trigger a manual deploy, add secrets for data loaders to use private APIs and passwords, view logs, configure sharing, _etc._ For details, see the [Building & deploying](https://observablehq.com/documentation/data-apps/deploys) documentation. -## GitHub Actions +### GitHub Actions -As an alternative to building on Observable Cloud, you can use [GitHub Actions](https://github.com/features/actions) and have GitHub build a new version of your app and deploy it to Observable. In your git repository, create and commit a file at `.github/workflows/deploy.yml`. Here is a starting example: +Alternatively, you can use [GitHub Actions](https://github.com/features/actions) to have GitHub build a new version of your app and deploy it to Observable. In your git repository, create and commit a file at `.github/workflows/deploy.yml`. Here is a starting example: ```yaml name: Deploy @@ -88,7 +90,7 @@ To create an API key: 1. Open the [API Key settings](https://observablehq.com/select-workspace?next=api-keys-settings) for your Observable workspace. 2. Click **New API Key**. -3. Check the **Deploy new versions of projects** checkbox. +3. Check the **Deploy new versions of data apps** checkbox. 4. Give your key a description, such as “Deploy via GitHub Actions”. 5. Click **Create API Key**. @@ -137,6 +139,8 @@ This uses one cache per calendar day (in the `America/Los_Angeles` time zone). I
You’ll need to edit the paths above if you’ve configured a source root other than src.
+
Caching is limited for now to manual builds and GitHub Actions. In the future, it will be available as a configuration option for Observable Cloud builds.
+ ## Deploy configuration The deploy command creates a file at .observablehq/deploy.json under the source root (typically src) with information on where to deploy the app. This file allows you to re-deploy an app without having to repeat where you want the app to live on Observable. @@ -147,7 +151,8 @@ The contents of the deploy config file look like this: { "projectId": "0123456789abcdef", "projectSlug": "hello-framework", - "workspaceLogin": "acme" + "workspaceLogin": "acme", + "continuousDeployment": true } ``` diff --git a/src/deploy.ts b/src/deploy.ts index dbf19540d..739fae594 100644 --- a/src/deploy.ts +++ b/src/deploy.ts @@ -1,7 +1,10 @@ +import {exec} from "node:child_process"; import {createHash} from "node:crypto"; import type {Stats} from "node:fs"; +import {existsSync} from "node:fs"; import {readFile, stat} from "node:fs/promises"; import {join} from "node:path/posix"; +import {promisify} from "node:util"; import slugify from "@sindresorhus/slugify"; import wrapAnsi from "wrap-ansi"; import type {BuildEffects, BuildManifest, BuildOptions} from "./build.js"; @@ -15,7 +18,7 @@ import {visitFiles} from "./files.js"; import type {Logger} from "./logger.js"; import type {AuthEffects} from "./observableApiAuth.js"; import {defaultEffects as defaultAuthEffects, formatUser, loginInner, validWorkspaces} from "./observableApiAuth.js"; -import {ObservableApiClient} from "./observableApiClient.js"; +import {ObservableApiClient, getObservableUiOrigin} from "./observableApiClient.js"; import type { DeployManifestFile, GetCurrentUserResponse, @@ -33,6 +36,26 @@ const DEPLOY_POLL_MAX_MS = 1000 * 60 * 5; const DEPLOY_POLL_INTERVAL_MS = 1000 * 5; const BUILD_AGE_WARNING_MS = 1000 * 60 * 5; +const OBSERVABLE_UI_ORIGIN = getObservableUiOrigin(); + +function settingsUrl(deployTarget: DeployTargetInfo) { + if (deployTarget.create) throw new Error("Incorrect deploy target state"); + return `${OBSERVABLE_UI_ORIGIN}projects/@${deployTarget.workspace.login}/${deployTarget.project.slug}`; +} + +/** + * Returns the ownerName and repoName of the first GitHub remote (HTTPS or SSH) + * on the current repository. Supports both https and ssh URLs: + * - https://github.com/observablehq/framework.git + * - git@github.com:observablehq/framework.git + */ +async function getGitHubRemote(): Promise<{ownerName: string; repoName: string} | undefined> { + const firstRemote = (await promisify(exec)("git remote -v")).stdout.match( + /^\S+\s(https:\/\/github.com\/|git@github.com:)(?[^/]+)\/(?[^/]*?)(\.git)?\s/m + ); + return firstRemote?.groups as {ownerName: string; repoName: string} | undefined; +} + export interface DeployOptions { config: Config; deployConfigPath: string | undefined; @@ -84,7 +107,7 @@ type DeployTargetInfo = | {create: true; workspace: {id: string; login: string}; projectSlug: string; title: string; accessLevel: string} | {create: false; workspace: {id: string; login: string}; project: GetProjectResponse}; -/** Deploy a project to ObservableHQ */ +/** Deploy a project to Observable */ export async function deploy(deployOptions: DeployOptions, effects = defaultEffects): Promise { Telemetry.record({event: "deploy", step: "start", force: deployOptions.force}); effects.clack.intro(`${inverse(" observable deploy ")} ${faint(`v${process.env.npm_package_version}`)}`); @@ -180,24 +203,145 @@ class Deployer { const {deployId} = this.deployOptions; if (!deployId) throw new Error("invalid deploy options"); await this.checkDeployCreated(deployId); + await this.uploadFiles(deployId, await this.getBuildFilePaths()); + await this.markDeployUploaded(deployId); + return await this.pollForProcessingCompletion(deployId); + } - const buildFilePaths = await this.getBuildFilePaths(); + private async cloudBuild(deployTarget: DeployTargetInfo) { + if (deployTarget.create) throw new Error("Incorrect deploy target state"); + const {deployPollInterval: pollInterval = DEPLOY_POLL_INTERVAL_MS} = this.deployOptions; + await this.apiClient.postProjectBuild(deployTarget.project.id); + const spinner = this.effects.clack.spinner(); + spinner.start("Requesting deploy"); + const pollExpiration = Date.now() + DEPLOY_POLL_MAX_MS; + while (true) { + if (Date.now() > pollExpiration) { + spinner.stop("Requesting deploy timed out."); + throw new CliError("Requesting deploy failed"); + } + const {latestCreatedDeployId} = await this.apiClient.getProject({ + workspaceLogin: deployTarget.workspace.login, + projectSlug: deployTarget.project.slug + }); + if (latestCreatedDeployId !== deployTarget.project.latestCreatedDeployId) { + spinner.stop( + `Deploy started. Watch logs: ${link(`${settingsUrl(deployTarget)}/deploys/${latestCreatedDeployId}`)}` + ); + // latestCreatedDeployId is initially null for a new project, but once + // it changes to a string it can never change back; since we know it has + // changed, we assert here that it’s not null + return latestCreatedDeployId!; + } + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + } - await this.uploadFiles(deployId, buildFilePaths); - await this.markDeployUploaded(deployId); - const deployInfo = await this.pollForProcessingCompletion(deployId); + // Throws error if local and remote GitHub repos don’t match or are invalid. + // Ignores this.deployOptions.config.root as we only support cloud builds from + // the root directory. + private async validateGitHubLink(deployTarget: DeployTargetInfo): Promise { + if (deployTarget.create) throw new Error("Incorrect deploy target state"); + if (!deployTarget.project.build_environment_id) throw new CliError("No build environment configured."); + if (!existsSync(".git")) throw new CliError("Not at root of a git repository."); + const remote = await getGitHubRemote(); + if (!remote) throw new CliError("No GitHub remote found."); + const branch = (await promisify(exec)("git rev-parse --abbrev-ref HEAD")).stdout.trim(); + if (!branch) throw new Error("Branch not found."); + + // If a source repository has already been configured, check that it’s + // accessible and matches the linked repository and branch. TODO: validate + // local/remote refs match, "Your branch is up to date", and "nothing to + // commit, working tree clean". + const {source} = deployTarget.project; + if (source) { + const linkedRepo = await this.apiClient.getGitHubRepository(remote); + if (linkedRepo) { + if (source.provider_id !== linkedRepo.provider_id) { + throw new CliError( + `Configured repository does not match local repository; check build settings on ${link( + `${settingsUrl(deployTarget)}/settings` + )}` + ); + } + if (source.branch !== branch) { + // TODO: If source.branch is empty, it'll use the default repository + // branch (usually main or master), which we don't know from our current + // getGitHubRepository response, and thus can't check here. + throw new CliError( + `Configured branch ${source.branch} does not match local branch ${branch}; check build settings on ${link( + `${settingsUrl(deployTarget)}/settings` + )}` + ); + } + } - return deployInfo; + if (!(await this.apiClient.getGitHubRepository({providerId: source.provider_id}))) { + // TODO: This could poll for auth too, but is a distinct case because it + // means the repo was linked at one point and then something went wrong + throw new CliError( + `Cannot access configured repository; check build settings on ${link( + `${settingsUrl(deployTarget)}/settings` + )}` + ); + } + + // Configured repo is OK; proceed + return; + } + + // If the source has not been configured, first check that the remote repo + // is linked in CD settings. If not, prompt the user to auth & link. + let linkedRepo = await this.apiClient.getGitHubRepository(remote); + if (!linkedRepo) { + if (!this.effects.isTty) + throw new CliError( + "Cannot access repository for continuous deployment and cannot request access in non-interactive mode" + ); + + // Repo is not authorized; link to auth page and poll for auth + const authUrl = new URL("/auth-github", OBSERVABLE_UI_ORIGIN); + authUrl.searchParams.set("owner", remote.ownerName); + authUrl.searchParams.set("repo", remote.repoName); + this.effects.clack.log.info( + `Authorize Observable to access the ${bold(remote.repoName)} repository: ${link(authUrl)}` + ); + + const spinner = this.effects.clack.spinner(); + spinner.start("Waiting for authorization"); + const {deployPollInterval: pollInterval = DEPLOY_POLL_INTERVAL_MS} = this.deployOptions; + const pollExpiration = Date.now() + DEPLOY_POLL_MAX_MS; + do { + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + if (Date.now() > pollExpiration) { + spinner.stop("Authorization timed out."); + throw new CliError("Repository authorization failed"); + } + } while (!(linkedRepo = await this.apiClient.getGitHubRepository(remote))); + spinner.stop("Repository authorized."); + } + + // Save the linked repo as the configured source. + const {provider, provider_id, url} = linkedRepo; + await this.apiClient + .postProjectEnvironment(deployTarget.project.id, {source: {provider, provider_id, url, branch}}) + .catch((error) => { + throw new CliError("Setting source repository for continuous deployment failed", {cause: error}); + }); } private async startNewDeploy(): Promise { - const deployConfig = await this.getUpdatedDeployConfig(); - const deployTarget = await this.getDeployTarget(deployConfig); - const buildFilePaths = await this.getBuildFilePaths(); - const deployId = await this.createNewDeploy(deployTarget); - - await this.uploadFiles(deployId, buildFilePaths); - await this.markDeployUploaded(deployId); + const {deployConfig, deployTarget} = await this.getDeployTarget(await this.getUpdatedDeployConfig()); + let deployId: string; + if (deployConfig.continuousDeployment) { + await this.validateGitHubLink(deployTarget); + deployId = await this.cloudBuild(deployTarget); + } else { + const buildFilePaths = await this.getBuildFilePaths(); + deployId = await this.createNewDeploy(deployTarget); + await this.uploadFiles(deployId, buildFilePaths); + await this.markDeployUploaded(deployId); + } return await this.pollForProcessingCompletion(deployId); } @@ -210,11 +354,7 @@ class Deployer { } return deployInfo; } catch (error) { - if (isHttpError(error)) { - throw new CliError(`Deploy ${deployId} not found.`, { - cause: error - }); - } + if (isHttpError(error)) throw new CliError(`Deploy ${deployId} not found.`, {cause: error}); throw error; } } @@ -274,7 +414,9 @@ class Deployer { } // Get the deploy target, prompting the user as needed. - private async getDeployTarget(deployConfig: DeployConfig): Promise { + private async getDeployTarget( + deployConfig: DeployConfig + ): Promise<{deployTarget: DeployTargetInfo; deployConfig: DeployConfig}> { let deployTarget: DeployTargetInfo; if (deployConfig.workspaceLogin && deployConfig.projectSlug) { try { @@ -384,18 +526,37 @@ class Deployer { } } + let {continuousDeployment} = deployConfig; + if (continuousDeployment === null) { + const enable = await this.effects.clack.confirm({ + message: wrapAnsi( + `Do you want to enable continuous deployment? ${faint( + "Given a GitHub repository, this builds in the cloud and redeploys whenever you push to the current branch." + )}`, + this.effects.outputColumns + ), + active: "Yes, enable and build in cloud", + inactive: "No, build locally" + }); + if (this.effects.clack.isCancel(enable)) throw new CliError("User canceled deploy", {print: false, exitCode: 0}); + continuousDeployment = enable; + } + + deployConfig = { + projectId: deployTarget.project.id, + projectSlug: deployTarget.project.slug, + workspaceLogin: deployTarget.workspace.login, + continuousDeployment + }; + await this.effects.setDeployConfig( this.deployOptions.config.root, this.deployOptions.deployConfigPath, - { - projectId: deployTarget.project.id, - projectSlug: deployTarget.project.slug, - workspaceLogin: deployTarget.workspace.login - }, + deployConfig, this.effects ); - return deployTarget; + return {deployConfig, deployTarget}; } // Create the new deploy on the server. diff --git a/src/observableApiClient.ts b/src/observableApiClient.ts index ccd6eddc5..7ea717061 100644 --- a/src/observableApiClient.ts +++ b/src/observableApiClient.ts @@ -127,6 +127,35 @@ export class ObservableApiClient { return await this._fetch(url, {method: "GET"}); } + async getGitHubRepository( + props: {ownerName: string; repoName: string} | {providerId: string} + ): Promise { + const params = + "providerId" in props ? `provider_id=${props.providerId}` : `owner=${props.ownerName}&repo=${props.repoName}`; + return await this._fetch( + new URL(`/cli/github/repository?${params}`, this._apiOrigin), + {method: "GET"} + ).catch(() => null); + // TODO: err.details.errors may be [{code: "NO_GITHUB_TOKEN"}] or [{code: "NO_REPO_ACCESS"}], + // which could be handled separately + } + + async postProjectEnvironment( + id: string, + body: {source: {provider: "github"; provider_id: string; url: string; branch: string}} + ): Promise { + const url = new URL(`/cli/project/${id}/environment`, this._apiOrigin); + return await this._fetch(url, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify(body) + }); + } + + async postProjectBuild(id): Promise<{id: string}> { + return await this._fetch<{id: string}>(new URL(`/cli/project/${id}/build`, this._apiOrigin), {method: "POST"}); + } + async postProject({ title, slug, @@ -265,10 +294,43 @@ export interface GetProjectResponse { title: string; owner: {id: string; login: string}; creator: {id: string; login: string}; + latestCreatedDeployId: string | null; + automatic_builds_enabled: boolean | null; + build_environment_id: string | null; + source: null | { + provider: string; + provider_id: string; + url: string; + branch: string | null; + }; // Available fields that we don't use // servingRoot: string | null; } +export interface PostProjectEnvironmentResponse { + automatic_builds_enabled: boolean | null; + build_environment_id: string | null; + source: null | { + provider: string; + provider_id: string; + url: string; + branch: string | null; + }; +} + +export interface GetGitHubRepositoryResponse { + provider: "github"; + provider_id: string; + url: string; + default_branch: string; + name: string; + linked_projects: { + title: string; + owner_id: string; + owner_name: string; + }[]; +} + export interface DeployInfo { id: string; status: string; diff --git a/src/observableApiConfig.ts b/src/observableApiConfig.ts index 59470c9d6..b87f4e3d9 100644 --- a/src/observableApiConfig.ts +++ b/src/observableApiConfig.ts @@ -36,6 +36,7 @@ export interface DeployConfig { projectId?: string | null; projectSlug: string | null; workspaceLogin: string | null; + continuousDeployment: boolean | null; } export type ApiKey = @@ -87,11 +88,12 @@ export async function getDeployConfig( } } // normalize - let {projectId, projectSlug, workspaceLogin} = config ?? ({} as any); + let {projectId, projectSlug, workspaceLogin, continuousDeployment} = config ?? ({} as any); if (typeof projectId !== "string") projectId = null; if (typeof projectSlug !== "string") projectSlug = null; if (typeof workspaceLogin !== "string") workspaceLogin = null; - return {projectId, projectSlug, workspaceLogin}; + if (typeof continuousDeployment !== "boolean") continuousDeployment = null; + return {projectId, projectSlug, workspaceLogin, continuousDeployment}; } export async function setDeployConfig( diff --git a/test/deploy-test.ts b/test/deploy-test.ts index abca7ae80..93ca46b1d 100644 --- a/test/deploy-test.ts +++ b/test/deploy-test.ts @@ -1,7 +1,9 @@ import assert, {fail} from "node:assert"; +import {exec} from "node:child_process"; import type {Stats} from "node:fs"; -import {stat} from "node:fs/promises"; +import {open, stat} from "node:fs/promises"; import {Readable, Writable} from "node:stream"; +import {promisify} from "node:util"; import type {BuildManifest} from "../src/build.js"; import {normalizeConfig, setCurrentDate} from "../src/config.js"; import type {DeployEffects, DeployOptions} from "../src/deploy.js"; @@ -12,10 +14,11 @@ import type {ObservableApiClientOptions} from "../src/observableApiClient.js"; import type {GetCurrentUserResponse} from "../src/observableApiClient.js"; import {ObservableApiClient} from "../src/observableApiClient.js"; import type {DeployConfig} from "../src/observableApiConfig.js"; -import {stripColor} from "../src/tty.js"; +import {link, stripColor} from "../src/tty.js"; import {MockAuthEffects} from "./mocks/authEffects.js"; import {TestClackEffects} from "./mocks/clack.js"; import {MockConfigEffects} from "./mocks/configEffects.js"; +import {mockIsolatedDirectory} from "./mocks/directory.js"; import {mockJsDelivr} from "./mocks/jsdelivr.js"; import {MockLogger} from "./mocks/logger.js"; import { @@ -115,7 +118,9 @@ class MockDeployEffects extends MockAuthEffects implements DeployEffects { async getDeployConfig(sourceRoot: string, deployConfigPath?: string): Promise { const key = this.getDeployConfigKey(sourceRoot, deployConfigPath); return ( - this.deployConfigs[key] ?? this.defaultDeployConfig ?? {projectId: null, projectSlug: null, workspaceLogin: null} + this.deployConfigs[key] ?? + this.defaultDeployConfig ?? + ({projectId: null, projectSlug: null, workspaceLogin: null, continuousDeployment: null} satisfies DeployConfig) ); } @@ -190,7 +195,8 @@ const TEST_OPTIONS: DeployOptions = { const DEPLOY_CONFIG: DeployConfig & {projectId: string; projectSlug: string; workspaceLogin: string} = { projectId: "project123", projectSlug: "bi", - workspaceLogin: "mock-user-ws" + workspaceLogin: "mock-user-ws", + continuousDeployment: false }; describe("deploy", () => { @@ -198,6 +204,212 @@ describe("deploy", () => { mockObservableApi(); mockJsDelivr(); + describe("in isolated directory with git repo", () => { + mockIsolatedDirectory({git: true}); + + it("fails cloud build if repo has no GitHub remote", async () => { + getCurrentObservableApi() + .handleGetCurrentUser() + .handleGetWorkspaceProjects({ + workspaceLogin: DEPLOY_CONFIG.workspaceLogin, + projects: [] + }) + .handlePostProject({projectId: DEPLOY_CONFIG.projectId, slug: DEPLOY_CONFIG.projectSlug}) + .start(); + const effects = new MockDeployEffects(); + effects.clack.inputs.push( + true, // No apps found. Do you want to create a new app? + DEPLOY_CONFIG.projectSlug, // What slug do you want to use? + "public", // Who is allowed to access your app? + true // Do you want to enable continuous deployment? + ); + + try { + await deploy(TEST_OPTIONS, effects); + assert.fail("expected error"); + } catch (error) { + CliError.assert(error, {message: "No GitHub remote found."}); + } + + effects.close(); + }); + + it("fails cloud build if repo doesn’t match", async () => { + getCurrentObservableApi() + .handleGetCurrentUser() + .handleGetProject({ + ...DEPLOY_CONFIG, + source: { + provider: "github", + provider_id: "123:456", + url: "https://github.com/observablehq/test.git", + branch: "main" + }, + latestCreatedDeployId: null + }) + .handleGetRepository({ownerName: "observablehq", repoName: "wrongrepo", provider_id: "000:001"}) + .start(); + const effects = new MockDeployEffects({deployConfig: {...DEPLOY_CONFIG, continuousDeployment: true}}); + + await (await open("readme.md", "a")).close(); + await promisify(exec)( + "git add . && git commit -m 'initial' && git remote add origin git@github.com:observablehq/wrongrepo.git" + ); + + try { + await deploy(TEST_OPTIONS, effects); + assert.fail("expected error"); + } catch (error) { + CliError.assert(error, { + message: `Configured repository does not match local repository; check build settings on ${link( + `https://observablehq.com/projects/@${DEPLOY_CONFIG.workspaceLogin}/${DEPLOY_CONFIG.projectSlug}/settings` + )}` + }); + } + + effects.close(); + }); + + it("starts cloud build when continuous deployment is enabled for new project and repo is valid", async () => { + const deployId = "deploy123"; + getCurrentObservableApi() + .handleGetCurrentUser() + .handleGetWorkspaceProjects({ + workspaceLogin: DEPLOY_CONFIG.workspaceLogin, + projects: [] + }) + .handlePostProject({projectId: DEPLOY_CONFIG.projectId, slug: DEPLOY_CONFIG.projectSlug}) + .handleGetRepository() + .handlePostProjectEnvironment() + .handlePostProjectBuild() + .handleGetProject({...DEPLOY_CONFIG, latestCreatedDeployId: deployId}) + .handleGetDeploy({deployId, deployStatus: "uploaded"}) + .start(); + const effects = new MockDeployEffects(); + effects.clack.inputs.push( + true, // No apps found. Do you want to create a new app? + DEPLOY_CONFIG.projectSlug, // What slug do you want to use? + "public", // Who is allowed to access your app? + true // Do you want to enable continuous deployment? + ); + + await (await open("readme.md", "a")).close(); + await promisify(exec)( + "git add . && git commit -m 'initial' && git remote add origin git@github.com:observablehq/test.git" + ); + + await deploy(TEST_OPTIONS, effects); + + effects.close(); + }); + + it("starts cloud build when continuous deployment is enabled for new project and repo is manually auth’ed while CLI is polling", async () => { + const deployId = "deploy123"; + getCurrentObservableApi() + .handleGetCurrentUser() + .handleGetWorkspaceProjects({ + workspaceLogin: DEPLOY_CONFIG.workspaceLogin, + projects: [] + }) + .handlePostProject({projectId: DEPLOY_CONFIG.projectId, slug: DEPLOY_CONFIG.projectSlug}) + .handleGetRepository({status: 404}) + .handleGetRepository() + .handlePostProjectEnvironment() + .handlePostProjectBuild() + .handleGetProject({...DEPLOY_CONFIG, latestCreatedDeployId: deployId}) + .handleGetDeploy({deployId, deployStatus: "uploaded"}) + .start(); + const effects = new MockDeployEffects(); + effects.clack.inputs.push( + true, // No apps found. Do you want to create a new app? + DEPLOY_CONFIG.projectSlug, // What slug do you want to use? + "public", // Who is allowed to access your app? + true // Do you want to enable continuous deployment? + ); + + await (await open("readme.md", "a")).close(); + await promisify(exec)( + "git add . && git commit -m 'initial' && git remote add origin git@github.com:observablehq/test.git" + ); + + await deploy(TEST_OPTIONS, effects); + + effects.close(); + }); + + it("starts cloud build when continuous deployment is enabled for existing project with existing source", async () => { + const deployId = "deploy123"; + getCurrentObservableApi() + .handleGetCurrentUser() + .handleGetProject({ + ...DEPLOY_CONFIG, + source: { + provider: "github", + provider_id: "123:456", + url: "https://github.com/observablehq/test.git", + branch: "main" + }, + latestCreatedDeployId: null + }) + .handleGetRepository({useProviderId: false}) + .handleGetRepository({useProviderId: true}) + .handlePostProjectBuild() + .handleGetProject({ + ...DEPLOY_CONFIG, + source: { + provider: "github", + provider_id: "123:456", + url: "https://github.com/observablehq/test.git", + branch: "main" + }, + latestCreatedDeployId: deployId + }) + .handleGetDeploy({deployId, deployStatus: "uploaded"}) + .start(); + const effects = new MockDeployEffects({deployConfig: {...DEPLOY_CONFIG, continuousDeployment: true}}); + + await (await open("readme.md", "a")).close(); + await promisify(exec)( + "git add . && git commit -m 'initial' && git remote add origin https://github.com/observablehq/test.git" + ); + + await deploy(TEST_OPTIONS, effects); + + effects.close(); + }); + }); + + describe("in isolated directory without git repo", () => { + mockIsolatedDirectory({git: false}); + + it("fails cloud build if not in a git repo", async () => { + getCurrentObservableApi() + .handleGetCurrentUser() + .handleGetWorkspaceProjects({ + workspaceLogin: DEPLOY_CONFIG.workspaceLogin, + projects: [] + }) + .handlePostProject({projectId: DEPLOY_CONFIG.projectId}) + .start(); + const effects = new MockDeployEffects(); + effects.clack.inputs.push( + true, // No apps found. Do you want to create a new app? + DEPLOY_CONFIG.projectSlug, // What slug do you want to use? + "public", // Who is allowed to access your app? + true // Do you want to enable continuous deployment? + ); + + try { + await deploy(TEST_OPTIONS, effects); + assert.fail("expected error"); + } catch (error) { + CliError.assert(error, {message: "Not at root of a git repository."}); + } + + effects.close(); + }); + }); + it("makes expected API calls for an existing project", async () => { const deployId = "deploy456"; getCurrentObservableApi() @@ -333,6 +545,7 @@ describe("deploy", () => { effects.clack.inputs.push( DEPLOY_CONFIG.projectSlug, // which project do you want to use? true, // Do you want to continue? (and overwrite the project) + false, // Do you want to enable continuous deployment? "change project title" // "what changed?" ); await deploy(TEST_OPTIONS, effects); @@ -569,9 +782,7 @@ describe("deploy", () => { const deployId = "deploy456"; getCurrentObservableApi() .handleGetCurrentUser() - .handleGetProject({ - ...DEPLOY_CONFIG - }) + .handleGetProject({...DEPLOY_CONFIG}) .handlePostDeploy({projectId: DEPLOY_CONFIG.projectId, deployId, status: 500}) .start(); const effects = new MockDeployEffects({deployConfig: DEPLOY_CONFIG}); @@ -766,10 +977,7 @@ describe("deploy", () => { const deployId = "deployId"; getCurrentObservableApi() .handleGetCurrentUser() - .handleGetProject({ - ...DEPLOY_CONFIG, - projectId: newProjectId - }) + .handleGetProject({...DEPLOY_CONFIG, projectId: newProjectId}) .handlePostDeploy({projectId: newProjectId, deployId}) .expectStandardFiles({deployId}) .handlePostDeployUploaded({deployId}) @@ -787,10 +995,7 @@ describe("deploy", () => { const oldDeployConfig = {...DEPLOY_CONFIG, projectId: "oldProjectId"}; getCurrentObservableApi() .handleGetCurrentUser() - .handleGetProject({ - ...DEPLOY_CONFIG, - projectId: newProjectId - }) + .handleGetProject({...DEPLOY_CONFIG, projectId: newProjectId}) .start(); const effects = new MockDeployEffects({deployConfig: oldDeployConfig, isTty: true}); effects.clack.inputs.push(false); // State doesn't match do you want to continue deploying? diff --git a/test/mocks/directory.ts b/test/mocks/directory.ts new file mode 100644 index 000000000..786938e72 --- /dev/null +++ b/test/mocks/directory.ts @@ -0,0 +1,25 @@ +import {exec} from "child_process"; +import {mkdtemp, rm} from "fs/promises"; +import {tmpdir} from "os"; +import {join} from "path/posix"; +import {promisify} from "util"; + +export function mockIsolatedDirectory({git}: {git: boolean}) { + let dir: string; + let cwd: string; + beforeEach(async () => { + cwd = process.cwd(); + dir = await mkdtemp(join(tmpdir(), "framework-test-")); + process.chdir(dir); + if (git) { + await promisify(exec)( + 'git init -b main && git config user.email "observable@example.com" && git config user.name "Observable User"' + ); + } + }); + + afterEach(async () => { + process.chdir(cwd); + await rm(dir, {recursive: true}); + }); +} diff --git a/test/mocks/observableApi.ts b/test/mocks/observableApi.ts index 65593636f..2ac1bb5e5 100644 --- a/test/mocks/observableApi.ts +++ b/test/mocks/observableApi.ts @@ -149,6 +149,8 @@ class ObservableApiMock { projectId = "project123", title = "Build test case", accessLevel = "private", + latestCreatedDeployId = null, + source = null, status = 200 }: { workspaceLogin: string; @@ -157,6 +159,8 @@ class ObservableApiMock { title?: string; accessLevel?: string; status?: number; + source?: GetProjectResponse["source"]; + latestCreatedDeployId?: null | string; }): ObservableApiMock { const response = status === 200 @@ -166,7 +170,11 @@ class ObservableApiMock { slug: projectSlug, title, creator: {id: "user-id", login: "user-login"}, - owner: {id: "workspace-id", login: "workspace-login"} + owner: {id: "workspace-id", login: workspaceLogin}, + latestCreatedDeployId, + automatic_builds_enabled: true, + build_environment_id: "abc123", + source } satisfies GetProjectResponse) : emptyErrorBody; const headers = authorizationHeader(status !== 401 && status !== 403); @@ -203,7 +211,11 @@ class ObservableApiMock { slug, title: "Mock Project", owner, - creator + creator, + latestCreatedDeployId: null, + automatic_builds_enabled: true, + build_environment_id: "abc123", + source: null } satisfies GetProjectResponse) : emptyErrorBody; const headers = authorizationHeader(status !== 403); @@ -235,7 +247,11 @@ class ObservableApiMock { creator, owner, title: p.title ?? "Mock Title", - accessLevel: p.accessLevel ?? "private" + accessLevel: p.accessLevel ?? "private", + latestCreatedDeployId: null, + automatic_builds_enabled: null, + build_environment_id: null, + source: null })) } satisfies PaginatedList) : emptyErrorBody; @@ -420,6 +436,88 @@ class ObservableApiMock { ); return this; } + + handleGetRepository({ + status = 200, + ownerName = "observablehq", + repoName = "test", + provider_id = "123:456", + useProviderId = false + }: {status?: number; ownerName?: string; repoName?: string; provider_id?: string; useProviderId?: boolean} = {}) { + const response = + status === 200 + ? JSON.stringify({ + provider: "github", + provider_id, + url: `https://github.com/${ownerName}/${repoName}.git`, + default_branch: "main", + name: "test", + linked_projects: [] + }) + : emptyErrorBody; + const headers = authorizationHeader(status !== 401); + if (useProviderId) { + // version that accepts provider_id + this._handlers.push((pool) => + pool + .intercept({ + path: `/cli/github/repository?provider_id=${encodeURIComponent(provider_id)}`, + headers: headersMatcher(headers) + }) + .reply(status, response, {headers: {"content-type": "application/json"}}) + ); + } else { + // version that accepts owner & repo + this._handlers.push((pool) => + pool + .intercept({ + path: `/cli/github/repository?owner=${ownerName}&repo=${repoName}`, + headers: headersMatcher(headers) + }) + .reply(status, response, {headers: {"content-type": "application/json"}}) + ); + } + return this; + } + + handlePostProjectEnvironment({status = 200}: {status?: number} = {}) { + const response = + status === 200 + ? JSON.stringify({ + automatic_builds_enabled: true, + build_environment_id: "abc123", + source: { + provider: "github", + provider_id: "123:456", + url: "https://github.com/observablehq/test.git", + branch: "main" + } + }) + : emptyErrorBody; + const headers = authorizationHeader(status !== 401); + this._handlers.push((pool) => + pool + .intercept({path: "/cli/project/project123/environment", method: "POST", headers: headersMatcher(headers)}) + .reply(status, response, {headers: {"content-type": "application/json"}}) + ); + return this; + } + + handlePostProjectBuild({status = 200}: {status?: number} = {}) { + const response = + status === 200 + ? JSON.stringify({ + id: "abc123" + }) + : emptyErrorBody; + const headers = authorizationHeader(status !== 401); + this._handlers.push((pool) => + pool + .intercept({path: "/cli/project/project123/build", method: "POST", headers: headersMatcher(headers)}) + .reply(status, response, {headers: {"content-type": "application/json"}}) + ); + return this; + } } function authorizationHeader(valid: boolean) {