Skip to content

Commit 3e5e669

Browse files
authored
Fil/onramp review (#1805)
* documentation * reformat and type * simpler * clarify the logic in validateGitHubLink fixes an uncaught Error ('Setting source repository for continuous deployment failed' was never reached)
1 parent 36e2153 commit 3e5e669

File tree

3 files changed

+92
-132
lines changed

3 files changed

+92
-132
lines changed

docs/deploying.md

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,19 @@ npm run deploy -- --help
3838

3939
## Continuous deployment
4040

41-
<!-- TODO: decide whether to primarily use “continuous deployment” or “automatic deploys” -->
41+
### Cloud builds
4242

43-
You can 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).
43+
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).
4444

45-
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.
45+
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.
4646

4747
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.
4848

49-
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.
49+
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.
5050

51-
## GitHub Actions
51+
### GitHub Actions
5252

53-
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:
53+
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:
5454

5555
```yaml
5656
name: Deploy
@@ -139,6 +139,8 @@ This uses one cache per calendar day (in the `America/Los_Angeles` time zone). I
139139

140140
<div class="note">You’ll need to edit the paths above if you’ve configured a source root other than <code>src</code>.</div>
141141

142+
<div class="tip">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.</div>
143+
142144
## Deploy configuration
143145

144146
The deploy command creates a file at <code>.observablehq/deploy.json</code> under the source root (typically <code>src</code>) 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.

src/deploy.ts

Lines changed: 71 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -39,41 +39,21 @@ const BUILD_AGE_WARNING_MS = 1000 * 60 * 5;
3939
const OBSERVABLE_UI_ORIGIN = getObservableUiOrigin();
4040

4141
function settingsUrl(deployTarget: DeployTargetInfo) {
42-
if (deployTarget.create) {
43-
throw new Error("Incorrect deploy target state");
44-
}
42+
if (deployTarget.create) throw new Error("Incorrect deploy target state");
4543
return `${OBSERVABLE_UI_ORIGIN}projects/@${deployTarget.workspace.login}/${deployTarget.project.slug}`;
4644
}
4745

4846
/**
4947
* Returns the ownerName and repoName of the first GitHub remote (HTTPS or SSH)
50-
* on the current repository, or null.
48+
* on the current repository. Supports both https and ssh URLs:
49+
* - https://github.com/observablehq/framework.git
50+
* - [email protected]:observablehq/framework.git
5151
*/
52-
async function getGitHubRemote() {
53-
const remotes = (await promisify(exec)("git remote -v")).stdout
54-
.split("\n")
55-
.filter((d) => d)
56-
.map((d) => {
57-
const [, url] = d.split(/\s/g);
58-
if (url.startsWith("https://github.com/")) {
59-
// HTTPS: https://github.com/observablehq/framework.git
60-
const [ownerName, repoName] = new URL(url).pathname
61-
.slice(1)
62-
.replace(/\.git$/, "")
63-
.split("/");
64-
return {ownerName, repoName};
65-
} else if (url.startsWith("[email protected]:")) {
66-
// SSH: git@github.com:observablehq/framework.git
67-
const [ownerName, repoName] = url
68-
.replace(/^git@github.com:/, "")
69-
.replace(/\.git$/, "")
70-
.split("/");
71-
return {ownerName, repoName};
72-
}
73-
});
74-
const remote = remotes.find((d) => d && d.ownerName && d.repoName);
75-
if (!remote) throw new CliError("No GitHub remote found.");
76-
return remote ?? null;
52+
async function getGitHubRemote(): Promise<{ownerName: string; repoName: string} | undefined> {
53+
const firstRemote = (await promisify(exec)("git remote -v")).stdout.match(
54+
/^\S+\s(https:\/\/github.com\/|git@github.com:)(?<ownerName>[^/]+)\/(?<repoName>[^/]*?)(\.git)?\s/m
55+
);
56+
return firstRemote?.groups as {ownerName: string; repoName: string} | undefined;
7757
}
7858

7959
export interface DeployOptions {
@@ -223,20 +203,13 @@ class Deployer {
223203
const {deployId} = this.deployOptions;
224204
if (!deployId) throw new Error("invalid deploy options");
225205
await this.checkDeployCreated(deployId);
226-
227-
const buildFilePaths = await this.getBuildFilePaths();
228-
229-
await this.uploadFiles(deployId, buildFilePaths);
206+
await this.uploadFiles(deployId, await this.getBuildFilePaths());
230207
await this.markDeployUploaded(deployId);
231-
const deployInfo = await this.pollForProcessingCompletion(deployId);
232-
233-
return deployInfo;
208+
return await this.pollForProcessingCompletion(deployId);
234209
}
235210

236211
private async cloudBuild(deployTarget: DeployTargetInfo) {
237-
if (deployTarget.create) {
238-
throw new Error("Incorrect deploy target state");
239-
}
212+
if (deployTarget.create) throw new Error("Incorrect deploy target state");
240213
const {deployPollInterval: pollInterval = DEPLOY_POLL_INTERVAL_MS} = this.deployOptions;
241214
await this.apiClient.postProjectBuild(deployTarget.project.id);
242215
const spinner = this.effects.clack.spinner();
@@ -264,51 +237,46 @@ class Deployer {
264237
}
265238
}
266239

267-
// Throws error if local and remote GitHub repos don’t match or are invalid
240+
// Throws error if local and remote GitHub repos don’t match or are invalid.
241+
// Ignores this.deployOptions.config.root as we only support cloud builds from
242+
// the root directory.
268243
private async validateGitHubLink(deployTarget: DeployTargetInfo): Promise<void> {
269-
if (deployTarget.create) {
270-
throw new Error("Incorrect deploy target state");
271-
}
272-
if (!deployTarget.project.build_environment_id) {
273-
// TODO: allow setting build environment from CLI
274-
throw new CliError("No build environment configured.");
275-
}
276-
// We only support cloud builds from the root directory so this ignores
277-
// this.deployOptions.config.root
278-
const isGit = existsSync(".git");
279-
if (!isGit) throw new CliError("Not at root of a git repository.");
280-
281-
const {ownerName, repoName} = await getGitHubRemote();
244+
if (deployTarget.create) throw new Error("Incorrect deploy target state");
245+
if (!deployTarget.project.build_environment_id) throw new CliError("No build environment configured.");
246+
if (!existsSync(".git")) throw new CliError("Not at root of a git repository.");
247+
const remote = await getGitHubRemote();
248+
if (!remote) throw new CliError("No GitHub remote found.");
282249
const branch = (await promisify(exec)("git rev-parse --abbrev-ref HEAD")).stdout.trim();
283-
let localRepo = await this.apiClient.getGitHubRepository({ownerName, repoName});
250+
if (!branch) throw new Error("Branch not found.");
284251

285252
// If a source repository has already been configured, check that it’s
286-
// accessible and matches the local repository and branch.
287-
// TODO: validate local/remote refs match, "Your branch is up to date",
288-
// and "nothing to commit, working tree clean".
253+
// accessible and matches the linked repository and branch. TODO: validate
254+
// local/remote refs match, "Your branch is up to date", and "nothing to
255+
// commit, working tree clean".
289256
const {source} = deployTarget.project;
290257
if (source) {
291-
if (localRepo && source.provider_id !== localRepo.provider_id) {
292-
throw new CliError(
293-
`Configured repository does not match local repository; check build settings on ${link(
294-
`${settingsUrl(deployTarget)}/settings`
295-
)}`
296-
);
297-
}
298-
if (localRepo && source.branch && source.branch !== branch) {
299-
// TODO: If source.branch is empty, it'll use the default repository
300-
// branch (usually main or master), which we don't know from our current
301-
// getGitHubRepository response, and thus can't check here.
302-
throw new CliError(
303-
`Configured branch does not match local branch; check build settings on ${link(
304-
`${settingsUrl(deployTarget)}/settings`
305-
)}`
306-
);
258+
const linkedRepo = await this.apiClient.getGitHubRepository(remote);
259+
if (linkedRepo) {
260+
if (source.provider_id !== linkedRepo.provider_id) {
261+
throw new CliError(
262+
`Configured repository does not match local repository; check build settings on ${link(
263+
`${settingsUrl(deployTarget)}/settings`
264+
)}`
265+
);
266+
}
267+
if (source.branch !== branch) {
268+
// TODO: If source.branch is empty, it'll use the default repository
269+
// branch (usually main or master), which we don't know from our current
270+
// getGitHubRepository response, and thus can't check here.
271+
throw new CliError(
272+
`Configured branch ${source.branch} does not match local branch ${branch}; check build settings on ${link(
273+
`${settingsUrl(deployTarget)}/settings`
274+
)}`
275+
);
276+
}
307277
}
308-
const remoteAuthedRepo = await this.apiClient.getGitHubRepository({
309-
providerId: source.provider_id
310-
});
311-
if (!remoteAuthedRepo) {
278+
279+
if (!(await this.apiClient.getGitHubRepository({providerId: source.provider_id}))) {
312280
// TODO: This could poll for auth too, but is a distinct case because it
313281
// means the repo was linked at one point and then something went wrong
314282
throw new CliError(
@@ -322,51 +290,49 @@ class Deployer {
322290
return;
323291
}
324292

325-
if (!localRepo) {
293+
// If the source has not been configured, first check that the remote repo
294+
// is linked in CD settings. If not, prompt the user to auth & link.
295+
let linkedRepo = await this.apiClient.getGitHubRepository(remote);
296+
if (!linkedRepo) {
326297
if (!this.effects.isTty)
327298
throw new CliError(
328299
"Cannot access repository for continuous deployment and cannot request access in non-interactive mode"
329300
);
330301

331302
// Repo is not authorized; link to auth page and poll for auth
332303
const authUrl = new URL("/auth-github", OBSERVABLE_UI_ORIGIN);
333-
authUrl.searchParams.set("owner", ownerName);
334-
authUrl.searchParams.set("repo", repoName);
335-
this.effects.clack.log.info(`Authorize Observable to access the ${bold(repoName)} repository: ${link(authUrl)}`);
304+
authUrl.searchParams.set("owner", remote.ownerName);
305+
authUrl.searchParams.set("repo", remote.repoName);
306+
this.effects.clack.log.info(
307+
`Authorize Observable to access the ${bold(remote.repoName)} repository: ${link(authUrl)}`
308+
);
336309

337310
const spinner = this.effects.clack.spinner();
338-
spinner.start("Waiting for repository to be authorized");
311+
spinner.start("Waiting for authorization");
339312
const {deployPollInterval: pollInterval = DEPLOY_POLL_INTERVAL_MS} = this.deployOptions;
340313
const pollExpiration = Date.now() + DEPLOY_POLL_MAX_MS;
341-
while (!localRepo) {
314+
do {
342315
await new Promise((resolve) => setTimeout(resolve, pollInterval));
343316
if (Date.now() > pollExpiration) {
344-
spinner.stop("Waiting for repository to be authorized timed out.");
317+
spinner.stop("Authorization timed out.");
345318
throw new CliError("Repository authorization failed");
346319
}
347-
localRepo = await this.apiClient.getGitHubRepository({ownerName, repoName});
348-
if (localRepo) spinner.stop("Repository authorized.");
349-
}
320+
} while (!(linkedRepo = await this.apiClient.getGitHubRepository(remote)));
321+
spinner.stop("Repository authorized.");
350322
}
351323

352-
const response = await this.apiClient.postProjectEnvironment(deployTarget.project.id, {
353-
source: {
354-
provider: localRepo.provider,
355-
provider_id: localRepo.provider_id,
356-
url: localRepo.url,
357-
branch
358-
}
359-
});
360-
361-
if (!response) throw new CliError("Setting source repository for continuous deployment failed");
362-
363-
// Configured repo is OK; proceed
364-
return;
324+
// Save the linked repo as the configured source.
325+
const {provider, provider_id, url} = linkedRepo;
326+
await this.apiClient
327+
.postProjectEnvironment(deployTarget.project.id, {source: {provider, provider_id, url, branch}})
328+
.catch((error) => {
329+
throw new CliError("Setting source repository for continuous deployment failed", {cause: error});
330+
});
365331
}
366332

367333
private async startNewDeploy(): Promise<GetDeployResponse> {
368334
const {deployConfig, deployTarget} = await this.getDeployTarget(await this.getUpdatedDeployConfig());
369-
let deployId: string | null;
335+
let deployId: string;
370336
if (deployConfig.continuousDeployment) {
371337
await this.validateGitHubLink(deployTarget);
372338
deployId = await this.cloudBuild(deployTarget);
@@ -388,11 +354,7 @@ class Deployer {
388354
}
389355
return deployInfo;
390356
} catch (error) {
391-
if (isHttpError(error)) {
392-
throw new CliError(`Deploy ${deployId} not found.`, {
393-
cause: error
394-
});
395-
}
357+
if (isHttpError(error)) throw new CliError(`Deploy ${deployId} not found.`, {cause: error});
396358
throw error;
397359
}
398360
}
@@ -580,7 +542,7 @@ class Deployer {
580542
continuousDeployment = enable;
581543
}
582544

583-
const newDeployConfig = {
545+
deployConfig = {
584546
projectId: deployTarget.project.id,
585547
projectSlug: deployTarget.project.slug,
586548
workspaceLogin: deployTarget.workspace.login,
@@ -590,11 +552,11 @@ class Deployer {
590552
await this.effects.setDeployConfig(
591553
this.deployOptions.config.root,
592554
this.deployOptions.deployConfigPath,
593-
newDeployConfig,
555+
deployConfig,
594556
this.effects
595557
);
596558

597-
return {deployConfig: newDeployConfig, deployTarget};
559+
return {deployConfig, deployTarget};
598560
}
599561

600562
// Create the new deploy on the server.

src/observableApiClient.ts

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -130,22 +130,20 @@ export class ObservableApiClient {
130130
async getGitHubRepository(
131131
props: {ownerName: string; repoName: string} | {providerId: string}
132132
): Promise<GetGitHubRepositoryResponse | null> {
133-
let url: URL;
134-
if ("providerId" in props) {
135-
url = new URL(`/cli/github/repository?provider_id=${props.providerId}`, this._apiOrigin);
136-
} else {
137-
url = new URL(`/cli/github/repository?owner=${props.ownerName}&repo=${props.repoName}`, this._apiOrigin);
138-
}
139-
try {
140-
return await this._fetch<GetGitHubRepositoryResponse>(url, {method: "GET"});
141-
} catch (err) {
142-
// TODO: err.details.errors may be [{code: "NO_GITHUB_TOKEN"}] or [{code: "NO_REPO_ACCESS"}],
143-
// which could be handled separately
144-
return null;
145-
}
133+
const params =
134+
"providerId" in props ? `provider_id=${props.providerId}` : `owner=${props.ownerName}&repo=${props.repoName}`;
135+
return await this._fetch<GetGitHubRepositoryResponse>(
136+
new URL(`/cli/github/repository?${params}`, this._apiOrigin),
137+
{method: "GET"}
138+
).catch(() => null);
139+
// TODO: err.details.errors may be [{code: "NO_GITHUB_TOKEN"}] or [{code: "NO_REPO_ACCESS"}],
140+
// which could be handled separately
146141
}
147142

148-
async postProjectEnvironment(id, body): Promise<PostProjectEnvironmentResponse> {
143+
async postProjectEnvironment(
144+
id: string,
145+
body: {source: {provider: "github"; provider_id: string; url: string; branch: string}}
146+
): Promise<PostProjectEnvironmentResponse> {
149147
const url = new URL(`/cli/project/${id}/environment`, this._apiOrigin);
150148
return await this._fetch<PostProjectEnvironmentResponse>(url, {
151149
method: "POST",
@@ -155,9 +153,7 @@ export class ObservableApiClient {
155153
}
156154

157155
async postProjectBuild(id): Promise<{id: string}> {
158-
return await this._fetch<{id: string}>(new URL(`/cli/project/${id}/build`, this._apiOrigin), {
159-
method: "POST"
160-
});
156+
return await this._fetch<{id: string}>(new URL(`/cli/project/${id}/build`, this._apiOrigin), {method: "POST"});
161157
}
162158

163159
async postProject({

0 commit comments

Comments
 (0)